Update converted source line numbers when conversion is saved
The conversion process has a good chance of changing the shape of .wxs source file. Therefore, the old line numbers may be wrong and should be replaced with the line numbers from the converted file when it is saved. Fixes 6969
Rob Mensching committed
Oct 25, 2022 at 07:01 UTC
217182f85fa737d20fb5846f395cabfa599bf1c6
5 files changed
+272
-61
src/wix/WixToolset.Converters/HashSetExtensions.cs
new
+17
@@ -0,0 +1,17 @@
1
+// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information.
2
+
3
+namespace WixToolset.Converters
4
+{
5
+ using System.Collections.Generic;
6
+
7
+ internal static class HashSetExtensions
8
+ {
9
+ public static void AddRange<T>(this HashSet<T> set, IEnumerable<T> values)
10
+ {
11
+ foreach (var value in values)
12
+ {
13
+ set.Add(value);
14
+ }
15
+ }
16
+ }
17
+}
src/wix/WixToolset.Converters/WixConverter.cs
+206
-57
@@ -11,6 +11,7 @@ namespace WixToolset.Converters
11
using System.Text.RegularExpressions;
12
using System.Xml;
13
using System.Xml.Linq;
14
+ using System.Xml.XPath;
15
using WixToolset.Data;
16
using WixToolset.Data.WindowsInstaller;
17
using WixToolset.Extensibility.Services;
@@ -272,6 +273,8 @@ namespace WixToolset.Converters
273
{ WixConverter.WixLocalizationElementWithoutNamespaceName, this.ConvertWixLocalizationElementWithoutNamespace },
274
};
275
276
+ this.ConversionMessages = new List<Message>();
277
+
278
this.Messaging = messaging;
279
280
this.IndentationAmount = indentationAmount;
@@ -285,7 +288,7 @@ namespace WixToolset.Converters
288
289
private CustomTableTarget CustomTableSetting { get; }
290
288
- private int Messages { get; set; }
291
+ private List<Message> ConversionMessages { get; }
292
293
private HashSet<ConverterTestType> ErrorsAsWarnings { get; set; }
294
@@ -308,54 +311,35 @@ namespace WixToolset.Converters
311
/// </summary>
312
/// <param name="sourceFile">The file to convert.</param>
313
/// <param name="saveConvertedFile">Option to save the converted Messages that are found.</param>
311
- /// <returns>The number of Messages found.</returns>
314
+ /// <returns>The number of conversions found.</returns>
315
public int ConvertFile(string sourceFile, bool saveConvertedFile)
316
{
314
- var document = this.OpenSourceFile(sourceFile);
317
+ var savedDocument = false;
318
316
- if (document is null)
319
+ if (this.TryOpenSourceFile(sourceFile, out var document))
320
{
318
- return 1;
319
- }
320
-
321
- this.ConvertDocument(document);
321
+ this.Convert(document);
322
323
- // Fix Messages if requested and necessary.
324
- if (saveConvertedFile && 0 < this.Messages)
325
- {
326
- this.SaveDocument(document);
323
+ // Fix Messages if requested and necessary.
324
+ if (saveConvertedFile && 0 < this.ConversionMessages.Count)
325
+ {
326
+ savedDocument = this.SaveDocument(document);
327
+ }
328
}
329
329
- return this.Messages;
330
+ return this.ReportMessages(document, savedDocument);
331
}
332
333
/// <summary>
334
/// Convert a document.
335
/// </summary>
336
/// <param name="document">The document to convert.</param>
336
- /// <returns>The number of Messages found.</returns>
337
+ /// <returns>The number of conversions found.</returns>
338
public int ConvertDocument(XDocument document)
339
{
339
- // Reset the instance info.
340
- this.Messages = 0;
341
- this.SourceVersion = 0;
342
- this.Operation = ConvertOperation.Convert;
343
-
344
- // Remove the declaration.
345
- if (null != document.Declaration
346
- && this.OnInformation(ConverterTestType.DeclarationPresent, null, "This file contains an XML declaration on the first line."))
347
- {
348
- document.Declaration = null;
349
- TrimLeadingText(document);
350
- }
351
-
352
- this.XRoot = document.Root;
353
-
354
- // Start converting the nodes at the top.
355
- this.ConvertNodes(document.Nodes(), 0);
356
- this.RemoveUnusedNamespaces(document.Root);
340
+ this.Convert(document);
341
358
- return this.Messages;
342
+ return this.ReportMessages(document, false);
343
}
344
345
/// <summary>
@@ -366,22 +350,20 @@ namespace WixToolset.Converters
350
/// <returns>The number of Messages found.</returns>
351
public int FormatFile(string sourceFile, bool saveConvertedFile)
352
{
369
- var document = this.OpenSourceFile(sourceFile);
353
+ var savedDocument = false;
354
371
- if (document is null)
355
+ if (this.TryOpenSourceFile(sourceFile, out var document))
356
{
373
- return 1;
374
- }
375
-
376
- this.FormatDocument(document);
357
+ this.FormatDocument(document);
358
378
- // Fix Messages if requested and necessary.
379
- if (saveConvertedFile && 0 < this.Messages)
380
- {
381
- this.SaveDocument(document);
359
+ // Fix Messages if requested and necessary.
360
+ if (saveConvertedFile && 0 < this.ConversionMessages.Count)
361
+ {
362
+ savedDocument = this.SaveDocument(document);
363
+ }
364
}
365
384
- return this.Messages;
366
+ return this.ReportMessages(document, savedDocument);
367
}
368
369
/// <summary>
@@ -390,44 +372,74 @@ namespace WixToolset.Converters
372
/// <param name="document">The document to format.</param>
373
/// <returns>The number of Messages found.</returns>
374
public int FormatDocument(XDocument document)
375
+ {
376
+ this.Format(document);
377
+
378
+ return this.ReportMessages(document, false);
379
+ }
380
+
381
+ private void Convert(XDocument document)
382
{
383
// Reset the instance info.
395
- this.Messages = 0;
384
+ this.ConversionMessages.Clear();
385
this.SourceVersion = 0;
397
- this.Operation = ConvertOperation.Format;
386
+ this.Operation = ConvertOperation.Convert;
387
388
// Remove the declaration.
389
if (null != document.Declaration
401
- && this.OnInformation(ConverterTestType.DeclarationPresent, null, "This file contains an XML declaration on the first line."))
390
+ && this.OnInformation(ConverterTestType.DeclarationPresent, document, "This file contains an XML declaration on the first line."))
391
{
392
document.Declaration = null;
393
TrimLeadingText(document);
394
}
395
396
+ this.XRoot = document.Root;
397
+
398
// Start converting the nodes at the top.
399
this.ConvertNodes(document.Nodes(), 0);
400
this.RemoveUnusedNamespaces(document.Root);
401
+ }
402
+
403
+ private void Format(XDocument document)
404
+ {
405
+ // Reset the instance info.
406
+ this.ConversionMessages.Clear();
407
+ this.SourceVersion = 0;
408
+ this.Operation = ConvertOperation.Format;
409
411
- return this.Messages;
410
+ // Remove the declaration.
411
+ if (null != document.Declaration
412
+ && this.OnInformation(ConverterTestType.DeclarationPresent, document, "This file contains an XML declaration on the first line."))
413
+ {
414
+ document.Declaration = null;
415
+ TrimLeadingText(document);
416
+ }
417
+
418
+ this.XRoot = document.Root;
419
+
420
+ // Start converting the nodes at the top.
421
+ this.ConvertNodes(document.Nodes(), 0);
422
+ this.RemoveUnusedNamespaces(document.Root);
423
}
424
414
- private XDocument OpenSourceFile(string sourceFile)
425
+ private bool TryOpenSourceFile(string sourceFile, out XDocument document)
426
{
427
this.SourceFile = sourceFile;
428
429
try
430
{
420
- return XDocument.Load(this.SourceFile, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
431
+ document = XDocument.Load(this.SourceFile, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
432
}
433
catch (XmlException e)
434
{
435
this.OnError(ConverterTestType.XmlException, null, "The xml is invalid. Detail: '{0}'", e.Message);
436
+ document = null;
437
}
438
427
- return null;
439
+ return document != null;
440
}
441
430
- private void SaveDocument(XDocument document)
442
+ private bool SaveDocument(XDocument document)
443
{
444
var ignoreDeclarationError = this.IgnoreErrors.Contains(ConverterTestType.DeclarationPresent);
445
@@ -437,11 +449,15 @@ namespace WixToolset.Converters
449
{
450
document.Save(writer);
451
}
452
+
453
+ return true;
454
}
455
catch (UnauthorizedAccessException)
456
{
457
this.OnError(ConverterTestType.UnauthorizedAccessException, null, "Could not write to file.");
458
}
459
+
460
+ return false;
461
}
462
463
private void ConvertNodes(IEnumerable<XNode> nodes, int level)
@@ -2129,6 +2145,14 @@ namespace WixToolset.Converters
2145
convertedAttribute = new XAttribute(ns.GetName(attribute.Name.LocalName), attribute.Value);
2146
}
2147
2148
+ if (convertedAttribute != attribute)
2149
+ {
2150
+ foreach (var message in attribute.Annotations<Message>())
2151
+ {
2152
+ convertedAttribute.AddAnnotation(message);
2153
+ }
2154
+ }
2155
+
2156
element.Add(convertedAttribute);
2157
}
2158
}
@@ -2214,6 +2238,110 @@ namespace WixToolset.Converters
2238
}
2239
}
2240
2241
+ private int ReportMessages(XDocument document, bool saved)
2242
+ {
2243
+ var conversionCount = this.ConversionMessages.Count;
2244
+
2245
+ // If the converted/formatted document was saved, update the source line numbers
2246
+ // in the messages since they will possibly be wrong.
2247
+ if (saved && conversionCount > 0)
2248
+ {
2249
+ var fixedupMessages = new HashSet<Message>();
2250
+
2251
+ // Load the converted document so we can look up new line numbers for
2252
+ // messages.
2253
+ var convertedDocument = XDocument.Load(this.SourceFile, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
2254
+ var convertedNavigator = convertedDocument.CreateNavigator();
2255
+
2256
+ // Look through all nodes in the document and try to fix up their line numbers.
2257
+ foreach (var elements in document.Descendants())
2258
+ {
2259
+ var fixedup = this.FixupMessageLineNumbers(elements, convertedNavigator);
2260
+ fixedupMessages.AddRange(fixedup);
2261
+
2262
+ // Attributes are not considered nodes so they must be enumerated independently.
2263
+ if (elements is XElement element)
2264
+ {
2265
+ foreach (var attribute in element.Attributes())
2266
+ {
2267
+ fixedup = this.FixupMessageLineNumbers(attribute, convertedNavigator);
2268
+ fixedupMessages.AddRange(fixedup);
2269
+ }
2270
+ }
2271
+ }
2272
+
2273
+ // For any messages that couldn't be fixed up, remove their line numbers since they point at lines
2274
+ // that are possibly wrong after the conversion.
2275
+ for (var i = 0; i < this.ConversionMessages.Count; ++i)
2276
+ {
2277
+ var message = this.ConversionMessages[i];
2278
+
2279
+ if (!fixedupMessages.Contains(message))
2280
+ {
2281
+ this.ConversionMessages[i] = new Message(new SourceLineNumber(this.SourceFile), message.Level, message.Id, message.ResourceNameOrFormat, message.MessageArgs);
2282
+ }
2283
+ }
2284
+ }
2285
+
2286
+ foreach (var message in this.ConversionMessages)
2287
+ {
2288
+ this.Messaging.Write(message);
2289
+ }
2290
+
2291
+ return conversionCount;
2292
+ }
2293
+
2294
+ private IReadOnlyCollection<Message> FixupMessageLineNumbers(XObject obj, XPathNavigator savedDocument)
2295
+ {
2296
+ var messages = obj.Annotations<Message>().ToList();
2297
+
2298
+ if (messages.Count == 0)
2299
+ {
2300
+ return Array.Empty<Message>();
2301
+ }
2302
+
2303
+ // We can't fix up line numbers based on attributes but we can fix up using their parent element, so do so.
2304
+ if (obj is XAttribute attribute)
2305
+ {
2306
+ obj = attribute.Parent;
2307
+ }
2308
+
2309
+ var fixedupMessages = new List<Message>();
2310
+
2311
+ var modifiedLineNumber = this.GetModifiedNodeLineNumber((XElement)obj, savedDocument);
2312
+
2313
+ foreach (var message in messages)
2314
+ {
2315
+ var fixedupMessage = message;
2316
+
2317
+ // If the line number wasn't modified, the existing message's source line nuber is correct
2318
+ // so skip creating a new one.
2319
+ if (modifiedLineNumber != null)
2320
+ {
2321
+ var index = this.ConversionMessages.IndexOf(message);
2322
+
2323
+ fixedupMessage = new Message(modifiedLineNumber, message.Level, message.Id, message.ResourceNameOrFormat, message.MessageArgs);
2324
+
2325
+ this.ConversionMessages[index] = fixedupMessage;
2326
+ }
2327
+
2328
+ fixedupMessages.Add(fixedupMessage);
2329
+ }
2330
+
2331
+ return fixedupMessages;
2332
+ }
2333
+
2334
+ private SourceLineNumber GetModifiedNodeLineNumber(XElement element, XPathNavigator savedDocument)
2335
+ {
2336
+ var xpathToOld = CalculateXPath(element);
2337
+
2338
+ var newNode = savedDocument.SelectSingleNode(xpathToOld);
2339
+ var newLineNumber = (newNode as IXmlLineInfo).LineNumber;
2340
+
2341
+ var oldLineNumber = (element as IXmlLineInfo)?.LineNumber;
2342
+ return (oldLineNumber != newLineNumber) ? new SourceLineNumber(this.SourceFile, newLineNumber) : null;
2343
+ }
2344
+
2345
/// <summary>
2346
/// Output an error message to the console.
2347
/// </summary>
@@ -2250,10 +2378,8 @@ namespace WixToolset.Converters
2378
return false;
2379
}
2380
2253
- // Increase the message count.
2254
- this.Messages++;
2381
+ var sourceLine = SourceLineNumberForXmlLineInfo(this.SourceFile, node);
2382
2256
- var sourceLine = (null == node) ? new SourceLineNumber(this.SourceFile ?? "wix.exe") : new SourceLineNumber(this.SourceFile, ((IXmlLineInfo)node).LineNumber);
2383
var prefix = String.Empty;
2384
if (level == MessageLevel.Information)
2385
{
@@ -2268,11 +2394,34 @@ namespace WixToolset.Converters
2394
2395
var msg = new Message(sourceLine, level, (int)converterTestType, format, args);
2396
2271
- this.Messaging.Write(msg);
2397
+ // Add the message as a node annotation so it could be possible to remap source line numbers
2398
+ // to their new locations after converting/formatting (since lines of code could be moved).
2399
+ node?.AddAnnotation(msg);
2400
+ this.ConversionMessages.Add(msg);
2401
2402
return true;
2403
}
2404
2405
+ private static string CalculateXPath(XElement element)
2406
+ {
2407
+ var builder = new StringBuilder();
2408
+
2409
+ while (element != null)
2410
+ {
2411
+ var index = element.Parent?.Elements().TakeWhile(e => e != element).Count() ?? 0;
2412
+ builder.Insert(0, $"/*[{index + 1}]");
2413
+
2414
+ element = element.Parent;
2415
+ }
2416
+
2417
+ return builder.ToString();
2418
+ }
2419
+
2420
+ private static SourceLineNumber SourceLineNumberForXmlLineInfo(string sourceFile, IXmlLineInfo lineInfo)
2421
+ {
2422
+ return (lineInfo?.HasLineInfo() == true) ? new SourceLineNumber(sourceFile, lineInfo.LineNumber) : new SourceLineNumber(sourceFile ?? "wix.exe");
2423
+ }
2424
+
2425
/// <summary>
2426
/// Return an identifier based on passed file/directory name
2427
/// </summary>
@@ -2338,7 +2487,7 @@ namespace WixToolset.Converters
2487
comments = initialComments;
2488
var nonCommentNodes = new List<XNode>();
2489
2341
- foreach(var node in nodes)
2490
+ foreach (var node in nodes)
2491
{
2492
if (XmlNodeType.Comment == node.NodeType)
2493
{
src/wix/test/WixToolsetTest.Converters/ConverterFixture.cs
+37
-1
@@ -3,6 +3,8 @@
3
namespace WixToolsetTest.Converters
4
{
5
using System;
6
+ using System.IO;
7
+ using System.Linq;
8
using System.Xml.Linq;
9
using WixBuildTools.TestSupport;
10
using WixToolset.Converters;
@@ -62,7 +64,7 @@ namespace WixToolsetTest.Converters
64
var document = XDocument.Parse(parse, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
65
66
var messaging = new MockMessaging();
65
- var converter = new WixConverter(messaging, 2, ignoreErrors: new[] { "DeclarationPresent" } );
67
+ var converter = new WixConverter(messaging, 2, ignoreErrors: new[] { "DeclarationPresent" });
68
69
var errors = converter.ConvertDocument(document);
70
@@ -102,6 +104,40 @@ namespace WixToolsetTest.Converters
104
WixAssert.CompareLineByLine(expected, actual);
105
}
106
107
+ [Fact]
108
+ public void CanConvertMainNamespaceFromDisk()
109
+ {
110
+ var dataFolder = TestData.Get("TestData", "FixDeclarationAndNamespace");
111
+
112
+ var expected = new[]
113
+ {
114
+ "<Wix xmlns=\"http://wixtoolset.org/schemas/v4/wxs\">",
115
+ " <Fragment />",
116
+ "</Wix>",
117
+ };
118
+
119
+ using (var fs = new TestDataFolderFileSystem())
120
+ {
121
+ fs.Initialize(dataFolder);
122
+ var path = Path.Combine(fs.BaseFolder, "FixDeclarationAndNamespace.wxs");
123
+
124
+ var messaging = new MockMessaging();
125
+ var converter = new WixConverter(messaging, 2, null, null);
126
+
127
+ var errors = converter.ConvertFile(path, true);
128
+
129
+ var messages = messaging.Messages.Select(m => $"{Path.GetFileName(m.SourceLineNumbers.FileName)}({m.SourceLineNumbers.LineNumber}) {m.ToString()}").ToArray();
130
+ WixAssert.CompareLineByLine(new[]
131
+ {
132
+ "FixDeclarationAndNamespace.wxs() [Converted] This file contains an XML declaration on the first line. (DeclarationPresent)",
133
+ "FixDeclarationAndNamespace.wxs(1) [Converted] The namespace 'http://schemas.microsoft.com/wix/2006/wi' is out of date. It must be 'http://wixtoolset.org/schemas/v4/wxs'. (XmlnsValueWrong)"
134
+ }, messages);
135
+
136
+ var actual = File.ReadAllLines(path);
137
+ WixAssert.CompareLineByLine(expected, actual);
138
+ }
139
+ }
140
+
141
[Fact]
142
public void CanConvertNamedMainNamespace()
143
{
src/wix/test/WixToolsetTest.Converters/ConverterIntegrationFixture.cs
+8
-3
@@ -85,9 +85,14 @@ namespace WixToolsetTest.Converters
85
86
var messaging = new MockMessaging();
87
var converter = new WixConverter(messaging, 4);
88
- var errors = converter.ConvertFile(targetFile, true);
89
-
90
- Assert.Single(messaging.Messages.Where(m => m.Id == 5/*WixConverter.ConverterTestType.UnauthorizedAccessException*/));
88
+ var convertedCount = converter.ConvertFile(targetFile, true);
89
+
90
+ var errors = messaging.Messages.Where(m => m.Level == WixToolset.Data.MessageLevel.Error).Select(m => m.ToString()).ToArray();
91
+ WixAssert.CompareLineByLine(new[]
92
+ {
93
+ "Could not write to file. (UnauthorizedAccessException)"
94
+ }, errors);
95
+ Assert.Equal(10, convertedCount);
96
}
97
}
98
src/wix/test/WixToolsetTest.Converters/TestData/FixDeclarationAndNamespace/FixDeclarationAndNamespace.wxs
new
+4
@@ -0,0 +1,4 @@
1
+<?xml version='1.0' encoding='utf-8'?>
2
+<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
3
+ <Fragment />
4
+</Wix>