Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ land in the test project's compile references and collide with the in-box types
| `MetadataFileNames` | `IReadOnlyList<string>` | 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

Expand Down
58 changes: 58 additions & 0 deletions SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,64 @@
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);

Check warning on line 85 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_c&open=AaGj-QH7bL4Df1eBbO_c&pullRequest=64
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
string message = result.Diagnostics[0].GetMessage();
StringAssert.Contains(message, "/a/Legacy/things.json");

Check warning on line 88 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_a&open=AaGj-QH7bL4Df1eBbO_a&pullRequest=64
StringAssert.Contains(message, "/a/Metadata/things.json");

Check warning on line 89 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_b&open=AaGj-QH7bL4Df1eBbO_b&pullRequest=64
Assert.AreEqual(0, result.GeneratedTrees.Length);

Check warning on line 90 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_d&open=AaGj-QH7bL4Df1eBbO_d&pullRequest=64
}

[TestMethod]
public void SameNamedMetadataFilesWithIdenticalContentsAreNotReported()
{
GeneratorDriverRunResult result = DriveWithTexts(
("/a/Legacy/things.json", NewThings),
("/a/Metadata/things.json", NewThings));

Assert.AreEqual(0, result.Diagnostics.Length);

Check warning on line 100 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_f&open=AaGj-QH7bL4Df1eBbO_f&pullRequest=64
Assert.AreEqual(1, result.GeneratedTrees.Length);

Check warning on line 101 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_g&open=AaGj-QH7bL4Df1eBbO_g&pullRequest=64
StringAssert.Contains(result.GeneratedTrees[0].ToString(), "class New");

Check warning on line 102 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_e&open=AaGj-QH7bL4Df1eBbO_e&pullRequest=64
}

[TestMethod]
public void AnEmptyDuplicateOfAMetadataFileIsNotAConflict()
{
GeneratorDriverRunResult result = DriveWithTexts(
("/a/Legacy/things.json", string.Empty),
("/a/Metadata/things.json", NewThings));

Assert.AreEqual(0, result.Diagnostics.Length);

Check warning on line 112 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_i&open=AaGj-QH7bL4Df1eBbO_i&pullRequest=64
StringAssert.Contains(result.GeneratedTrees[0].ToString(), "class New");

Check warning on line 113 in SourceGeneratorToolkit.Test/GeneratorBaseTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGj-QH7bL4Df1eBbO_h&open=AaGj-QH7bL4Df1eBbO_h&pullRequest=64
}

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()
{
Expand Down
40 changes: 30 additions & 10 deletions SourceGeneratorToolkit/GeneratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <remarks>
/// 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
/// <see cref="Generate"/>.
/// </remarks>
protected abstract DiagnosticDescriptor MetadataParseFailed { get; }

/// <inheritdoc/>
Expand All @@ -72,19 +77,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
{
ImmutableArray<MetadataFile> 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<string, MetadataFile> 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<string, MetadataFile> byName = new(StringComparer.Ordinal);
HashSet<string> conflicted = new(StringComparer.Ordinal);
foreach (IGrouping<string, MetadataFile> 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);
}
Expand Down
18 changes: 9 additions & 9 deletions SourceGeneratorToolkit/MetadataFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ public sealed class MetadataFile(string fileName, string text, SourceText? sourc
PropertyNameCaseInsensitive = true
};

/// <summary>
/// The full path, held as a field rather than read from the constructor parameter so
/// <see cref="Equals(MetadataFile)"/> can compare it on another instance.
/// </summary>
private readonly string filePath = path;

/// <summary>Gets the file's name, without its directory.</summary>
public string FileName { get; } = fileName;

/// <summary>Gets the file's contents.</summary>
public string Text { get; } = text;

/// <summary>
/// Gets the file's full path. Held rather than read from the constructor parameter so
/// <see cref="Equals(MetadataFile)"/> can compare it on another instance.
/// </summary>
internal string FilePath { get; } = path;

/// <inheritdoc/>
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);

/// <inheritdoc/>
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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));
}
}

Expand Down
Loading