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
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ side of it.
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.
`JsonException`, and the model's own constructor or setters throw whatever ordinary code throws
(`ArgumentNullException`, `NullReferenceException`, `FormatException`, ...). `Deserialize<T>`
reports every exception except `OperationCanceledException` and `OutOfMemoryException`, 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 Down
19 changes: 19 additions & 0 deletions SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,25 @@
StringAssert.Contains(result.GeneratedSources[0].SourceText.ToString(), "1 others, things unread");
}

[TestMethod]
[DataRow("""{"things":[{"size":"1"}]}""", "NullReferenceException", DisplayName = "NullReferenceException")]
[DataRow("""{"things":[{"name":"a","size":"big"}]}""", "FormatException", DisplayName = "FormatException")]
[DataRow("""{"things":[{"name":"a","size":"99999999999"}]}""", "OverflowException", DisplayName = "OverflowException")]
public void AnyExceptionFromModelCodeIsReportedRatherThanCrashingTheGenerator(string things, string exceptionType)
{
GeneratorRunResult result = Harness.Run(
new ParsedPairGenerator(),
new Dictionary<string, string> { ["things.json"] = things });

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

Check warning on line 236 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_Fcug45osNqUKZ1c&open=AaGj_Fcug45osNqUKZ1c&pullRequest=65
Assert.AreEqual(TestDiagnostics.MetadataParseFailed.Id, result.Diagnostics[0].Id);
StringAssert.Contains(result.Diagnostics[0].GetMessage(), "things.json");

Check warning on line 238 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_Fcug45osNqUKZ1Z&open=AaGj_Fcug45osNqUKZ1Z&pullRequest=65
StringAssert.Contains(result.Diagnostics[0].GetMessage(), exceptionType);

Check warning on line 239 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_Fcug45osNqUKZ1a&open=AaGj_Fcug45osNqUKZ1a&pullRequest=65
Assert.AreEqual(1, result.GeneratedSources.Length);

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

Check warning on line 241 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_Fcug45osNqUKZ1b&open=AaGj_Fcug45osNqUKZ1b&pullRequest=65
}

[TestMethod]
public void AJsonNullDocumentIsReportedRatherThanTreatedAsEmpty()
{
Expand Down
52 changes: 52 additions & 0 deletions SourceGeneratorToolkit.Test/TestGenerators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,58 @@ protected override void Generate(SourceProductionContext context, MetadataSet me
}
}

/// <summary>
/// An entry whose constructor does what ordinary model code does with its input, and so throws
/// <see cref="System.NullReferenceException"/>, <see cref="System.FormatException"/> or
/// <see cref="System.OverflowException"/> rather than an argument exception.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "The missing guard is the point: a missing name has to throw NullReferenceException.")]
public sealed class ParsedThing(string name, string size)
{
public string Name { get; } = name.Trim();

public string Size { get; } = size;

public int SizeValue { get; } = int.Parse(size, System.Globalization.CultureInfo.InvariantCulture);
}

/// <summary>A metadata shape whose entries are <see cref="ParsedThing"/>.</summary>
public sealed class ParsedThingsMetadata
{
public List<ParsedThing> Things { get; set; } = [];
}

/// <summary>
/// Reads both metadata files, the first into <see cref="ParsedThingsMetadata"/>, and emits from
/// whichever one deserialized.
/// </summary>
internal sealed class ParsedPairGenerator : 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)
{
ParsedThingsMetadata? things = metadata["things.json"]?.Deserialize<ParsedThingsMetadata>(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("Parsed.g.cs", codeBlocker.ToString());
}
}

/// <summary>
/// Declares a file nothing supplies, so the missing-file path has a generator to run.
/// </summary>
Expand Down
27 changes: 9 additions & 18 deletions SourceGeneratorToolkit/MetadataFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,12 @@ public Location FindLocation(string anchor, string needle)
/// <para>
/// <see cref="JsonSerializer"/> also lets the model's own constructor exceptions through
/// unwrapped, so an ordinary guard clause meeting a missing property throws
/// <see cref="ArgumentNullException"/>. <see cref="ArgumentException"/> and its derivations are
/// reported the same way.
/// <see cref="ArgumentNullException"/>, and ordinary model code throws whatever it throws: a
/// <see cref="NullReferenceException"/> from <c>name.Trim()</c> on a missing property, a
/// <see cref="FormatException"/> or <see cref="OverflowException"/> from <c>int.Parse</c>. Any of
/// those escaping would be the same CS8785, so every exception is reported the same way, with its
/// type name in the reason. Only <see cref="OperationCanceledException"/>, which the driver uses
/// to stop a run, and <see cref="OutOfMemoryException"/> are let through.
/// </para>
/// </remarks>
public T? Deserialize<T>(SourceProductionContext context, DiagnosticDescriptor parseFailed)
Expand All @@ -167,17 +171,10 @@ public Location FindLocation(string anchor, string needle)
context.ReportAt(parseFailed, LocationOf(ex), FileName, ex.Message);
return null;
}
catch (NotSupportedException ex)
catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException)
{
return ParseFailed(ex);
}
catch (InvalidOperationException ex)
{
return ParseFailed(ex);
}
catch (ArgumentException ex)
{
return ParseFailed(ex);
context.Report(parseFailed, FileName, $"{ex.GetType().Name}: {ex.Message}");
return null;
}

if (metadata is not null)
Expand All @@ -187,12 +184,6 @@ public Location FindLocation(string anchor, string needle)

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

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

/// <summary>
Expand Down
Loading