Skip to content
Open
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: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ land in the test project's compile references and collide with the in-box types
Base for a generator driven by exactly one metadata file, deserialized into `T`. Takes the file
name as a constructor argument.

#### Properties

| Name | Type | Description |
|------|------|-------------|
| `MetadataSerializerOptions` | `JsonSerializerOptions` | The options the file is deserialized with. Defaults to `MetadataFile.DefaultSerializerOptions`. Override it to add converters or a naming policy, returning a cached `static readonly` instance (CA1869). Virtual. |

#### Methods

| Name | Return Type | Description |
Expand All @@ -280,6 +286,7 @@ One metadata file a generator asked for, with the text it was found to contain.
|------|------|-------------|
| `FileName` | `string` | The file's name, without its directory. |
| `Text` | `string` | The file's contents. |
| `DefaultSerializerOptions` | `JsonSerializerOptions` | Static and read-only. Property names match case-insensitively, and `//` and `/* */` comments and trailing commas are accepted, because metadata is written by hand. Copy it with `new JsonSerializerOptions(MetadataFile.DefaultSerializerOptions)` to change it. |

#### Methods

Expand All @@ -288,6 +295,7 @@ One metadata file a generator asked for, with the text it was found to contain.
| `FindLocation(string needle)` | `Location` | A location covering the first occurrence of `needle`, or `Location.None`. |
| `FindLocation(string anchor, string needle)` | `Location` | A location covering the first occurrence of `needle` at or after `anchor`, falling back to the unscoped match and then to `Location.None`. |
| `Deserialize<T>(SourceProductionContext, DiagnosticDescriptor)` | `T?` | Deserializes the file, reporting the descriptor and returning null on failure. |
| `Deserialize<T>(SourceProductionContext, DiagnosticDescriptor, JsonSerializerOptions)` | `T?` | The same, reading the file with the given options. |

### `MetadataSet`

Expand Down
39 changes: 39 additions & 0 deletions SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,45 @@
public override SourceText GetText(System.Threading.CancellationToken cancellationToken = default) => SourceText.From(text);
}

[TestMethod]
public void AHandWrittenMetadataFileWithCommentsAndTrailingCommasParses()
{
const string text = """
{
// The things this compilation declares.
"things": [
{ "name": "Alpha", "kind": "first", },
/* Beta comes second. */
{ "name": "Beta", "kind": "second" },
],
}
""";

GeneratorRunResult result = Harness.Run(
new ThingsGenerator(),
new Dictionary<string, string> { ["things.json"] = text });

Assert.AreEqual(0, result.Diagnostics.Length, string.Join("; ", result.Diagnostics));

Check warning on line 141 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=AaG23L3s81ycsIeH4qpg&open=AaG23L3s81ycsIeH4qpg&pullRequest=69
string source = result.GeneratedSources.Single().SourceText.ToString();
StringAssert.Contains(source, "class Alpha");

Check warning on line 143 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=AaG23L3s81ycsIeH4qpe&open=AaG23L3s81ycsIeH4qpe&pullRequest=69
StringAssert.Contains(source, "class Beta");

Check warning on line 144 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=AaG23L3s81ycsIeH4qpf&open=AaG23L3s81ycsIeH4qpf&pullRequest=69
}

[TestMethod]
public void AGeneratorCanOverrideTheMetadataSerializerOptions()
{
GeneratorRunResult result = Harness.Run(
new EnumByNameGenerator(),
new Dictionary<string, string> { ["things.json"] = """{ "things": [ { "name": "Alpha", "kind": "Second" } ] }""" });

Assert.AreEqual(0, result.Diagnostics.Length, string.Join("; ", result.Diagnostics));

Check warning on line 154 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=AaG23L3s81ycsIeH4qpi&open=AaG23L3s81ycsIeH4qpi&pullRequest=69
StringAssert.Contains(result.GeneratedSources.Single().SourceText.ToString(), "// Alpha: Second");

Check warning on line 155 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=AaG23L3s81ycsIeH4qph&open=AaG23L3s81ycsIeH4qph&pullRequest=69
}

[TestMethod]
public void TheDefaultMetadataSerializerOptionsAreReadOnly() =>
Assert.IsTrue(MetadataFile.DefaultSerializerOptions.IsReadOnly, "A shared instance must not be mutable by a consumer.");

[TestMethod]
public void AMalformedMetadataFileIsReportedRatherThanSwallowed()
{
Expand Down
52 changes: 52 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;
using System.Text.Json.Serialization;
using ktsu.CodeBlocker;
using ktsu.CodeBlocker.Templates;
Expand Down Expand Up @@ -421,3 +422,54 @@ internal static string WriteThingWithNullableAndNonNullableMembers()
return codeBlocker.ToString();
}
}

/// <summary>How a <see cref="KindedThing"/> is read from its string name.</summary>
public enum ThingKind
{
First,
Second,
}

/// <summary>An entry whose kind is an enum, which needs a converter the defaults don't carry.</summary>
public sealed class KindedThing
{
public string Name { get; set; } = string.Empty;

public ThingKind Kind { get; set; }
}

/// <summary>A metadata shape with an enum member.</summary>
public sealed class KindedThingsMetadata
{
public List<KindedThing> Things { get; set; } = [];
}

