diff --git a/README.md b/README.md
index efd9530..3fb72f5 100644
--- a/README.md
+++ b/README.md
@@ -255,7 +255,7 @@ 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 and the `` marker. A null or empty copyright writes only the marker. 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. |
| `WriteSourceFile(CodeBlocker, SourceFileTemplate, string?)` | `void` | Writes a whole source file, header included. Static. |
### `GeneratorBase`
diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
index 38ba293..5650de2 100644
--- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
+++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
@@ -4,6 +4,7 @@ namespace ktsu.SourceGeneratorToolkit.Test;
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
@@ -293,10 +294,50 @@ public void AMultiLineCopyrightIsCommentedOnEveryLine(string lineBreak)
{
string source = HeaderProbe.Write($"Copyright (c) Example.{lineBreak}{lineBreak}All rights reserved.");
- Assert.AreEqual("// Copyright (c) Example.\n//\n// All rights reserved.\n// \n\nnamespace Generated { }\n", source);
+ Assert.AreEqual("// Copyright (c) Example.\n//\n// All rights reserved.\n// \n#nullable enable\n\nnamespace Generated { }\n", source);
Assert.AreEqual(0, CSharpSyntaxTree.ParseText(source).GetDiagnostics().Count(), source);
}
+ [TestMethod]
+ public void TheHeaderEnablesNullableAfterTheGeneratedMarker() =>
+ StringAssert.Contains(HeaderProbe.Write(null), "// \n#nullable enable\n");
+
+ [TestMethod]
+ public void AnnotatedGeneratedMembersCompileWithoutCS8669()
+ {
+ ImmutableArray diagnostics = CompileWithConsumer("int n = t.Description?.Length ?? 0;");
+
+ Assert.IsFalse(diagnostics.Any(d => d.Id == "CS8669"), string.Join("\n", diagnostics));
+ }
+
+ [TestMethod]
+ public void UnannotatedGeneratedMembersAreNotNullOblivious()
+ {
+ ImmutableArray diagnostics = CompileWithConsumer("t.Name = null;");
+
+ Assert.IsTrue(diagnostics.Any(d => d.Id == "CS8625"), string.Join("\n", diagnostics));
+ }
+
+ ///
+ /// Compiles a class holding a string? and a
+ /// string member, next to a consumer statement, the way a nullable-enabled project would.
+ ///
+ private static ImmutableArray CompileWithConsumer(string statement)
+ {
+ string generated = SourceFileProbe.WriteThingWithNullableAndNonNullableMembers();
+ string consumer = $"namespace Consumer {{ public static class User {{ public static void Use(Generated.Thing t) {{ {statement} }} }} }}";
+
+ CSharpCompilation compilation = CSharpCompilation.Create(
+ assemblyName: "NullableConsumer",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(generated, path: "Thing.g.cs"), CSharpSyntaxTree.ParseText(consumer, path: "User.cs")],
+ references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
+
+ ImmutableArray diagnostics = compilation.GetDiagnostics();
+ Assert.IsFalse(diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error), generated + "\n" + string.Join("\n", diagnostics));
+ return diagnostics;
+ }
+
[TestMethod]
public void TheGeneratorReusesItsOutputWhenNothingChanged()
{
diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs
index 7afef9c..568b33e 100644
--- a/SourceGeneratorToolkit.Test/TestGenerators.cs
+++ b/SourceGeneratorToolkit.Test/TestGenerators.cs
@@ -340,3 +340,32 @@ internal static string Write(string? copyright)
return codeBlocker.ToString();
}
}
+
+///
+/// Exposes the protected whole-file writer, so its output can be compiled alongside a consumer.
+///
+internal abstract class SourceFileProbe : GeneratorBase
+{
+ ///
+ /// Writes Generated.Thing with a string? field and a string field.
+ ///
+ internal static string WriteThingWithNullableAndNonNullableMembers()
+ {
+ SourceFileTemplate file = new() { Namespace = "Generated" };
+ file.Classes.Add(new ClassTemplate
+ {
+ Name = "Thing",
+ Kind = ktsu.CodeBlocker.Templates.TypeKind.Class,
+ Keywords = { CSharpKeywords.Public },
+ Members =
+ {
+ new FieldTemplate { Name = "Description", Type = "string?", Keywords = { CSharpKeywords.Public } },
+ new FieldTemplate { Name = "Name", Type = "string", DefaultValue = "\"\"", Keywords = { CSharpKeywords.Public } },
+ },
+ });
+
+ using CodeBlocker codeBlocker = CreateCodeBlocker();
+ WriteSourceFile(codeBlocker, file, TestDiagnostics.Copyright);
+ return codeBlocker.ToString();
+ }
+}
diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs
index 0ba1891..fa152ad 100644
--- a/SourceGeneratorToolkit/GeneratorBase.cs
+++ b/SourceGeneratorToolkit/GeneratorBase.cs
@@ -128,6 +128,12 @@ protected static CodeBlocker CreateCodeBlocker() =>
/// A parameter rather than a hard-coded literal, so a consuming repository can keep the header in
/// step with its own file header template from one place, and so nothing about any one
/// repository leaks into this package.
+ ///
+ /// The header ends with #nullable enable. Roslyn compiles a file carrying the
+ /// generated-file marker with the nullable context disabled, whatever the consuming project's
+ /// Nullable setting, so without the directive every ? annotation in the output
+ /// raises CS8669 and every unannotated reference member is null-oblivious to its callers.
+ ///
///
/// is .
protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright)
@@ -143,6 +149,7 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright
}
codeBlocker.WriteLine("// ");
+ codeBlocker.WriteLine("#nullable enable");
codeBlocker.NewLine();
}