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
15 changes: 11 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@ side of it.
for `things.json`.
- **A missing or malformed file always reports.** Producing no output and no explanation is
indistinguishable from a generator that had nothing to emit, and swallowing a `JsonException`
means malformed metadata silently generates something wrong.
means malformed metadata silently generates something wrong. "Malformed" is wider than bad JSON:
`JsonSerializer` reports a shape it cannot construct as `NotSupportedException` and some
converter-configuration failures as `InvalidOperationException`, neither of which derives from
`JsonException`. `Deserialize<T>` catches all three, because one escaping to the Roslyn driver
becomes a generic CS8785 that names neither the file nor the reason — and abandons every other
file in the same invocation.
- **The harness is a separate package, not a separate test project.** A consumer testing their own
generator needs it, so it ships. It is separate from the analyzer package because it reads files,
which RS1035 bans for code that runs in an analyzer host — and `EnforceExtendedAnalyzerRules`
Expand All @@ -138,9 +143,11 @@ that matters.

- `SourceGeneratorToolkit.Test/Metadata/*.json` are the fixtures. They are copied to the output
directory and supplied to the driver the way MSBuild's `AdditionalFiles` item group supplies them.
- `TestGenerators.cs` holds `ThingsGenerator` (single file), `PairGenerator` (two files) and
`AbsentFileGenerator` (declares a file nothing supplies), plus the `TST` diagnostic catalogue that
stands in for a consumer's own.
- `TestGenerators.cs` holds `ThingsGenerator` (single file), `PairGenerator` (two files),
`AbsentFileGenerator` (declares a file nothing supplies), `UnsupportedShapeGenerator` and
`AmbiguousConstructorGenerator` (metadata shapes `System.Text.Json` refuses to construct) and
`ResilientPairGenerator` (two files, emitting from whichever parsed), plus the `TST` diagnostic
catalogue that stands in for a consumer's own.
- Use explicit types (no `var`) in test bodies.

## CI/CD
Expand Down
54 changes: 54 additions & 0 deletions SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,60 @@
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
}

[TestMethod]
public void AShapeTheSerializerCannotConstructIsReportedRatherThanCrashingTheGenerator()
{
// An interface-typed model throws NotSupportedException, not JsonException. Uncaught, it
// leaves the driver to report its own CS8785, which names neither the file nor the reason.
GeneratorRunResult result = Harness.Run(new UnsupportedShapeGenerator());

Assert.IsNull(result.Exception, $"The generator threw instead of reporting a diagnostic: {result.Exception}");
Assert.AreEqual(0, result.GeneratedSources.Length);

Check warning on line 94 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=AaCf6x6LFlmS90VF9FU4&open=AaCf6x6LFlmS90VF9FU4&pullRequest=6
Assert.AreEqual(1, result.Diagnostics.Length);

Check warning on line 95 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=AaCf6x6LFlmS90VF9FU5&open=AaCf6x6LFlmS90VF9FU5&pullRequest=6
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
Assert.AreEqual(DiagnosticSeverity.Error, result.Diagnostics[0].Severity);
StringAssert.Contains(result.Diagnostics[0].GetMessage(), "things.json");

Check warning on line 98 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=AaCf6x6LFlmS90VF9FU1&open=AaCf6x6LFlmS90VF9FU1&pullRequest=6
}

[TestMethod]
public void AmbiguousConstructorsAreReportedRatherThanCrashingTheGenerator()
{
// The other NotSupportedException shape: two parameterized constructors, no [JsonConstructor].
GeneratorRunResult result = Harness.Run(new AmbiguousConstructorGenerator());

Assert.IsNull(result.Exception, $"The generator threw instead of reporting a diagnostic: {result.Exception}");
Assert.AreEqual(0, result.GeneratedSources.Length);

Check warning on line 108 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=AaCf6x6LFlmS90VF9FU2&open=AaCf6x6LFlmS90VF9FU2&pullRequest=6
Assert.AreEqual(1, result.Diagnostics.Length);

Check warning on line 109 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=AaCf6x6LFlmS90VF9FU3&open=AaCf6x6LFlmS90VF9FU3&pullRequest=6
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
}

