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..df666cd 100644
--- a/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs
+++ b/SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs
@@ -5,13 +5,42 @@ 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]
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(MetadataDirectory) { 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(MetadataDirectory) { 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..ecb5f50 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,10 +89,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
productionContext.Report(MetadataFileMissing, name);
}
- Generate(productionContext, new MetadataSet(byName));
+ 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.
///
@@ -115,6 +146,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 +174,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 +209,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 +222,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 .
///