diff --git a/CLAUDE.md b/CLAUDE.md index bc80606..c12fdb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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` @@ -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 diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index 578c97f..3e93d7c 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -83,6 +83,60 @@ public void AMalformedSecondMetadataFileIsAlsoReported() 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); + Assert.AreEqual(1, result.Diagnostics.Length); + 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"); + } + + [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); + Assert.AreEqual(1, result.Diagnostics.Length); + 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); + Assert.AreEqual(1, result.Diagnostics.Length); + 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); + Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id); + Assert.AreEqual(1, result.GeneratedSources.Length); + StringAssert.Contains(result.GeneratedSources[0].SourceText.ToString(), "1 others, things unread"); + } + [TestMethod] public void AJsonNullDocumentIsReportedRatherThanTreatedAsEmpty() { diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index 7f23668..c24da3f 100644 --- a/SourceGeneratorToolkit.Test/TestGenerators.cs +++ b/SourceGeneratorToolkit.Test/TestGenerators.cs @@ -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; @@ -29,6 +30,49 @@ public sealed class OthersMetadata public List Others { get; set; } = []; } +/// +/// An interface-typed metadata shape. Modelling "one of several variant kinds" this way is an +/// ordinary choice, and System.Text.Json refuses it with +/// rather than — a sibling type, not a subclass. +/// +public interface IThingsMetadata +{ + public List Things { get; } +} + +/// +/// The other shape System.Text.Json refuses the same way: two public parameterized +/// constructors and no [JsonConstructor] to pick between them. +/// +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; +} + +/// +/// A metadata shape whose two properties claim the same JSON name. System.Text.Json cannot +/// build a contract for it and reports that as — the +/// third way deserialization fails without throwing . +/// +public sealed class ConflictingNamesMetadata +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string AlsoName { get; set; } = string.Empty; +} + /// /// The diagnostics the test generators report, allocated the way a consuming repository allocates /// its own: one catalogue, one prefix, one category. @@ -114,6 +158,89 @@ protected override void Generate(SourceProductionContext context, MetadataSet me } } +/// +/// Deserializes into an interface, so the unsupported-shape path has a generator to run. +/// +internal sealed class UnsupportedShapeGenerator() : GeneratorBase("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"); +} + +/// +/// Deserializes into a type whose constructors are ambiguous, the other unsupported shape. +/// +internal sealed class AmbiguousConstructorGenerator() : GeneratorBase("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"); +} + +/// +/// Deserializes into a type whose property names collide, so the converter-configuration path has a +/// generator to run. +/// +internal sealed class ConflictingNamesGenerator() : GeneratorBase("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"); +} + +/// +/// Reads both metadata files and emits from whichever one deserialized, rather than giving up when +/// either fails. +/// +/// +/// 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. +/// +internal sealed class ResilientPairGenerator : GeneratorBase +{ + protected override IReadOnlyList 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(context, MetadataParseFailed); + OthersMetadata? others = metadata?["others.json"]?.Deserialize(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()); + } +} + /// /// Declares a file nothing supplies, so the missing-file path has a generator to run. /// diff --git a/SourceGeneratorToolkit/MetadataFile.cs b/SourceGeneratorToolkit/MetadataFile.cs index 75d74ad..10404b1 100644 --- a/SourceGeneratorToolkit/MetadataFile.cs +++ b/SourceGeneratorToolkit/MetadataFile.cs @@ -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. + /// + /// Malformed input is not the only way deserialization fails, so catching only + /// is not enough. reports a shape it + /// cannot construct — an interface or abstract type, or a type with several parameterized + /// constructors and no [JsonConstructor] — as , and + /// some converter-configuration failures as . Neither + /// derives from , 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. + /// /// public T? Deserialize(SourceProductionContext context, DiagnosticDescriptor parseFailed) where T : class { + T? metadata; try { - T? metadata = JsonSerializer.Deserialize(Text, DeserializeOptions); - if (metadata is not null) - { - return metadata; - } - - context.Report(parseFailed, FileName, "the document deserialized to null"); - return null; + metadata = JsonSerializer.Deserialize(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;