diff --git a/README.md b/README.md index 2b33d85..fa7e33d 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ land in the test project's compile references and collide with the in-box types | Name | Type | Description | |------|------|-------------| | `MetadataFileNames` | `IReadOnlyList` | The metadata files this generator reads, without their directories. Abstract. | -| `Diagnostics` | `DiagnosticCatalog` | The catalogue this generator's diagnostics are allocated from. Abstract. | +| `Diagnostics` | `DiagnosticCatalog` | The catalogue this generator's diagnostics are allocated from. `MetadataFileMissing` and `MetadataParseFailed` must both be allocated from it, or `Initialize` throws and the build reports CS8784. Abstract. | | `MetadataFileMissing` | `DiagnosticDescriptor` | Reported when a declared file is not in the compilation. Takes the file name. Abstract. | | `MetadataParseFailed` | `DiagnosticDescriptor` | Reported when a file cannot be parsed, or when several files of one declared name have different contents (the reason lists their paths, and none is used). Takes the file name and the reason. Abstract. | diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index efd5aaf..82bb4d8 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -120,6 +120,27 @@ private sealed class PathedText(string path, string text) : AdditionalText public override SourceText GetText(System.Threading.CancellationToken cancellationToken = default) => SourceText.From(text); } + [TestMethod] + public void ANullDiagnosticCatalogueIsRefused() + { + GeneratorRunResult result = Harness.Run(new NullCatalogGenerator()); + + Assert.IsInstanceOfType(result.Exception); + StringAssert.Contains(result.Exception.Message, "NullCatalogGenerator.Diagnostics returned null"); + Assert.AreEqual(0, result.GeneratedSources.Length); + } + + [TestMethod] + public void ADescriptorFromAnotherCatalogueIsRefused() + { + GeneratorRunResult result = Harness.Run(new MismatchedCatalogGenerator()); + + Assert.IsInstanceOfType(result.Exception); + StringAssert.Contains(result.Exception.Message, "MismatchedCatalogGenerator.MetadataFileMissing (TST001)"); + StringAssert.Contains(result.Exception.Message, "'Other' catalogue"); + Assert.AreEqual(0, result.GeneratedSources.Length); + } + [TestMethod] public void AMalformedMetadataFileIsReportedRatherThanSwallowed() { diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index 4886d26..8ec70c2 100644 --- a/SourceGeneratorToolkit.Test/TestGenerators.cs +++ b/SourceGeneratorToolkit.Test/TestGenerators.cs @@ -421,3 +421,38 @@ internal static string WriteThingWithNullableAndNonNullableMembers() return codeBlocker.ToString(); } } + +/// A second catalogue, so a generator can name one its descriptors weren't allocated from. +internal static class OtherDiagnostics +{ + internal static DiagnosticCatalog Catalog { get; } = new("OTH", "Other"); +} + +/// Names a null catalogue, which must refuse. +internal sealed class NullCatalogGenerator() : GeneratorBase("things.json") +{ + protected override DiagnosticCatalog Diagnostics => null!; + + protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing; + + protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed; + + protected override void Generate(SourceProductionContext context, ThingsMetadata metadata, CodeBlocker codeBlocker) => + context.AddSource("Things.g.cs", "// generated"); +} + +/// +/// Names a catalogue its descriptors weren't allocated from: the prefix drift +/// exists to prevent. +/// +internal sealed class MismatchedCatalogGenerator() : GeneratorBase("things.json") +{ + protected override DiagnosticCatalog Diagnostics => OtherDiagnostics.Catalog; + + protected override DiagnosticDescriptor MetadataFileMissing => TestDiagnostics.MetadataFileMissing; + + protected override DiagnosticDescriptor MetadataParseFailed => TestDiagnostics.MetadataParseFailed; + + protected override void Generate(SourceProductionContext context, ThingsMetadata metadata, CodeBlocker codeBlocker) => + context.AddSource("Things.g.cs", "// generated"); +} diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index 3ae6cac..64b014d 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -37,6 +37,11 @@ public abstract class GeneratorBase : IIncrementalGenerator /// /// Gets the catalogue this generator's diagnostics are allocated from. /// + /// + /// and must both be allocated + /// from it. throws otherwise, so the two diagnostics this base reports + /// cannot drift to a prefix other than the one the generator's own tests enumerate. + /// protected abstract DiagnosticCatalog Diagnostics { get; } /// @@ -57,8 +62,14 @@ public abstract class GeneratorBase : IIncrementalGenerator protected abstract DiagnosticDescriptor MetadataParseFailed { get; } /// + /// + /// is , or or + /// was not allocated from it. + /// public void Initialize(IncrementalGeneratorInitializationContext context) { + EnsureCataloged(); + IReadOnlyList wanted = MetadataFileNames; IncrementalValueProvider> metadataFiles = context.AdditionalTextsProvider @@ -115,6 +126,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + // A required catalogue that nothing reads looks enforced and isn't: a generator could report its + // two built-in diagnostics under a prefix other than the one it releases and tests, which is the + // drift DiagnosticCatalog exists to prevent. Thrown from Initialize, the failure reaches the + // consumer's build as the driver's CS8784 warning naming this message, and the generator emits + // nothing. + private void EnsureCataloged() + { + DiagnosticCatalog catalog = Diagnostics + ?? throw new InvalidOperationException($"{GetType().Name}.Diagnostics returned null. A generator must name the catalogue its diagnostics are allocated from."); + + EnsureCataloged(catalog, MetadataFileMissing, nameof(MetadataFileMissing)); + EnsureCataloged(catalog, MetadataParseFailed, nameof(MetadataParseFailed)); + } + + private void EnsureCataloged(DiagnosticCatalog catalog, DiagnosticDescriptor? descriptor, string member) + { + if (descriptor is not null && catalog.Descriptors.Any(cataloged => cataloged.Id == descriptor.Id)) + { + return; + } + + string cataloged = catalog.Descriptors.Count == 0 + ? "none" + : string.Join(", ", catalog.Descriptors.Select(entry => entry.Id)); + throw new InvalidOperationException( + $"{GetType().Name}.{member} ({descriptor?.Id ?? "null"}) was not allocated from {GetType().Name}.Diagnostics, " + + $"the '{catalog.Category}' catalogue (descriptors: {cataloged}). Allocate it from that catalogue."); + } + /// /// Runs an action with the consumer's language version available to . ///