Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<auto-generated />` 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 `<auto-generated />` 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<T>`
Expand Down Expand Up @@ -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 |
Expand Down
31 changes: 30 additions & 1 deletion SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,42 @@
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());

Check warning on line 28 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48s&open=AaGb2KUAyEi22LFzq48s&pullRequest=61

Check warning on line 28 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48t&open=AaGb2KUAyEi22LFzq48t&pullRequest=61
Assert.AreEqual(0, tree.GetDiagnostics().Count(), string.Join("\n", tree.GetDiagnostics()));

Check warning on line 29 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48v&open=AaGb2KUAyEi22LFzq48v&pullRequest=61

Check warning on line 29 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48w&open=AaGb2KUAyEi22LFzq48w&pullRequest=61

Check warning on line 29 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48u&open=AaGb2KUAyEi22LFzq48u&pullRequest=61
}

[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(), "// <auto-generated />\n#nullable enable\n");

Check warning on line 42 in SourceGeneratorToolkit.Test/GeneratorHarnessTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_SourceGeneratorToolkit&issues=AaGb2KUAyEi22LFzq48r&open=AaGb2KUAyEi22LFzq48r&pullRequest=61
}

[TestMethod]
public void AnOverrideReplacesTheRealFileContents()
Expand Down
19 changes: 16 additions & 3 deletions SourceGeneratorToolkit.Testing/GeneratorHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ namespace ktsu.SourceGeneratorToolkit.Testing;
/// <param name="metadataDirectory">The directory the metadata files were copied to.</param>
public sealed class GeneratorHarness(string metadataDirectory)
{
/// <summary>
/// Gets the C# language version of the compilation the generators run against.
/// </summary>
/// <remarks>
/// <see cref="LanguageVersion.Default"/> unless set. Set it to an older version, such as
/// <see cref="LanguageVersion.CSharp7_3"/>, to check that a generator's output still parses for a
/// <c>netstandard2.0</c> or .NET Framework consumer that does not set <c>LangVersion</c>.
/// </remarks>
public LanguageVersion LanguageVersion { get; init; } = LanguageVersion.Default;

/// <summary>
/// Runs a generator against every metadata file in the directory.
/// </summary>
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -184,11 +194,14 @@ private List<AdditionalText> BuildTexts(IReadOnlyDictionary<string, string>? ove
return texts;
}

private static GeneratorDriverRunResult Drive(IReadOnlyList<IIncrementalGenerator> generators, List<AdditionalText> texts)
private CSharpParseOptions ParseOptions => new(LanguageVersion);

private GeneratorDriverRunResult Drive(IReadOnlyList<IIncrementalGenerator> generators, List<AdditionalText> texts)
{
GeneratorDriver driver = CSharpGeneratorDriver.Create(
generators: [.. generators.Select(generator => generator.AsSourceGenerator())],
additionalTexts: texts);
additionalTexts: texts,
parseOptions: ParseOptions);

return driver.RunGenerators(CreateCompilation()).GetRunResult();
}
Expand Down
72 changes: 67 additions & 5 deletions SourceGeneratorToolkit/GeneratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand Down Expand Up @@ -64,8 +65,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
})
.Collect();

context.RegisterSourceOutput(metadataFiles, (productionContext, files) =>
IncrementalValueProvider<(ImmutableArray<MetadataFile> Files, ParseOptions Options)> inputs =
metadataFiles.Combine(context.ParseOptionsProvider);

context.RegisterSourceOutput(inputs, (productionContext, input) =>
{
ImmutableArray<MetadataFile> 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.
Expand All @@ -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)));
});
}

/// <summary>
/// Runs an action with the consumer's language version available to <see cref="WriteFileHeader(CodeBlocker, string?)"/>.
/// </summary>
/// <param name="languageVersion">The consuming compilation's language version, if known.</param>
/// <param name="action">The action to run.</param>
/// <remarks>
/// 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.
/// </remarks>
private static void RunForLanguageVersion(LanguageVersion? languageVersion, Action action)
{
LanguageVersion? previous = consumerLanguageVersion;
consumerLanguageVersion = languageVersion;
try
{
action();
}
finally
{
consumerLanguageVersion = previous;
}
}

/// <summary>
/// Emits this generator's sources.
/// </summary>
Expand Down Expand Up @@ -115,6 +146,13 @@ protected static CodeBlocker CreateCodeBlocker() =>
/// </summary>
private static readonly string[] CopyrightLineBreaks = ["\r\n", "\n", "\r", "\u0085", "\u2028", "\u2029"];

/// <summary>
/// The language version of the compilation the generator is currently producing sources for, or
/// <see langword="null"/> outside <see cref="Generate"/>.
/// </summary>
[ThreadStatic]
private static LanguageVersion? consumerLanguageVersion;

/// <summary>
/// Writes the header every generated file starts with.
/// </summary>
Expand All @@ -136,9 +174,29 @@ protected static CodeBlocker CreateCodeBlocker() =>
/// <c>Nullable</c> setting, so without the directive every <c>?</c> annotation in the output
/// raises CS8669 and every unannotated reference member is null-oblivious to its callers.
/// </para>
/// <para>
/// <c>#nullable</c> 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 <c>netstandard2.0</c> or .NET Framework project gets by
/// default. Called from <see cref="Generate"/>, the version is the consuming compilation's; called
/// anywhere else, it is unknown and the directive is written. Use the overload that takes a
/// <see cref="LanguageVersion"/> to choose explicitly.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="codeBlocker"/> is <see langword="null"/>.</exception>
protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright)
protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright) =>
WriteFileHeader(codeBlocker, copyright, consumerLanguageVersion ?? LanguageVersion.Latest);

/// <summary>
/// Writes the header every generated file starts with, for a consumer on the given language version.
/// </summary>
/// <param name="codeBlocker">The <see cref="CodeBlocker"/> to write to.</param>
/// <param name="copyright">The copyright, as for <see cref="WriteFileHeader(CodeBlocker, string?)"/>.</param>
/// <param name="languageVersion">
/// The consuming compilation's language version. <c>#nullable enable</c> is written only for C# 8
/// and later.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="codeBlocker"/> is <see langword="null"/>.</exception>
protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright, LanguageVersion languageVersion)
{
Ensure.NotNull(codeBlocker);

Expand All @@ -151,7 +209,11 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright
}

codeBlocker.WriteLine("// <auto-generated />");
codeBlocker.WriteLine("#nullable enable");
if (languageVersion.MapSpecifiedToEffectiveVersion() >= LanguageVersion.CSharp8)
{
codeBlocker.WriteLine("#nullable enable");
}

codeBlocker.NewLine();
}

Expand All @@ -160,7 +222,7 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright
/// </summary>
/// <param name="codeBlocker">The <see cref="CodeBlocker"/> to write to.</param>
/// <param name="sourceFileTemplate">The file to write.</param>
/// <param name="copyright">The copyright line, as for <see cref="WriteFileHeader"/>.</param>
/// <param name="copyright">The copyright line, as for <see cref="WriteFileHeader(CodeBlocker, string?)"/>.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="codeBlocker"/> or <paramref name="sourceFileTemplate"/> is <see langword="null"/>.
/// </exception>
Expand Down
Loading