From 59c2eb7a1e3a0c8c27132eca207e9a154cae132b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 12:09:47 +0100 Subject: [PATCH 1/5] feat: added Nullable overloads that take Compilation directly - useful for pipeline-based collections --- .../Helpers/IncrementalPipeline.cs | 2 +- src/src/SourceGeneratorShared/TypeIdentity.cs | 20 +++++++++++++++++++ .../SourceGeneratorShared/TypeReference.cs | 11 ++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs index 64bb13f..35030d8 100644 --- a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs @@ -120,7 +120,7 @@ public static IncrementalValueProvider< /// compilation is not a C# compilation. /// #pragma warning disable format - static bool? IsNullableContextEnabled(Compilation compilation) => + public static bool? IsNullableContextEnabled(Compilation compilation) => compilation is CSharpCompilation { diff --git a/src/src/SourceGeneratorShared/TypeIdentity.cs b/src/src/SourceGeneratorShared/TypeIdentity.cs index 3f0a28d..e274362 100644 --- a/src/src/SourceGeneratorShared/TypeIdentity.cs +++ b/src/src/SourceGeneratorShared/TypeIdentity.cs @@ -571,10 +571,30 @@ public override int GetHashCode() /// Creates a nullable structured type reference. public TypeReference MakeNullable() => AsTypeReference().Nullable(); + /// + /// Creates a nullable structured type reference, using the specified generation settings to determine whether + /// nullable context is enabled or unknown. + /// + /// The generation settings to use. + /// The modified type reference. public TypeReference MakeNullable(GenerationSettings settings) => AsTypeReference().Nullable(settings); + /// + /// Creates a nullable structured type reference, using the specified code writer to determine whether + /// nullable context is enabled or unknown. + /// + /// The code writer to use. + /// The modified type reference. public TypeReference MakeNullable(CodeWriter writer) => AsTypeReference().Nullable(writer); + /// + /// Creates a nullable structured type reference, using the specified compilation to determine whether + /// nullable context is enabled or unknown. + /// + /// The compilation to use. + /// The modified type reference. + public TypeReference MakeNullable(Compilation compilation) => AsTypeReference().Nullable(compilation); + /// Creates an array structured type reference with the specified rank. public TypeReference MakeArray(int rank = 1) => AsTypeReference().MakeArray(rank); diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs index 31976e5..a016b99 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -241,6 +241,17 @@ public TypeReference Nullable(CodeWriter writer) => : writer.IsNullableContextEnabled is null or true ? AppendNullable(TypeModifier.Nullable) : this; + /// + /// Appends a nullable annotation if the given settings indicate that nullable context is enabled or unknown. + /// + /// The compilation to use. + /// The modified type reference. + /// If is . + public TypeReference Nullable(Compilation compilation) => + compilation == null ? throw new ArgumentNullException(nameof(compilation)) + : IncrementalPipeline.IsNullableContextEnabled(compilation) is null or true ? AppendNullable(TypeModifier.Nullable) + : this; + /// Appends an array of the given rank. public TypeReference MakeArray(int rank = 1) => Append(TypeModifier.Array(rank)); From 286b26acfb376e37c795b2ee4084f9cdff1b7963 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 12:10:28 +0100 Subject: [PATCH 2/5] chore: bumped version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a13b25..b66c6d7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.29", + "version": "1.0.0-prerelease.30", "private": true } From 730ff789038cc0a496317b0b3a255ef16f37c4e0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 12:11:03 +0100 Subject: [PATCH 3/5] chore: formatting --- src/src/SourceGeneratorShared/TypeReference.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs index a016b99..55b0ea0 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -249,7 +249,8 @@ public TypeReference Nullable(CodeWriter writer) => /// If is . public TypeReference Nullable(Compilation compilation) => compilation == null ? throw new ArgumentNullException(nameof(compilation)) - : IncrementalPipeline.IsNullableContextEnabled(compilation) is null or true ? AppendNullable(TypeModifier.Nullable) + : IncrementalPipeline.IsNullableContextEnabled(compilation) is null or true + ? AppendNullable(TypeModifier.Nullable) : this; /// Appends an array of the given rank. From 3bbe1629d0f5e6e1b1bff9981393c5b5bbac33fb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 15:45:39 +0100 Subject: [PATCH 4/5] refactor: nullable detection in the code writer has been firmed up --- global.json | 2 +- .../SourceGeneratorFramework.Analyzers.csproj | 5 +- ...SourceGeneratorFramework.CodeFixers.csproj | 7 +- ...amework.ExampleGenerator.CodeFixers.csproj | 7 +- ...GeneratorFramework.ExampleGenerator.csproj | 5 +- ...SourceGeneratorFramework.Generators.csproj | 5 +- .../SourceGeneratorFramework/Sdk/README.md | 12 +- .../SourceGeneratorFramework.csproj | 47 +------- src/src/SourceGeneratorShared/CodeWriter.cs | 37 +++++-- .../GenerationSettings.cs | 9 +- .../Helpers/IncrementalPipeline.cs | 30 ++--- .../NullableDirectiveMode.cs | 3 +- .../SourceGeneratorShared/TypeReference.cs | 17 ++- .../CodeWriterTests.cs | 68 ++++++++++++ ...elineTests_NullableContextTestGenerator.cs | 103 ++++++++++++++++++ .../ExplicitNullableContextTestGenerator.cs | 43 ++++++++ .../NullableContextTestGenerator.cs | 42 +++++++ .../TypeReferenceTests.cs | 30 +++++ 18 files changed, 376 insertions(+), 96 deletions(-) create mode 100644 src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs create mode 100644 src/tests/SourceGeneratorShared.UnitTests/TestGenerators/ExplicitNullableContextTestGenerator.cs create mode 100644 src/tests/SourceGeneratorShared.UnitTests/TestGenerators/NullableContextTestGenerator.cs diff --git a/global.json b/global.json index e60c344..776b857 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.DotNetProjectSdk": "1.0.0-prerelease.45" + "Purview.DotNetProjectSdk": "1.0.0-prerelease.46" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj index 01905a5..b1a631d 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj +++ b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj @@ -1,7 +1,6 @@  true - $(RootNamespace) @@ -9,8 +8,8 @@ - - + + true - $(RootNamespace) @@ -9,9 +8,9 @@ - - - + + + - false true - - - + + + diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj b/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj index 3d24ab0..750485e 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj @@ -1,6 +1,5 @@  - false true $(NoWarn);IDE0130; @@ -31,7 +30,7 @@ - - + + diff --git a/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj b/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj index 01905a5..b1a631d 100644 --- a/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj +++ b/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj @@ -1,7 +1,6 @@  true - $(RootNamespace) @@ -9,8 +8,8 @@ - - + + () with }; ``` +If your generated output must use nullable reference annotations regardless of the consuming project's `` setting, configure `NullableDirectiveMode.Always` (or set `IsNullableContextEnabled = true`) on the `GenerationSettings` passed into the pipeline; the configured value is honoured instead of the compilation's state. + ### Nullable reference annotations in generated types `TypeReference.Nullable()` and `TypeIdentity.MakeNullable()` produce nullable annotations. When the target compilation does not support nullable, a nullable *reference* annotation (`string?`) is invalid outside a nullable context and is elided, while a nullable *value* type (`int?`) is always emitted. @@ -199,6 +201,8 @@ writer.WriteType(PurviewTypeLibrary.System.String.MakeNullable(writer)); // elid writer.WriteType(TypeIdentity.Create().MakeNullable()); // "string" when nullable is off, "string?" when on ``` +Composition and rendering both resolve `NullableDirectiveMode` together with `IsNullableContextEnabled`, so an `Always` mode keeps the `?` even for a nullable-disabled target, and a `Disable` mode strips it even when the target enables nullable. + The analyzer `PSGFR16` (suggestion) flags bare `Nullable()`/`MakeNullable()` calls and its code fix passes the first in-scope `CodeWriter` or `GenerationSettings`, including project-wide "Fix all" support. ### Comparing references with or without annotations diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index 28a840f..03e68ae 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -12,7 +12,10 @@ true true snupkg - $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared;IncludeAnalyzerAssembly + + false + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared $(TargetsForTfmSpecificDebugSymbolsInPackage);IncludeSourceGeneratorSharedSymbols @@ -22,15 +25,12 @@ PrivateAssets="all" ReferenceOutputAssembly="false" OutputItemType="Analyzer" - Targets="GetSourceGeneratorAnalyzerFiles" /> - - + + - - - - - - - - - - - - - - - - - analyzers/dotnet/cs/ - - - diff --git a/src/src/SourceGeneratorShared/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs index 495a0fc..4633ae5 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -102,8 +102,8 @@ public CodeWriter( public string GeneratorVersion { get; } /// - /// Gets or sets how the #nullable enable directive is emitted by - /// . The value is seeded from + /// Gets or sets how nullable annotations and the #nullable enable directive are emitted by + /// and type rendering. The value is seeded from /// at construction. /// public NullableDirectiveMode NullableDirectiveMode { get; set; } @@ -113,6 +113,11 @@ public CodeWriter( /// seeded from at construction and is /// when the state is unknown. /// + /// + /// When is or + /// , the mode controls both the header directive and + /// rendered annotations; this value only drives the mode. + /// public bool? IsNullableContextEnabled { get; set; } /// @@ -1337,9 +1342,9 @@ public CodeWriter WriteConstructor(ConstructorDeclarationOptions declaration, Ac /// The generator name; defaults to . /// The generator version; defaults to . /// - /// Controls whether the #nullable enable directive is emitted. When , - /// is used, which defaults to - /// . + /// Controls whether the #nullable enable directive is emitted and whether nullable + /// reference annotations are rendered. When , + /// is used, which defaults to . /// /// The pragmas to include in the header. /// The current writer. @@ -1391,10 +1396,17 @@ static bool ShouldWriteNullableDirective(NullableDirectiveMode mode, bool? isNul { NullableDirectiveMode.Always => true, NullableDirectiveMode.Disable => false, - NullableDirectiveMode.Auto => isNullableContextEnabled ?? true, + NullableDirectiveMode.Auto => isNullableContextEnabled is null or true, _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown nullable directive mode."), }; + /// + /// Resolves whether nullable reference annotations are emitted by this writer, reconciling + /// and so that the + /// header directive and type rendering always agree. + /// + bool ShouldUseNullableAnnotations => ShouldWriteNullableDirective(NullableDirectiveMode, IsNullableContextEnabled); + /// /// Writes a declaration. /// @@ -2813,9 +2825,10 @@ string parameterName /// The type reference to write. /// The current writer. /// - /// When the writer's is , nullable reference - /// annotations such as string? are elided because they are invalid outside a nullable context. - /// Nullable value types such as int? are always written. + /// When the writer's nullable context resolves to disabled (see + /// and ), nullable reference annotations such as + /// string? are elided because they are invalid outside a nullable context. Nullable value + /// types such as int? are always written. /// public CodeWriter WriteType(TypeReference reference) { @@ -2826,7 +2839,7 @@ public CodeWriter WriteType(TypeReference reference) ValidateTypeReference(reference, nameof(reference)); - return Write(reference.RenderFullNameForNullable(IsNullableContextEnabled is not false)); + return Write(reference.RenderFullNameForNullable(ShouldUseNullableAnnotations)); } CodeWriter WriteTypeReference(TypeReference reference) @@ -2836,13 +2849,13 @@ CodeWriter WriteTypeReference(TypeReference reference) ValidateTypeReference(reference, nameof(reference)); - Write(reference.RenderFullNameForNullable(IsNullableContextEnabled is not false)); + Write(reference.RenderFullNameForNullable(ShouldUseNullableAnnotations)); return this; } int GetTypeReferenceLength(TypeReference type) => - type.IsEmpty ? 0 : type.RenderFullNameForNullable(IsNullableContextEnabled is not false).Length; + type.IsEmpty ? 0 : type.RenderFullNameForNullable(ShouldUseNullableAnnotations).Length; static void ValidateTypeReference(TypeReference reference, string parameterName) { diff --git a/src/src/SourceGeneratorShared/GenerationSettings.cs b/src/src/SourceGeneratorShared/GenerationSettings.cs index 21994a5..8440c3e 100644 --- a/src/src/SourceGeneratorShared/GenerationSettings.cs +++ b/src/src/SourceGeneratorShared/GenerationSettings.cs @@ -31,16 +31,17 @@ public GenerationSettings( public string? DisabledSourceGenMSBuildProperty { get; } /// - /// Gets how the #nullable enable directive is emitted by generated headers written via - /// CodeWriter.WriteAutoGeneratedHeader. The default is , + /// Gets how nullable annotations and the #nullable enable directive are emitted by generated + /// code written via CodeWriter. The default is , /// which uses when it is known. /// public NullableDirectiveMode NullableDirectiveMode { get; init; } = NullableDirectiveMode.Auto; /// /// Gets whether the target compilation has nullable annotations enabled. The incremental pipeline - /// sets this when the compilation is available; when the value is unknown, - /// such as for post-initialization outputs or tests that construct settings directly. + /// sets this from the compilation when it is available and no explicit value was configured; + /// when the value is unknown, such as for post-initialization outputs or + /// tests that construct settings directly. /// public bool? IsNullableContextEnabled { get; init; } diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs index 35030d8..9a1d9ab 100644 --- a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs @@ -98,17 +98,20 @@ public static IncrementalValueProvider< $"Creating generation context ({typeof(TCapabilities)}) for compilation '{input.Left.AssemblyName}'." ); - settings = settings with + // Honour an explicitly configured nullable-context value first, falling back to the + // compilation's nullable annotations state only when one was not specified. + var resolvedSettings = settings with { ValidateCodeWriterScopes = configuration.ValidateCodeWriterScopes, IsSourceGeneratorDisabled = configuration.IsSourceGeneratorDisabled, IsLoggingEnabled = logger is not null, - IsNullableContextEnabled = IsNullableContextEnabled(compilation), + IsNullableContextEnabled = + settings.IsNullableContextEnabled ?? IsNullableContextEnabled(compilation), }; - var capabilities = factory(compilation, settings, logger, cancellationToken); + var capabilities = factory(compilation, resolvedSettings, logger, cancellationToken); - return new GenerationContext(capabilities, settings, logger); + return new GenerationContext(capabilities, resolvedSettings, logger); } ) .WithTrackingName($"GetGenerationContext_{typeof(TCapabilities).Name}"); @@ -117,16 +120,17 @@ public static IncrementalValueProvider< /// /// Determines whether the compilation has nullable annotations enabled, which is the case when /// the compilation options allow nullable annotations. Returns when the - /// compilation is not a C# compilation. + /// compilation is not a C# compilation or the state cannot be determined. /// -#pragma warning disable format - public static bool? IsNullableContextEnabled(Compilation compilation) => - compilation - is CSharpCompilation - { - Options.NullableContextOptions: NullableContextOptions.Annotations or NullableContextOptions.Enable - }; -#pragma warning restore format + public static bool? IsNullableContextEnabled(Compilation compilation) + { + if (compilation is not CSharpCompilation csharpCompilation) + return null; + + return csharpCompilation.Options.NullableContextOptions + is NullableContextOptions.Annotations + or NullableContextOptions.Enable; + } static IncrementalValueProvider GenerationConfigurationValueProvider( IncrementalGeneratorInitializationContext context, diff --git a/src/src/SourceGeneratorShared/NullableDirectiveMode.cs b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs index fb33f74..60e9cfb 100644 --- a/src/src/SourceGeneratorShared/NullableDirectiveMode.cs +++ b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs @@ -1,7 +1,8 @@ namespace Purview.SourceGeneratorFramework; /// -/// Controls whether the #nullable enable directive is emitted by WriteAutoGeneratedHeader. +/// Controls whether the #nullable enable directive is emitted by WriteAutoGeneratedHeader +/// and whether nullable reference annotations are rendered by type writing. Both stay in lockstep. /// public enum NullableDirectiveMode { diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs index 55b0ea0..6ada51f 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -227,18 +227,20 @@ public string RenderAttributeName /// If is . public TypeReference Nullable(GenerationSettings settings) => settings == null ? throw new ArgumentNullException(nameof(settings)) - : settings.IsNullableContextEnabled is null or true ? AppendNullable(TypeModifier.Nullable) + : ShouldComposeNullable(settings.NullableDirectiveMode, settings.IsNullableContextEnabled) + ? AppendNullable(TypeModifier.Nullable) : this; /// - /// Appends a nullable annotation if the given settings indicate that nullable context is enabled or unknown. + /// Appends a nullable annotation if the given code writer indicates that nullable context is enabled or unknown. /// /// The code writer to use. /// The modified type reference. /// If is . public TypeReference Nullable(CodeWriter writer) => writer == null ? throw new ArgumentNullException(nameof(writer)) - : writer.IsNullableContextEnabled is null or true ? AppendNullable(TypeModifier.Nullable) + : ShouldComposeNullable(writer.NullableDirectiveMode, writer.IsNullableContextEnabled) + ? AppendNullable(TypeModifier.Nullable) : this; /// @@ -259,6 +261,15 @@ public TypeReference Nullable(Compilation compilation) => /// Appends a pointer indirection. public TypeReference MakePointer() => Append(TypeModifier.PointerModifier); + static bool ShouldComposeNullable(NullableDirectiveMode mode, bool? isNullableContextEnabled) => + mode switch + { + NullableDirectiveMode.Always => true, + NullableDirectiveMode.Disable => false, + NullableDirectiveMode.Auto => isNullableContextEnabled is null or true, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown nullable directive mode."), + }; + TypeReference AppendNullable(TypeModifier nullable) { if (nullable.Kind == TypeModifierKind.Nullable && nullable.NullableKind == NullableModifierKind.Unknown) diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index d680b2a..6adb31c 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -1943,6 +1943,74 @@ public async Task WriteType_GivenNullableEnabledOrUnknownContext_KeepsAnnotation await Assert.That(unknown.ToString()).IsEqualTo("string?"); } + [Test] + public async Task WriteType_GivenAlwaysModeAndDisabledContext_KeepsAnnotations() + { + var writer = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Always, + IsNullableContextEnabled = false, + } + ); + + writer.WriteType(TypeIdentity.Create().MakeNullable()); + writer.Write(" "); + writer.WriteType(TypeIdentity.Create().MakeNullable()); + + await Assert.That(writer.ToString()).IsEqualTo("string? int?"); + } + + [Test] + public async Task WriteType_GivenDisableModeAndEnabledContext_StripsReferenceAnnotations() + { + var writer = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + IsNullableContextEnabled = true, + } + ); + + writer.WriteType(TypeIdentity.Create().MakeNullable()); + writer.Write(" "); + writer.WriteType(TypeIdentity.Create().MakeNullable()); + + await Assert.That(writer.ToString()).IsEqualTo("string int?"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenAlwaysModeAndDisabledContext_WritesDirective() + { + var writer = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Always, + IsNullableContextEnabled = false, + } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).Contains("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenDisableModeAndEnabledContext_OmitsDirective() + { + var writer = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + IsNullableContextEnabled = true, + } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).DoesNotContain("#nullable enable"); + } + [Test] public async Task WriteType_GivenNullReference_Throws() { diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs new file mode 100644 index 0000000..df6fe9f --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs @@ -0,0 +1,103 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Purview.SourceGeneratorFramework.TestGenerators; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Helpers; + +public class IncrementalPipelineTests_NullableContextTestGenerator + : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GenerationContext_GivenNullableEnabledCompilation_WritesDirectiveAndAnnotation( + CancellationToken cancellationToken + ) + { + var result = await GenerateAsync( + "public sealed class Sample { }", + new() { NullableContextOptions = NullableContextOptions.Enable }, + cancellationToken + ); + + var source = result.GetSource(); + + await Assert.That(source).Contains("#nullable enable"); + await Assert.That(source).Contains("string? Name"); + } + + [Test] + public async Task GenerationContext_GivenNullableDisabledCompilation_OmitsDirectiveAndStripsAnnotation( + CancellationToken cancellationToken + ) + { + var result = await GenerateAsync( + "public sealed class Sample { }", + new() { NullableContextOptions = NullableContextOptions.Disable }, + cancellationToken + ); + + var source = result.GetSource(); + + await Assert.That(source).DoesNotContain("#nullable enable"); + await Assert.That(source).DoesNotContain("string? Name"); + await Assert.That(source).Contains("string Name"); + } +} + +public class IncrementalPipelineTests_ExplicitNullableContextTestGenerator + : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GenerationContext_GivenExplicitNullableEnabled_OverridesDisabledCompilation( + CancellationToken cancellationToken + ) + { + var result = await GenerateAsync( + "public sealed class Sample { }", + new() { NullableContextOptions = NullableContextOptions.Disable }, + cancellationToken + ); + + var source = result.GetSource(); + + await Assert.That(source).Contains("#nullable enable"); + await Assert.That(source).Contains("string? Name"); + } +} + +public class IncrementalPipelineNullableDetectionTests +{ + [Test] + public async Task IsNullableContextEnabled_GivenNullCompilation_ReturnsNull() + { + await Assert.That(IncrementalPipeline.IsNullableContextEnabled(null!)).IsNull(); + } + + [Test] + public async Task IsNullableContextEnabled_GivenEnabledCompilation_ReturnsTrue() + { + var compilation = TestCompilation + .Create("public sealed class Sample { }") + .WithOptions( + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithNullableContextOptions( + NullableContextOptions.Enable + ) + ); + + await Assert.That(IncrementalPipeline.IsNullableContextEnabled(compilation)).IsTrue(); + } + + [Test] + public async Task IsNullableContextEnabled_GivenDisabledCompilation_ReturnsFalse() + { + var compilation = TestCompilation + .Create("public sealed class Sample { }") + .WithOptions( + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithNullableContextOptions( + NullableContextOptions.Disable + ) + ); + + await Assert.That(IncrementalPipeline.IsNullableContextEnabled(compilation)).IsFalse(); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/ExplicitNullableContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/ExplicitNullableContextTestGenerator.cs new file mode 100644 index 0000000..89166be --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/ExplicitNullableContextTestGenerator.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +/// +/// Emits a generated file whose header and nullable reference annotation are driven by an explicit +/// value, proving that a configured value +/// takes precedence over the compilation's nullable context. +/// +public sealed class ExplicitNullableContextTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + new GenerationSettings("ExplicitNullableContextTestGenerator", "1.0.0") { IsNullableContextEnabled = true } + ); + + context.RegisterSourceOutput( + generationContext, + static (spc, ctx) => + { + var writer = ctx.CreateCodeWriter(); + writer.WriteAutoGeneratedHeader(); + writer.WriteFileScopedNamespace("Test"); + writer.WriteClass( + new TypeDeclarationOptions("Sample") { Accessibility = TypeDeclarationAccessibility.Public }, + body => + body.WriteProperty( + new( + "Name", + TypeIdentity.Create().MakeNullable(writer), + TypeDeclarationAccessibility.Public + ) + ) + ); + + spc.AddSource("ExplicitNullableContext.g.cs", writer.ToString()); + } + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/NullableContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/NullableContextTestGenerator.cs new file mode 100644 index 0000000..bce1bd0 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/NullableContextTestGenerator.cs @@ -0,0 +1,42 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +/// +/// Emits a generated file whose header and nullable reference annotation are derived from the +/// compilation's nullable context through the pipeline-resolved settings. +/// +public sealed class NullableContextTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + new GenerationSettings("NullableContextTestGenerator", "1.0.0") + ); + + context.RegisterSourceOutput( + generationContext, + static (spc, ctx) => + { + var writer = ctx.CreateCodeWriter(); + writer.WriteAutoGeneratedHeader(); + writer.WriteFileScopedNamespace("Test"); + writer.WriteClass( + new TypeDeclarationOptions("Sample") { Accessibility = TypeDeclarationAccessibility.Public }, + body => + body.WriteProperty( + new( + "Name", + TypeIdentity.Create().MakeNullable(writer), + TypeDeclarationAccessibility.Public + ) + ) + ); + + spc.AddSource("NullableContext.g.cs", writer.ToString()); + } + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs index 58182a0..b38bac8 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs @@ -130,6 +130,36 @@ public async Task Nullable_GivenWriterContext_BehavesLikeSettings() await Assert.That(@string.MakeNullable(enabled).RenderFullName).IsEqualTo("string?"); } + [Test] + public async Task Nullable_GivenAlwaysModeAndDisabledContext_AppendsAnnotation() + { + var settings = new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Always, + IsNullableContextEnabled = false, + }; + var writer = new CodeWriter(settings); + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable(settings).RenderFullName).IsEqualTo("string?"); + await Assert.That(@string.MakeNullable(writer).RenderFullName).IsEqualTo("string?"); + } + + [Test] + public async Task Nullable_GivenDisableModeAndEnabledContext_DoesNotAppendAnnotation() + { + var settings = new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + IsNullableContextEnabled = true, + }; + var writer = new CodeWriter(settings); + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable(settings).RenderFullName).IsEqualTo("string"); + await Assert.That(@string.MakeNullable(writer).RenderFullName).IsEqualTo("string"); + } + [Test] public async Task Nullable_GivenNullSettingsOrWriter_Throws() { From 3ec963e887b9647169b8463514393bc74d41a920 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 16:04:07 +0100 Subject: [PATCH 5/5] refactor: flipped the nullable test setup to be enabled by default --- .../SourceGeneratorTestOptions.cs | 6 +-- ...elineTests_NullableContextTestGenerator.cs | 21 ++++++++ .../AlwaysNullableContextTestGenerator.cs | 49 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/tests/SourceGeneratorShared.UnitTests/TestGenerators/AlwaysNullableContextTestGenerator.cs diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs index 9802398..70f5fdc 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs @@ -185,10 +185,10 @@ public SourceGeneratorTestOptions() /// /// Gets the nullable context of the test compilation. The default is - /// , mirroring the framework's auto-detection of the - /// #nullable enable directive in generated headers. + /// , so test compilations accept nullable annotations + /// and the framework's auto-detection emits the #nullable enable directive in generated headers. /// - public NullableContextOptions NullableContextOptions { get; init; } = NullableContextOptions.Disable; + public NullableContextOptions NullableContextOptions { get; init; } = NullableContextOptions.Enable; /// /// Gets the language version of the test compilation. diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs index df6fe9f..14c4c08 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs @@ -65,6 +65,27 @@ CancellationToken cancellationToken } } +public class IncrementalPipelineTests_AlwaysNullableContextTestGenerator + : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GenerationContext_GivenAlwaysModeAndDisabledCompilation_WritesDirectiveAndAnnotation( + CancellationToken cancellationToken + ) + { + var result = await GenerateAsync( + "public sealed class Sample { }", + new() { NullableContextOptions = NullableContextOptions.Disable }, + cancellationToken + ); + + var source = result.GetSource(); + + await Assert.That(source).Contains("#nullable enable"); + await Assert.That(source).Contains("string? Name"); + } +} + public class IncrementalPipelineNullableDetectionTests { [Test] diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/AlwaysNullableContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/AlwaysNullableContextTestGenerator.cs new file mode 100644 index 0000000..139d03f --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/AlwaysNullableContextTestGenerator.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +/// +/// Mirrors a downstream generator that opts into nullable annotations regardless of the consuming +/// compilation by passing explicit settings with through +/// the settings-based pipeline overload. +/// +public sealed class AlwaysNullableContextTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var settings = GenerationSettings.Create() with + { + NullableDirectiveMode = NullableDirectiveMode.Always, + }; + + var generationContext = IncrementalPipeline.GenerationContextValueProvider( + context, + settings, + static (_, _, _, _) => EmptyCapabilities.Instance + ); + + context.RegisterSourceOutput( + generationContext, + static (spc, ctx) => + { + var writer = ctx.CreateCodeWriter(); + writer.WriteAutoGeneratedHeader(); + writer.WriteFileScopedNamespace("Test"); + writer.WriteClass( + new TypeDeclarationOptions("Sample") { Accessibility = TypeDeclarationAccessibility.Public }, + body => + body.WriteProperty( + new( + "Name", + TypeIdentity.Create().MakeNullable(writer), + TypeDeclarationAccessibility.Public + ) + ) + ); + + spc.AddSource("AlwaysNullableContext.g.cs", writer.ToString()); + } + ); + } +}