/// <summary>
/// Overrides <see cref="GeneratorBase{T}.MetadataSerializerOptions"/> to read enums by name, and
/// emits one class per entry named for its kind.
/// </summary>
internal sealed class EnumByNameGenerator() : GeneratorBase<KindedThingsMetadata>("things.json")
{
private static readonly JsonSerializerOptions Options = new(MetadataFile.DefaultSerializerOptions)
{
Converters = { new JsonStringEnumConverter() },
};

protected override JsonSerializerOptions MetadataSerializerOptions => Options;

protected override DiagnosticCatalog Diagnostics => TestDiagnostics.Catalog;

protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing;

protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed;

protected override void Generate(SourceProductionContext context, KindedThingsMetadata metadata, CodeBlocker codeBlocker)
{
foreach (KindedThing thing in metadata.Things)
{
codeBlocker.WriteLine($"// {thing.Name}: {thing.Kind}");
}

context.AddSource("Kinds.g.cs", codeBlocker.ToString());
}
}
15 changes: 14 additions & 1 deletion SourceGeneratorToolkit/GeneratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace ktsu.SourceGeneratorToolkit;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using ktsu.CodeBlocker;
using ktsu.CodeBlocker.Templates;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -291,10 +292,22 @@ public abstract class GeneratorBase<T>(string metadataFileName) : GeneratorBase
/// <inheritdoc/>
protected sealed override IReadOnlyList<string> MetadataFileNames => [metadataFileName];

/// <summary>
/// Gets the options the metadata file is deserialized with. Defaults to
/// <see cref="MetadataFile.DefaultSerializerOptions"/>.
/// </summary>
/// <remarks>
/// Override to add converters, a naming policy or stricter reading. Return a cached
/// <see langword="static"/> <see langword="readonly"/> instance rather than a new one per call:
/// constructing <see cref="JsonSerializerOptions"/> per call builds a fresh serializer cache every
/// time (CA1869).
/// </remarks>
protected virtual JsonSerializerOptions MetadataSerializerOptions => MetadataFile.DefaultSerializerOptions;

/// <inheritdoc/>
protected sealed override void Generate(SourceProductionContext context, MetadataSet metadata)
{
T? deserialized = metadata?[metadataFileName]?.Deserialize<T>(context, MetadataParseFailed);
T? deserialized = metadata?[metadataFileName]?.Deserialize<T>(context, MetadataParseFailed, MetadataSerializerOptions);
if (deserialized is null)
{
return;
Expand Down
53 changes: 48 additions & 5 deletions SourceGeneratorToolkit/MetadataFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,31 @@ namespace ktsu.SourceGeneratorToolkit;
public sealed class MetadataFile(string fileName, string text, SourceText? sourceText, string path) : IEquatable<MetadataFile>
{
/// <summary>
/// Gets the options <see cref="Deserialize{T}(SourceProductionContext, DiagnosticDescriptor)"/>
/// reads metadata with: property names match case-insensitively, and <c>//</c> and <c>/* */</c>
/// comments and trailing commas are accepted, because metadata files are maintained by hand.
/// </summary>
/// <remarks>
/// Read-only, so it can be shared. To change it, copy it, adjust the copy, and keep that copy in a
/// <see langword="static"/> <see langword="readonly"/> field:
/// <c>new JsonSerializerOptions(MetadataFile.DefaultSerializerOptions) { Converters = { new JsonStringEnumConverter() } }</c>.
/// Allocated once rather than per call: constructing <see cref="JsonSerializerOptions"/> inside
/// the method builds a fresh serializer cache every time (CA1869), which for a generator that
/// re-reads its metadata on every compilation is the expensive half of the work.
/// </summary>
private static readonly JsonSerializerOptions DeserializeOptions = new()
/// </remarks>
public static JsonSerializerOptions DefaultSerializerOptions { get; } = CreateDefaultSerializerOptions();

private static JsonSerializerOptions CreateDefaultSerializerOptions()
{
PropertyNameCaseInsensitive = true
};
JsonSerializerOptions options = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
options.MakeReadOnly(populateMissingResolver: true);
return options;
}

/// <summary>Gets the file's name, without its directory.</summary>
public string FileName { get; } = fileName;
Expand Down Expand Up @@ -178,12 +195,38 @@ private int IndexOfToken(string value, int startIndex)
/// </para>
/// </remarks>
public T? Deserialize<T>(SourceProductionContext context, DiagnosticDescriptor parseFailed)
where T : class =>
Deserialize<T>(context, parseFailed, DefaultSerializerOptions);

/// <summary>
/// Deserializes the file into <typeparamref name="T"/> with the given options.
/// </summary>
/// <typeparam name="T">The metadata shape to deserialize into.</typeparam>
/// <param name="context">The source production context, used to report a parse failure.</param>
/// <param name="parseFailed">
/// The descriptor reported when the file cannot be parsed. Its message format takes the file
/// name and the reason.
/// </param>
/// <param name="options">
/// The options to read the file with. Pass a cached instance, such as a copy of
/// <see cref="DefaultSerializerOptions"/> held in a <see langword="static"/> <see langword="readonly"/>
/// field, rather than a new one per call (CA1869).
/// </param>
/// <returns>The deserialized metadata, or <see langword="null"/> when parsing failed.</returns>
/// <remarks>
/// Failures are reported exactly as by
/// <see cref="Deserialize{T}(SourceProductionContext, DiagnosticDescriptor)"/>.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
public T? Deserialize<T>(SourceProductionContext context, DiagnosticDescriptor parseFailed, JsonSerializerOptions options)
where T : class
{
Ensure.NotNull(options);

T? metadata;
try
{
metadata = JsonSerializer.Deserialize<T>(Text, DeserializeOptions);
metadata = JsonSerializer.Deserialize<T>(Text, options);
}
catch (JsonException ex)
{
Expand Down
Loading