diff --git a/README.md b/README.md index 2b33d85..ae63d3c 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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(SourceProductionContext, DiagnosticDescriptor)` | `T?` | Deserializes the file, reporting the descriptor and returning null on failure. | +| `Deserialize(SourceProductionContext, DiagnosticDescriptor, JsonSerializerOptions)` | `T?` | The same, reading the file with the given options. | ### `MetadataSet` diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index efd5aaf..106decd 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -120,6 +120,45 @@ private sealed class PathedText(string path, string text) : AdditionalText 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 { ["things.json"] = text }); + + Assert.AreEqual(0, result.Diagnostics.Length, string.Join("; ", result.Diagnostics)); + string source = result.GeneratedSources.Single().SourceText.ToString(); + StringAssert.Contains(source, "class Alpha"); + StringAssert.Contains(source, "class Beta"); + } + + [TestMethod] + public void AGeneratorCanOverrideTheMetadataSerializerOptions() + { + GeneratorRunResult result = Harness.Run( + new EnumByNameGenerator(), + new Dictionary { ["things.json"] = """{ "things": [ { "name": "Alpha", "kind": "Second" } ] }""" }); + + Assert.AreEqual(0, result.Diagnostics.Length, string.Join("; ", result.Diagnostics)); + StringAssert.Contains(result.GeneratedSources.Single().SourceText.ToString(), "// Alpha: Second"); + } + + [TestMethod] + public void TheDefaultMetadataSerializerOptionsAreReadOnly() => + Assert.IsTrue(MetadataFile.DefaultSerializerOptions.IsReadOnly, "A shared instance must not be mutable by a consumer."); + [TestMethod] public void AMalformedMetadataFileIsReportedRatherThanSwallowed() { diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index 4886d26..d77aeb0 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; using System.Text.Json.Serialization; using ktsu.CodeBlocker; using ktsu.CodeBlocker.Templates; @@ -421,3 +422,54 @@ internal static string WriteThingWithNullableAndNonNullableMembers() return codeBlocker.ToString(); } } + +/// How a is read from its string name. +public enum ThingKind +{ + First, + Second, +} + +/// An entry whose kind is an enum, which needs a converter the defaults don't carry. +public sealed class KindedThing +{ + public string Name { get; set; } = string.Empty; + + public ThingKind Kind { get; set; } +} + +/// A metadata shape with an enum member. +public sealed class KindedThingsMetadata +{ + public List Things { get; set; } = []; +} + +/// +/// Overrides to read enums by name, and +/// emits one class per entry named for its kind. +/// +internal sealed class EnumByNameGenerator() : GeneratorBase("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()); + } +} diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index 3ae6cac..72b4af3 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -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; @@ -291,10 +292,22 @@ public abstract class GeneratorBase(string metadataFileName) : GeneratorBase /// protected sealed override IReadOnlyList MetadataFileNames => [metadataFileName]; + /// + /// Gets the options the metadata file is deserialized with. Defaults to + /// . + /// + /// + /// Override to add converters, a naming policy or stricter reading. Return a cached + /// instance rather than a new one per call: + /// constructing per call builds a fresh serializer cache every + /// time (CA1869). + /// + protected virtual JsonSerializerOptions MetadataSerializerOptions => MetadataFile.DefaultSerializerOptions; + /// protected sealed override void Generate(SourceProductionContext context, MetadataSet metadata) { - T? deserialized = metadata?[metadataFileName]?.Deserialize(context, MetadataParseFailed); + T? deserialized = metadata?[metadataFileName]?.Deserialize(context, MetadataParseFailed, MetadataSerializerOptions); if (deserialized is null) { return; diff --git a/SourceGeneratorToolkit/MetadataFile.cs b/SourceGeneratorToolkit/MetadataFile.cs index 1e6aec7..73b7693 100644 --- a/SourceGeneratorToolkit/MetadataFile.cs +++ b/SourceGeneratorToolkit/MetadataFile.cs @@ -23,14 +23,31 @@ namespace ktsu.SourceGeneratorToolkit; public sealed class MetadataFile(string fileName, string text, SourceText? sourceText, string path) : IEquatable { /// + /// Gets the options + /// reads metadata with: property names match case-insensitively, and // and /* */ + /// comments and trailing commas are accepted, because metadata files are maintained by hand. + /// + /// + /// Read-only, so it can be shared. To change it, copy it, adjust the copy, and keep that copy in a + /// field: + /// new JsonSerializerOptions(MetadataFile.DefaultSerializerOptions) { Converters = { new JsonStringEnumConverter() } }. /// Allocated once rather than per call: constructing 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. - /// - private static readonly JsonSerializerOptions DeserializeOptions = new() + /// + 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; + } /// Gets the file's name, without its directory. public string FileName { get; } = fileName; @@ -178,12 +195,38 @@ private int IndexOfToken(string value, int startIndex) /// /// public T? Deserialize(SourceProductionContext context, DiagnosticDescriptor parseFailed) + where T : class => + Deserialize(context, parseFailed, DefaultSerializerOptions); + + /// + /// Deserializes the file into with the given options. + /// + /// The metadata shape to deserialize into. + /// The source production context, used to report a parse failure. + /// + /// The descriptor reported when the file cannot be parsed. Its message format takes the file + /// name and the reason. + /// + /// + /// The options to read the file with. Pass a cached instance, such as a copy of + /// held in a + /// field, rather than a new one per call (CA1869). + /// + /// The deserialized metadata, or when parsing failed. + /// + /// Failures are reported exactly as by + /// . + /// + /// is . + public T? Deserialize(SourceProductionContext context, DiagnosticDescriptor parseFailed, JsonSerializerOptions options) where T : class { + Ensure.NotNull(options); + T? metadata; try { - metadata = JsonSerializer.Deserialize(Text, DeserializeOptions); + metadata = JsonSerializer.Deserialize(Text, options); } catch (JsonException ex) {