[TestMethod]
public void AConverterConfigurationFailureIsReportedRatherThanCrashingTheGenerator()
{
// Two properties claiming one JSON name is neither malformed input nor an unconstructable
// type: System.Text.Json cannot build the contract and says so with InvalidOperationException.
GeneratorRunResult result = Harness.Run(new ConflictingNamesGenerator());

Assert.IsNull(result.Exception, $"The generator threw instead of reporting a diagnostic: {result.Exception}");
Assert.AreEqual(0, result.GeneratedSources.Length);

Check warning on line 121 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=AaCf9WeGImpJCa55ma0q&open=AaCf9WeGImpJCa55ma0q&pullRequest=6
Assert.AreEqual(1, result.Diagnostics.Length);

Check warning on line 122 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=AaCf9WeGImpJCa55ma0r&open=AaCf9WeGImpJCa55ma0r&pullRequest=6
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
}

[TestMethod]
public void OneFileFailingOnAnUnsupportedShapeStillLeavesTheOthersProcessed()
{
// A throw out of Deserialize abandons the whole RegisterSourceOutput callback, so every other
// declared file goes unread. A reported diagnostic stops at the file it belongs to.
GeneratorRunResult result = Harness.Run(new ResilientPairGenerator());

Assert.IsNull(result.Exception, $"The generator threw instead of reporting a diagnostic: {result.Exception}");
Assert.AreEqual(1, result.Diagnostics.Length);

Check warning on line 134 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=AaCf6x6LFlmS90VF9FU7&open=AaCf6x6LFlmS90VF9FU7&pullRequest=6
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
Assert.AreEqual(1, result.GeneratedSources.Length);

Check warning on line 136 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=AaCf6x6LFlmS90VF9FU8&open=AaCf6x6LFlmS90VF9FU8&pullRequest=6
StringAssert.Contains(result.GeneratedSources[0].SourceText.ToString(), "1 others, things unread");

Check warning on line 137 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=AaCf6x6LFlmS90VF9FU6&open=AaCf6x6LFlmS90VF9FU6&pullRequest=6
}

[TestMethod]
public void AJsonNullDocumentIsReportedRatherThanTreatedAsEmpty()
{
Expand Down
127 changes: 127 additions & 0 deletions SourceGeneratorToolkit.Test/TestGenerators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.SourceGeneratorToolkit.Test;

using System.Collections.Generic;
using System.Text.Json.Serialization;
using ktsu.CodeBlocker;
using ktsu.CodeBlocker.Templates;
using Microsoft.CodeAnalysis;
Expand All @@ -29,6 +30,49 @@ public sealed class OthersMetadata
public List<ThingDefinition> Others { get; set; } = [];
}

/// <summary>
/// An interface-typed metadata shape. Modelling "one of several variant kinds" this way is an
/// ordinary choice, and <c>System.Text.Json</c> refuses it with <see cref="System.NotSupportedException"/>
/// rather than <see cref="System.Text.Json.JsonException"/> — a sibling type, not a subclass.
/// </summary>
public interface IThingsMetadata
{
public List<ThingDefinition> Things { get; }
}

/// <summary>
/// The other shape <c>System.Text.Json</c> refuses the same way: two public parameterized
/// constructors and no <c>[JsonConstructor]</c> to pick between them.
/// </summary>
public sealed class AmbiguousMetadata
{
public AmbiguousMetadata(string name) => Name = name;

public AmbiguousMetadata(string name, string kind)
{
Name = name;
Kind = kind;
}

public string Name { get; }

public string Kind { get; } = string.Empty;
}

/// <summary>
/// A metadata shape whose two properties claim the same JSON name. <c>System.Text.Json</c> cannot
/// build a contract for it and reports that as <see cref="System.InvalidOperationException"/> — the
/// third way deserialization fails without throwing <see cref="System.Text.Json.JsonException"/>.
/// </summary>
public sealed class ConflictingNamesMetadata
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;

[JsonPropertyName("name")]
public string AlsoName { get; set; } = string.Empty;
}

/// <summary>
/// The diagnostics the test generators report, allocated the way a consuming repository allocates
/// its own: one catalogue, one prefix, one category.
Expand Down Expand Up @@ -114,6 +158,89 @@ protected override void Generate(SourceProductionContext context, MetadataSet me
}
}

/// <summary>
/// Deserializes into an interface, so the unsupported-shape path has a generator to run.
/// </summary>
internal sealed class UnsupportedShapeGenerator() : GeneratorBase<IThingsMetadata>("things.json")
{
protected override DiagnosticCatalog Diagnostics => TestDiagnostics.Catalog;

protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing;

protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed;

protected override void Generate(SourceProductionContext context, IThingsMetadata metadata, CodeBlocker codeBlocker) =>
context.AddSource("Unsupported.g.cs", "// unreachable");
}

