From 47ffbe9b750821ce92be10b7a317c76cb9b0f757 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:28:55 +0000 Subject: [PATCH 1/3] Leave #nullable enable out of headers for consumers below C# 8 [minor] WriteFileHeader wrote #nullable enable into every generated file, so a generator with a C# 7.3-compatible body broke the build of a netstandard2.0 or .NET Framework consumer on the default C# 7.3 with CS8370. GeneratorBase now reads the consumer's language version from ParseOptionsProvider and, while Generate runs, WriteFileHeader writes the directive only for C# 8 and later. A new overload takes the version explicitly, and GeneratorHarness gains a LanguageVersion property so the older-consumer case can be tested. Fixes ktsu-dev/SourceGeneratorToolkit#59 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3i9xsUaiPekqznVhL --- .gitignore | 18 ++++++ README.md | 9 ++- .../GeneratorHarnessTests.cs | 27 +++++++++ .../GeneratorHarness.cs | 19 +++++- SourceGeneratorToolkit/GeneratorBase.cs | 58 +++++++++++++++++-- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index dc0470a..e043c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# and except a Unity project's Packages/, which is source: Unity's package manifest and its +# resolved lock file are both meant to be committed, and a NuGet restore folder never contains +# a file by either name. +!**/[Pp]ackages/manifest.json +!**/[Pp]ackages/packages-lock.json # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -651,3 +656,16 @@ Temporary Items # ImGui.ini files imgui.ini + +# Game engine projects +# +# Godot: the import cache, and the mono/temp bin+obj a C# build writes. +.godot/ + +# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule +# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs +# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently +# breaks those references - including for a plug-in whose .dll is itself a build output. This +# negation has to come after that rule to win, and is scoped to the asset tree so the Visual +# Studio artifact stays ignored everywhere else. +!**/[Aa]ssets/**/*.meta diff --git a/README.md b/README.md index 3fb72f5..20bcdf9 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,8 @@ land in the test project's compile references and collide with the in-box types | `Initialize(IncrementalGeneratorInitializationContext)` | `void` | Finds the declared files and registers the source output. Sealed behaviour; do not reimplement. | | `Generate(SourceProductionContext, MetadataSet)` | `void` | Emits this generator's sources. Abstract. | | `CreateCodeBlocker()` | `CodeBlocker` | A `CodeBlocker` pinned to LF, so output is byte-identical on every platform. Static. | -| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line, the `` marker and `#nullable enable` (generated code otherwise compiles with nullable disabled). A null or empty copyright writes no copyright lines. Static. | +| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line, the `` marker and `#nullable enable` (generated code otherwise compiles with nullable disabled). The directive is left out when the consuming compilation is below C# 8, such as a `netstandard2.0` or .NET Framework project on the default C# 7.3; outside `Generate` the version is unknown and the directive is written. A null or empty copyright writes no copyright lines. Static. | +| `WriteFileHeader(CodeBlocker, string?, LanguageVersion)` | `void` | As above, for an explicitly given consumer language version. Static. | | `WriteSourceFile(CodeBlocker, SourceFileTemplate, string?)` | `void` | Writes a whole source file, header included. Static. | ### `GeneratorBase` @@ -346,6 +347,12 @@ In `ktsu.SourceGeneratorToolkit.Testing`. Runs an incremental generator over a d metadata files and hands back what it produced. Takes the metadata directory as a constructor argument. +#### Properties + +| Name | Type | Description | +|------|------|-------------| +| `LanguageVersion` | `LanguageVersion` | The C# version of the compilation the generators run against. `Default` unless set with an object initializer; set `CSharp7_3` to check output for an older consumer. | + #### Methods | Name | Return Type | Description | diff --git a/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs b/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs index 00a2a68..e27da4c 100644 --- a/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs @@ -5,7 +5,9 @@ namespace ktsu.SourceGeneratorToolkit.Test; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using ktsu.SourceGeneratorToolkit.Testing; [TestClass] @@ -13,6 +15,31 @@ public sealed class GeneratorHarnessTests { private static GeneratorHarness Harness => new(Path.Combine(AppContext.BaseDirectory, "Metadata")); + [TestMethod] + public void AHeaderForACSharp73ConsumerParsesWithoutCS8370() + { + GeneratorHarness harness = new(Path.Combine(AppContext.BaseDirectory, "Metadata")) { LanguageVersion = LanguageVersion.CSharp7_3 }; + + GeneratorRunResult result = harness.Run(new PairGenerator()); + + SyntaxTree tree = result.GeneratedSources[0].SyntaxTree; + Assert.IsFalse(tree.GetText().ToString().Contains("#nullable", StringComparison.Ordinal), tree.GetText().ToString()); + Assert.AreEqual(0, tree.GetDiagnostics().Count(), string.Join("\n", tree.GetDiagnostics())); + } + + [TestMethod] + [DataRow(LanguageVersion.CSharp8)] + [DataRow(LanguageVersion.Default)] + [DataRow(LanguageVersion.Latest)] + public void AHeaderForACSharp8OrLaterConsumerEnablesNullable(LanguageVersion languageVersion) + { + GeneratorHarness harness = new(Path.Combine(AppContext.BaseDirectory, "Metadata")) { LanguageVersion = languageVersion }; + + GeneratorRunResult result = harness.Run(new PairGenerator()); + + StringAssert.Contains(result.GeneratedSources[0].SourceText.ToString(), "// \n#nullable enable\n"); + } + [TestMethod] public void AnOverrideReplacesTheRealFileContents() { diff --git a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs index c5a4ee9..833982a 100644 --- a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs +++ b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs @@ -33,6 +33,16 @@ namespace ktsu.SourceGeneratorToolkit.Testing; /// The directory the metadata files were copied to. public sealed class GeneratorHarness(string metadataDirectory) { + /// + /// Gets the C# language version of the compilation the generators run against. + /// + /// + /// unless set. Set it to an older version, such as + /// , to check that a generator's output still parses for a + /// netstandard2.0 or .NET Framework consumer that does not set LangVersion. + /// + public LanguageVersion LanguageVersion { get; init; } = LanguageVersion.Default; + /// /// Runs a generator against every metadata file in the directory. /// @@ -126,7 +136,7 @@ public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator) GeneratorDriver driver = CSharpGeneratorDriver.Create( generators: [generator.AsSourceGenerator()], additionalTexts: texts, - parseOptions: null, + parseOptions: ParseOptions, optionsProvider: null, driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); @@ -184,11 +194,14 @@ private List BuildTexts(IReadOnlyDictionary? ove return texts; } - private static GeneratorDriverRunResult Drive(IReadOnlyList generators, List texts) + private CSharpParseOptions ParseOptions => new(LanguageVersion); + + private GeneratorDriverRunResult Drive(IReadOnlyList generators, List texts) { GeneratorDriver driver = CSharpGeneratorDriver.Create( generators: [.. generators.Select(generator => generator.AsSourceGenerator())], - additionalTexts: texts); + additionalTexts: texts, + parseOptions: ParseOptions); return driver.RunGenerators(CreateCompilation()).GetRunResult(); } diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index e0a75e4..9a355da 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -9,6 +9,7 @@ namespace ktsu.SourceGeneratorToolkit; using ktsu.CodeBlocker; using ktsu.CodeBlocker.Templates; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; /// @@ -64,8 +65,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }) .Collect(); - context.RegisterSourceOutput(metadataFiles, (productionContext, files) => + IncrementalValueProvider<(ImmutableArray Files, ParseOptions Options)> inputs = + metadataFiles.Combine(context.ParseOptionsProvider); + + context.RegisterSourceOutput(inputs, (productionContext, input) => { + ImmutableArray files = input.Files; + // A duplicate name means the same metadata reached the compilation twice; the first with // content wins. An empty file is still present, so it is handed on and fails to parse // rather than being reported as missing. @@ -83,7 +89,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) productionContext.Report(MetadataFileMissing, name); } - Generate(productionContext, new MetadataSet(byName)); + // Held for the duration of Generate so the static WriteFileHeader, which has no access to + // the compilation, can still match its output to the consumer's language version. + LanguageVersion? previous = consumerLanguageVersion; + consumerLanguageVersion = (input.Options as CSharpParseOptions)?.LanguageVersion; + try + { + Generate(productionContext, new MetadataSet(byName)); + } + finally + { + consumerLanguageVersion = previous; + } }); } @@ -115,6 +132,13 @@ protected static CodeBlocker CreateCodeBlocker() => /// private static readonly string[] CopyrightLineBreaks = ["\r\n", "\n", "\r", "\u0085", "\u2028", "\u2029"]; + /// + /// The language version of the compilation the generator is currently producing sources for, or + /// outside . + /// + [ThreadStatic] + private static LanguageVersion? consumerLanguageVersion; + /// /// Writes the header every generated file starts with. /// @@ -136,9 +160,29 @@ protected static CodeBlocker CreateCodeBlocker() => /// Nullable setting, so without the directive every ? annotation in the output /// raises CS8669 and every unannotated reference member is null-oblivious to its callers. /// + /// + /// #nullable needs C# 8, so it is left out when the consuming compilation is on an earlier + /// language version, such as the C# 7.3 a netstandard2.0 or .NET Framework project gets by + /// default. Called from , the version is the consuming compilation's; called + /// anywhere else, it is unknown and the directive is written. Use the overload that takes a + /// to choose explicitly. + /// /// /// is . - protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright) + protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright) => + WriteFileHeader(codeBlocker, copyright, consumerLanguageVersion ?? LanguageVersion.Latest); + + /// + /// Writes the header every generated file starts with, for a consumer on the given language version. + /// + /// The to write to. + /// The copyright, as for . + /// + /// The consuming compilation's language version. #nullable enable is written only for C# 8 + /// and later. + /// + /// is . + protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright, LanguageVersion languageVersion) { Ensure.NotNull(codeBlocker); @@ -151,7 +195,11 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright } codeBlocker.WriteLine("// "); - codeBlocker.WriteLine("#nullable enable"); + if (languageVersion.MapSpecifiedToEffectiveVersion() >= LanguageVersion.CSharp8) + { + codeBlocker.WriteLine("#nullable enable"); + } + codeBlocker.NewLine(); } @@ -160,7 +208,7 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright /// /// The to write to. /// The file to write. - /// The copyright line, as for . + /// The copyright line, as for . /// /// or is . /// From 859eb0788fa2e7a6d5cde23d224d8fbe63332d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:31:32 +0000 Subject: [PATCH 2/3] Build the harness tests' metadata path once, with Path.Join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3i9xsUaiPekqznVhL --- SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs b/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs index e27da4c..df666cd 100644 --- a/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs @@ -13,12 +13,14 @@ namespace ktsu.SourceGeneratorToolkit.Test; [TestClass] public sealed class GeneratorHarnessTests { - private static GeneratorHarness Harness => new(Path.Combine(AppContext.BaseDirectory, "Metadata")); + private static string MetadataDirectory => Path.Join(AppContext.BaseDirectory, "Metadata"); + + private static GeneratorHarness Harness => new(MetadataDirectory); [TestMethod] public void AHeaderForACSharp73ConsumerParsesWithoutCS8370() { - GeneratorHarness harness = new(Path.Combine(AppContext.BaseDirectory, "Metadata")) { LanguageVersion = LanguageVersion.CSharp7_3 }; + GeneratorHarness harness = new(MetadataDirectory) { LanguageVersion = LanguageVersion.CSharp7_3 }; GeneratorRunResult result = harness.Run(new PairGenerator()); @@ -33,7 +35,7 @@ public void AHeaderForACSharp73ConsumerParsesWithoutCS8370() [DataRow(LanguageVersion.Latest)] public void AHeaderForACSharp8OrLaterConsumerEnablesNullable(LanguageVersion languageVersion) { - GeneratorHarness harness = new(Path.Combine(AppContext.BaseDirectory, "Metadata")) { LanguageVersion = languageVersion }; + GeneratorHarness harness = new(MetadataDirectory) { LanguageVersion = languageVersion }; GeneratorRunResult result = harness.Run(new PairGenerator()); From 82faef6dd61e801dc01589eee0403e682aece991 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:42:12 +0000 Subject: [PATCH 3/3] Set the consumer language version from a static helper (S2696) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3i9xsUaiPekqznVhL --- SourceGeneratorToolkit/GeneratorBase.cs | 38 +++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index 9a355da..ecb5f50 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -89,21 +89,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context) productionContext.Report(MetadataFileMissing, name); } - // Held for the duration of Generate so the static WriteFileHeader, which has no access to - // the compilation, can still match its output to the consumer's language version. - LanguageVersion? previous = consumerLanguageVersion; - consumerLanguageVersion = (input.Options as CSharpParseOptions)?.LanguageVersion; - try - { - Generate(productionContext, new MetadataSet(byName)); - } - finally - { - consumerLanguageVersion = previous; - } + RunForLanguageVersion( + (input.Options as CSharpParseOptions)?.LanguageVersion, + () => Generate(productionContext, new MetadataSet(byName))); }); } + /// + /// Runs an action with the consumer's language version available to . + /// + /// The consuming compilation's language version, if known. + /// The action to run. + /// + /// Held for the duration of the action so the static header writer, which has no access to the + /// compilation, can still match its output to the consumer's language version. + /// + private static void RunForLanguageVersion(LanguageVersion? languageVersion, Action action) + { + LanguageVersion? previous = consumerLanguageVersion; + consumerLanguageVersion = languageVersion; + try + { + action(); + } + finally + { + consumerLanguageVersion = previous; + } + } + /// /// Emits this generator's sources. ///