diff --git a/README.md b/README.md index 20bcdf9..2b33d85 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ land in the test project's compile references and collide with the in-box types | `MetadataFileNames` | `IReadOnlyList` | The metadata files this generator reads, without their directories. Abstract. | | `Diagnostics` | `DiagnosticCatalog` | The catalogue this generator's diagnostics are allocated from. Abstract. | | `MetadataFileMissing` | `DiagnosticDescriptor` | Reported when a declared file is not in the compilation. Takes the file name. Abstract. | -| `MetadataParseFailed` | `DiagnosticDescriptor` | Reported when a file cannot be parsed. Takes the file name and the reason. Abstract. | +| `MetadataParseFailed` | `DiagnosticDescriptor` | Reported when a file cannot be parsed, or when several files of one declared name have different contents (the reason lists their paths, and none is used). Takes the file name and the reason. Abstract. | #### Methods diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index 90e58f9..efd5aaf 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -62,6 +62,64 @@ public void AMultiFileGeneratorReportsEachFileThatIsAbsent() StringAssert.Contains(result.Diagnostics[0].GetMessage(), "others.json"); } + private const string OldThings = """{ "things": [ { "name": "Old" } ] }"""; + + private const string NewThings = """{ "things": [ { "name": "New" } ] }"""; + + private static GeneratorDriverRunResult DriveWithTexts(params (string Path, string Text)[] files) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new ThingsGenerator().AsSourceGenerator()], + [.. files.Select(file => (AdditionalText)new PathedText(file.Path, file.Text))]); + + return driver.RunGenerators(CSharpCompilation.Create("Duplicates")).GetRunResult(); + } + + [TestMethod] + public void SameNamedMetadataFilesWithDifferentContentsAreReportedWithBothPaths() + { + GeneratorDriverRunResult result = DriveWithTexts( + ("/a/Legacy/things.json", OldThings), + ("/a/Metadata/things.json", NewThings)); + + Assert.AreEqual(1, result.Diagnostics.Length); + Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id); + string message = result.Diagnostics[0].GetMessage(); + StringAssert.Contains(message, "/a/Legacy/things.json"); + StringAssert.Contains(message, "/a/Metadata/things.json"); + Assert.AreEqual(0, result.GeneratedTrees.Length); + } + + [TestMethod] + public void SameNamedMetadataFilesWithIdenticalContentsAreNotReported() + { + GeneratorDriverRunResult result = DriveWithTexts( + ("/a/Legacy/things.json", NewThings), + ("/a/Metadata/things.json", NewThings)); + + Assert.AreEqual(0, result.Diagnostics.Length); + Assert.AreEqual(1, result.GeneratedTrees.Length); + StringAssert.Contains(result.GeneratedTrees[0].ToString(), "class New"); + } + + [TestMethod] + public void AnEmptyDuplicateOfAMetadataFileIsNotAConflict() + { + GeneratorDriverRunResult result = DriveWithTexts( + ("/a/Legacy/things.json", string.Empty), + ("/a/Metadata/things.json", NewThings)); + + Assert.AreEqual(0, result.Diagnostics.Length); + StringAssert.Contains(result.GeneratedTrees[0].ToString(), "class New"); + } + + private sealed class PathedText(string path, string text) : AdditionalText + { + public override string Path { get; } = path; + + public override SourceText GetText(System.Threading.CancellationToken cancellationToken = default) => SourceText.From(text); + } + [TestMethod] public void AMalformedMetadataFileIsReportedRatherThanSwallowed() { diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index ecb5f50..3ae6cac 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -49,6 +49,11 @@ public abstract class GeneratorBase : IIncrementalGenerator /// Gets the descriptor reported when a metadata file cannot be parsed. Its message format takes /// the file name and the reason. /// + /// + /// Also reported when the compilation holds several files of one declared name with different + /// contents. The reason then lists their paths, and none of them is handed to + /// . + /// protected abstract DiagnosticDescriptor MetadataParseFailed { get; } /// @@ -72,19 +77,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { ImmutableArray files = input.Files; - // A duplicate name means the same metadata reached the compilation twice; the first with - // content wins. An empty file is still present, so it is handed on and fails to parse - // rather than being reported as missing. - Dictionary byName = files - .GroupBy(file => file.FileName, StringComparer.Ordinal) - .ToDictionary( - group => group.Key, - group => group.FirstOrDefault(file => file.Text.Length > 0) ?? group.First(), - StringComparer.Ordinal); + // A duplicate name with identical contents means the same metadata reached the compilation + // twice; the first with content wins. An empty file is still present, so it is handed on + // and fails to parse rather than being reported as missing. + Dictionary byName = new(StringComparer.Ordinal); + HashSet conflicted = new(StringComparer.Ordinal); + foreach (IGrouping group in files.GroupBy(file => file.FileName, StringComparer.Ordinal)) + { + // Same-named files with different contents are not one file supplied twice. Picking one + // would generate from whichever the build happened to list first, with nothing to say + // the other was ignored, so neither is used and both paths are reported. + MetadataFile[] withContent = [.. group.Where(file => file.Text.Length > 0)]; + if (withContent.Select(file => file.Text).Distinct(StringComparer.Ordinal).Skip(1).Any()) + { + conflicted.Add(group.Key); + productionContext.Report( + MetadataParseFailed, + group.Key, + "it was supplied more than once with different contents: " + + string.Join(", ", withContent.Select(file => file.FilePath))); + continue; + } + + byName[group.Key] = withContent.FirstOrDefault() ?? group.First(); + } // A missing file must say so. Producing no output and no explanation is indistinguishable // from a generator that simply had nothing to emit. - foreach (string name in wanted.Where(name => !byName.ContainsKey(name))) + foreach (string name in wanted.Where(name => !byName.ContainsKey(name) && !conflicted.Contains(name))) { productionContext.Report(MetadataFileMissing, name); } diff --git a/SourceGeneratorToolkit/MetadataFile.cs b/SourceGeneratorToolkit/MetadataFile.cs index 5117b04..1e6aec7 100644 --- a/SourceGeneratorToolkit/MetadataFile.cs +++ b/SourceGeneratorToolkit/MetadataFile.cs @@ -32,23 +32,23 @@ public sealed class MetadataFile(string fileName, string text, SourceText? sourc PropertyNameCaseInsensitive = true }; - /// - /// The full path, held as a field rather than read from the constructor parameter so - /// can compare it on another instance. - /// - private readonly string filePath = path; - /// Gets the file's name, without its directory. public string FileName { get; } = fileName; /// Gets the file's contents. public string Text { get; } = text; + /// + /// Gets the file's full path. Held rather than read from the constructor parameter so + /// can compare it on another instance. + /// + internal string FilePath { get; } = path; + /// public bool Equals(MetadataFile? other) => other is not null && string.Equals(FileName, other.FileName, StringComparison.Ordinal) - && string.Equals(filePath, other.filePath, StringComparison.Ordinal) + && string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) && string.Equals(Text, other.Text, StringComparison.Ordinal); /// @@ -60,7 +60,7 @@ public override int GetHashCode() unchecked { int hash = StringComparer.Ordinal.GetHashCode(FileName); - hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(filePath); + hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(FilePath); return (hash * 397) ^ StringComparer.Ordinal.GetHashCode(Text); } } @@ -301,7 +301,7 @@ private int Utf8Bytes(int index, int characters) => private Location LocationAt(int index, int length) { TextSpan span = new(index, length); - return Location.Create(filePath, span, sourceText!.Lines.GetLinePositionSpan(span)); + return Location.Create(FilePath, span, sourceText!.Lines.GetLinePositionSpan(span)); } }