/// <summary>
/// Deserializes into a type whose constructors are ambiguous, the other unsupported shape.
/// </summary>
internal sealed class AmbiguousConstructorGenerator() : GeneratorBase<AmbiguousMetadata>("things.json")
{
protected override DiagnosticCatalog Diagnostics => TestDiagnostics.Catalog;

protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing;

protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed;

protected override void Generate(SourceProductionContext context, AmbiguousMetadata metadata, CodeBlocker codeBlocker) =>
context.AddSource("Ambiguous.g.cs", "// unreachable");
}

/// <summary>
/// Deserializes into a type whose property names collide, so the converter-configuration path has a
/// generator to run.
/// </summary>
internal sealed class ConflictingNamesGenerator() : GeneratorBase<ConflictingNamesMetadata>("things.json")
{
protected override DiagnosticCatalog Diagnostics => TestDiagnostics.Catalog;

protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing;

protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed;

protected override void Generate(SourceProductionContext context, ConflictingNamesMetadata metadata, CodeBlocker codeBlocker) =>
context.AddSource("Conflicting.g.cs", "// unreachable");
}

/// <summary>
/// Reads both metadata files and emits from whichever one deserialized, rather than giving up when
/// either fails.
/// </summary>
/// <remarks>
/// <see cref="PairGenerator"/> returns early unless both files parse, which cannot distinguish one
/// file failing from the whole invocation being abandoned before the second file was reached. This
/// one can: the unsupported shape is deserialized first, so output from the second file only exists
/// if the first failure stayed contained.
/// </remarks>
internal sealed class ResilientPairGenerator : GeneratorBase
{
protected override IReadOnlyList<string> MetadataFileNames => ["things.json", "others.json"];

protected override DiagnosticCatalog Diagnostics => TestDiagnostics.Catalog;

protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing;

protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed;

protected override void Generate(SourceProductionContext context, MetadataSet metadata)
{
IThingsMetadata? things = metadata?["things.json"]?.Deserialize<IThingsMetadata>(context, MetadataParseFailed);
OthersMetadata? others = metadata?["others.json"]?.Deserialize<OthersMetadata>(context, MetadataParseFailed);

if (others is null)
{
return;
}

using CodeBlocker codeBlocker = CreateCodeBlocker();
WriteFileHeader(codeBlocker, TestDiagnostics.Copyright);
codeBlocker.WriteLine($"// {others.Others.Count} others, things {(things is null ? "unread" : "read")}");
context.AddSource("Others.g.cs", codeBlocker.ToString());
}
}

/// <summary>
/// Declares a file nothing supplies, so the missing-file path has a generator to run.
/// </summary>
Expand Down
41 changes: 33 additions & 8 deletions SourceGeneratorToolkit/MetadataFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,47 @@ public Location FindLocation(string anchor, string needle)
/// A parse failure is always reported. Swallowing it — the tempting shape, because a generator
/// has nowhere obvious to throw — means malformed metadata produces no diagnostic at all and the
/// generator silently emits something wrong.
/// <para>
/// Malformed input is not the only way deserialization fails, so catching only
/// <see cref="JsonException"/> is not enough. <see cref="JsonSerializer"/> reports a shape it
/// cannot construct — an interface or abstract type, or a type with several parameterized
/// constructors and no <c>[JsonConstructor]</c> — as <see cref="NotSupportedException"/>, and
/// some converter-configuration failures as <see cref="InvalidOperationException"/>. Neither
/// derives from <see cref="JsonException"/>, so both used to escape to the Roslyn driver, which
/// reports its own generic CS8785 naming neither the file nor the reason and abandons every
/// other file in the same invocation. They degrade to the same diagnostic as malformed JSON.
/// </para>
/// </remarks>
public T? Deserialize<T>(SourceProductionContext context, DiagnosticDescriptor parseFailed)
where T : class
{
T? metadata;
try
{
T? metadata = JsonSerializer.Deserialize<T>(Text, DeserializeOptions);
if (metadata is not null)
{
return metadata;
}

context.Report(parseFailed, FileName, "the document deserialized to null");
return null;
metadata = JsonSerializer.Deserialize<T>(Text, DeserializeOptions);
}
catch (JsonException ex)
{
return ParseFailed(ex);
}
catch (NotSupportedException ex)
{
return ParseFailed(ex);
}
catch (InvalidOperationException ex)
{
return ParseFailed(ex);
}

if (metadata is not null)
{
return metadata;
}

context.Report(parseFailed, FileName, "the document deserialized to null");
return null;

T? ParseFailed(Exception ex)
{
context.Report(parseFailed, FileName, ex.Message);
return null;
Expand Down