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/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 } 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 @@ - - + + /// 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/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index 6d5461f..3203bf8 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -164,11 +164,11 @@ See [`SourceGeneratorFramework.ExampleGenerator`](../SourceGeneratorFramework.Ex ### The `#nullable enable` directive -`WriteAutoGeneratedHeader()` emits `#nullable enable` according to a `NullableDirectiveMode`: +`WriteAutoGeneratedHeader()` emits `#nullable enable` according to a `NullableDirectiveMode`. The same mode also controls whether nullable *reference* annotations are rendered by type writing, so the directive and the emitted annotations always agree: -- `Auto` (default) — the framework reads the target compilation's nullable context when the pipeline creates the generation context and emits the directive only when nullable annotations are enabled. When the state is unknown (for example in post-initialization outputs or tests), the directive is still emitted. -- `Always` — always emit `#nullable enable`. -- `Disable` — never emit the directive. +- `Auto` (default) — the framework reads the target compilation's nullable context when the pipeline creates the generation context and emits the directive only when nullable annotations are enabled. When the state is unknown (for example in post-initialization outputs or tests), the directive is still emitted. An explicitly configured `GenerationSettings.IsNullableContextEnabled` value takes precedence over the compilation's state. +- `Always` — always emit `#nullable enable` and always render nullable reference annotations, even when the target compilation disables nullable. +- `Disable` — never emit the directive and never render nullable reference annotations. Override it per call, or set a generator-wide default on `GenerationSettings`: @@ -181,6 +181,8 @@ var settings = GenerationSettings.Create() 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 64bb13f..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 - 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/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..6ada51f 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -227,18 +227,32 @@ 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; + + /// + /// 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. @@ -247,6 +261,15 @@ public TypeReference Nullable(CodeWriter writer) => /// 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..14c4c08 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_NullableContextTestGenerator.cs @@ -0,0 +1,124 @@ +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 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] + 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/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()); + } + ); + } +} 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() {