📝

.NET 6からのC# Incremental Source Generatorの開発入門

2021/12/18に公開約45,800字

本記事はもなふわすい~とる~むアドベントカレンダー 2021に登録しようと思ったら全部埋まっていた上にC#アドカレその1にも登録できませんでしたのでその2に登録することといたします。

25日の記事です。

筆者環境

  • Windows 10
  • Visual Studio 2022 version 17.0.3
    • .NET Compiler Platform SDK
  • .NET 6.0.101

概要

当記事では昨年作ったEmbedResourceCSharpというSource GeneratorをIncremental Source Generator(以下ISG)に対応させ、Visual Studio 2022以前Roslyn version 3しかない環境でもNuget経由でインストールして動作するようにします。

背景

Source Generatorの失敗

.NET 5とC#9.0と共に華々しく登場したSource Generator(以下SG)は当初ボイラープレートコードを削減することを期待されていました。
実際SGはT4テンプレートでは対応しきれないような複数のファイルに散らばった定型コードを良い感じに削減することに成功しました。

しかし、ある程度の規模のプロジェクトでSGを使用するとエディタ体験が悲惨なことになりました(EventSource SGが重すぎた話)。
APIの構造上全然キャッシュが効かないのでエディタで一文字変更する毎にSGがゴリゴリ走って全ソースコードを舐めていました。

先程のEventSource SGの例では毎分14GBアロケーションしていたそうです。ナンテコッタイ

Incremental Source Generatorの登場

Roslynはエディタ体験も重視しているコンパイラで当たり前のようにインクリメンタルにソースコードの解釈を行います。
Red Green Treeなる構造を採用する等してインクリメンタルな動作を実現しています。

「SGもRoslyn本体のようにインクリメンタルに振る舞えるようにする」
そのためにソースコード生成はパイプラインとして再構築されました。

パイプラインというよりはReactive ExtensionsからOnErrorとOnCompletedを取り払った感があります。

これから実装するEmbedResourceCSharpとは

C#においてリソース埋め込みをする標準的な方法はcsprojにEmbeddedResourceタグでファイルを指定し、C#プログラム中でAssembly.GetManifestResourceStreamを呼び出してStreamを取得する方法です。

Assembly.GetExecutingAssemblyでアロケーション、Assembly.GetManifestResourceStreamでアロケーション、更にその上化石のようなStream API!

私はつらい!耐えられない!何で素直にReadOnlySpan<byte>を読ませてくれないのでしょうか!

Sample.cs
using EmbedResourceCSharp;
class Hoge
{
    [FileEmbed("../../.settings/init.xml")]
    private static partial System.ReadOnlySpan<byte> GetSettingXml();
}

上記のようなpartial methodに属性でリソースファイルのパスを指定したらコンパイル時にファイルの中身のbyte列を埋め込んでくれるのが理想ではありませんか?

次の章からはこれを実現していきましょう。

実装

最初はローカルで動くISG作り

  • EmbedResourceCSharp.sln
  • src
    • EmbedResourceCSharp
    • EmbedResourceCSharp.csproj
    • Generator.cs
  • tests
    • FileTests
    • FileTests.csproj
    • EmbedTests.cs

Class1.csとか自動で生えてくるcsファイルは削除しておきましょう。

EmbedResourceCSharp.csprojを以下の様に書き換えます。TargetFrameworkは未だnetstandard2.0なのがつらい

EmbedResourceCSharp.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>library</OutputType>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
    
    <!-- 以下は好み -->
    <LangVersion>10</LangVersion>
    <Deterministic>true</Deterministic>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

IsRoslynComponentをtrueにすることでデバッグが非常に楽になります。

FileTests.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <LangVersion>9</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="3.1.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\EmbedResourceCSharp\EmbedResourceCSharp.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>

テストプロジェクトにEmbedResourceCSharp.csprojの参照を追加する際にはOutputItemType="Analyzer" ReferenceOutputAssembly="false"を忘れないようにしましょう。

Generator.cs

Generator.cs
namespace EmbedResourceCSharp;

[Microsoft.CodeAnalysis.GeneratorAttribute]
public sealed class Generator : Microsoft.CodeAnalysis.IIncrementalGenerator
{
    public void Initialize(Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext context)
    {
    }
}

特に何もしない雛形なISGですね。
Microsoft.CodeAnalysis.IIncrementalGeneratorを実装したnew()制約を満たすpublicなclassにMicrosoft.CodeAnalysis.GeneratorAttribute属性を付与することでRoslyn Compilerから編集時やビルド時に使用されるようになります。

このGeneratorクラスにはインスタンスフィールドやプロパティを持たせないでください。Roslynチームは好きなタイミングでnew Generator()したり、捨てたりすると宣言しています。

FileEmbedAttribute生成

Microsoft.CodeAnalysis.IncrementalGeneratorInitializationContext構造体に対してまずはFileEmbed属性を出力してみましょう。

Generator.cs
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(callback: GenerateInitialCode);
}

private void GenerateInitialCode(Microsoft.CodeAnalysis.IncrementalGeneratorPostInitializationContext context)
{
    System.Threading.CancellationToken token = context.CancellationToken;
    token.ThrowIfCancellationRequested();
    context.AddSource(hintName: "Attribute.cs", source: @"namespace EmbedResourceCSharp
{
    [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = false)]
    internal sealed class FileEmbedAttribute : global::System.Attribute
    {
        public string Path { get; }

        public FileEmbedAttribute(string path)
        {
            Path = path;
        }
    }
}
");
}

常に生成結果が変わらないソースコードはRegisterPostInitializationOutputメソッドで出力するべきです。
RegisterPostInitializationOutputメソッドはAction<IncrementalGeneratorPostInitializationContext>デリゲートを引数に取ります。

GenerateInitialCodeではCancellationTokenプロパティをチェックしてキャンセル処理を忘れないようにしましょう。
context.AddSourceの第2引数に渡すソースコードに限らず、自動生成する全てのソースコードではglobal usingを警戒してグローバル名前空間エイリアスを常に使うようにしましょう。

FileEmbedAttributeを示すINamedTypeSymbolのmetadataからの取得

FileEmbed属性を含むC#ファイルをプロジェクトに追加した後はその属性をRoslyn上で取得します。
後でソースコードを走査する際に属性が一致しているかどうかを調べる必要があるためです。

Generated.cs
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(callback: GenerateInitialCode);

    Microsoft.CodeAnalysis.IncrementalValueProvider<Microsoft.CodeAnalysis.INamedTypeSymbol> file =
    Microsoft.CodeAnalysis.IncrementalValueProviderExtensions.Select(
        source: context.CompilationProvider,
	selector: static Microsoft.CodeAnalysis.INamedTypeSymbol (Microsoft.CodeAnalysis.Compilation compilation, CancellationToken token) =>
        {
            token.ThrowIfCancellationRequested();
            return compilation.GetTypeByMetadataName("EmbedResourceCSharp.FileEmbedAttribute")
	        ?? throw new System.NullReferenceException("FileEmbedAttribute not found");
        });
}

IncrementalGeneratorInitializationContextにはCompilationProviderプロパティがあります。
これに対して[Microsoft.CodeAnalysis.IncrementalValueProviderExtensions.Select拡張メソッド]でFileEmbed属性のMicrosoft.CodeAnalysis.INamedTypeSymbolを得るようにします。
あらゆるタイミングでキャンセル処理を要求されるので忘れないようにしましょう。
次回以降[Microsoft.CodeAnalysis.IncrementalValueProviderExtensions下の拡張メソッド]を拡張メソッドとして使用しますのでusing Microsoft.CodeAnalysis;があるものとして読んでください。

ソースコードからFileEmbed属性を使用しているメソッドを探す

Generated.cs
    context.RegisterPostInitializationOutput(GenerateInitialCode);
    IncrementalValueProvider<INamedTypeSymbol> file = ...;

    Microsoft.CodeAnalysis.IncrementalValuesProvider<(Microsoft.CodeAnalysis.IMethodSymbol, Microsoft.CodeAnalysis.AttributeData)>
        files = context.SyntaxProvider
            .CreateSyntaxProvider(Predicate, Transform)
            .Combine(file)
            .Select(PostTransform)
            .Where(x => x.Method is not null && x.Path is not null)!;
}

private bool Predicate(Microsoft.CodeAnalysis.SyntaxNode node, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    return node is Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax { AttributeLists.Count: > 0 };
}

private Microsoft.CodeAnalysis.IMethodSymbol? Transform(Microsoft.CodeAnalysis.GeneratorSyntaxContext context, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax syntax = (context.Node as MethodDeclarationSyntax)!;
    Microsoft.CodeAnalysis.IMethodSymbol? symbol = context.SemanticModel.GetDeclaredSymbol(syntax, token);
    return symbol;
}

private (Microsoft.CodeAnalysis.IMethodSymbol? Method, string? Path)
    PostTransform((Microsoft.CodeAnalysis.IMethodSymbol? Method, Microsoft.CodeAnalysis.INamedTypeSymbol Type) pair, CancellationToken token)
{
    Microsoft.CodeAnalysis.IMethodSymbol? method = pair.Method;
    if (method is null)
    {
        return default;
    }

    foreach (Microsoft.CodeAnalysis.AttributeData attribute in method.GetAttributes())
    {
        token.ThrowIfCancellationRequested();
        if (Microsoft.CodeAnalysis.SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, pair.Type))
        {
            return (method, attribute.ConstructorArguments[0].Value as string);
        }
    }

    return default;
}

Predicate

C#ソースコードの全走査を行う場合IncrementalGeneratorInitializationContextSyntaxProviderプロパティに対してCreateSyntaxProviderメソッドを呼び出すことでWhere(Predicate)とSelect(Transform)を一括して行います。

特にPredicateはエディタ上で超高頻度に呼ばれます。
ゼロアロケーションとなるように注意しましょう。

Predicate
bool Predicate(Microsoft.CodeAnalysis.SyntaxNode node, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    return node is Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax { AttributeLists.Count: > 0 };
}

Source Generatorを含むRoslyn界のプログラミングでは派生型へのダウンキャストは当たり前なことですのでオブジェクト指向好きの人はブラウザバックして、どうぞ

Predicate内でこれ以上フィルタリングしてはならない理由

専用のRoslyn Analyzerを作る場合話は別ですが、ISG内でエラーとか警告文を出すつもりがある場合あまりフィルタリングしない方がエラーを拾いやすいです。

Predicate内でも属性の名前でフィルタリングすることは可能です。

example.cs
private bool Predicate(SyntaxNode node, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    if (node is not MethodDeclarationSyntax { AttributeLists.Count: > 0 } declarationSyntax)
    {
        return false;
    }

    for (int i = 0; i < declarationSyntax.AttributeLists.Count; i++)
    {
        Microsoft.CodeAnalysis.CSharp.Syntax.AttributeListSyntax attributeList = declarationSyntax.AttributeLists[i];
        for (int j = 0; j < attributeList.Attributes.Count; j++)
        {
            Microsoft.CodeAnalysis.CSharp.Syntax.AttributeSyntax attribute = attributeList.Attributes[j];
            Microsoft.CodeAnalysis.CSharp.Syntax.NameSyntax name = attribute.Name;
            switch (name)
            {
                case Microsoft.CodeAnalysis.CSharp.Syntax.QualifiedNameSyntax qualifiedName
                    when qualifiedName is { Right.Identifier.Text: "FileEmbed" or "FileEmbedAttribute" }:
                case Microsoft.CodeAnalysis.CSharp.Syntax.SimpleNameSyntax simpleName
                    when simpleName is { Identifier.Text: "FileEmbed" or "FileEmbedAttribute" }:
                    return true;
                default:
                    break;
            }
        }
    }

    return false;
}

この様に名前のガバガバ比較をすること自体はできますが、usingエイリアスを使われた場合漏れが生じるのでやはりやるべきではないでしょう。

Transform

TransformではMicrosoft.CodeAnalysis.GeneratorSyntaxContextから渡ってきたNodeプロパティを適切なMethodDeclarationSyntaxにキャストし、SemanticModelプロパティからMicrosoft.CodeAnalysis.IMethodSymbolを取得します。

private Microsoft.CodeAnalysis.IMethodSymbol? Transform(Microsoft.CodeAnalysis.GeneratorSyntaxContext context, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax syntax = (context.Node as MethodDeclarationSyntax)!;
    Microsoft.CodeAnalysis.IMethodSymbol? symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(context.SemanticModel, syntax, token);
    return symbol;
}

Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol拡張メソッドはCancellationTokenを要求していますので多分重いと思われます。呼び出す回数を減らしたいものですね。

PostTransform

CreateSyntaxProviderの結果に対してfileをCombineしてIncrementalValuesProvider<ValueTuple<IMethodSymbol?, INamedTypeSymbol>>を得ます。
これの各要素に対してPostTransformで処理したくない対象外のメソッドはSelectでnullを返し、Whereで非nullだけを処理するようにします。

private (Microsoft.CodeAnalysis.IMethodSymbol? Method, string? Path)
    PostTransform((Microsoft.CodeAnalysis.IMethodSymbol? Method, Microsoft.CodeAnalysis.INamedTypeSymbol Type) pair, CancellationToken token)
{
    Microsoft.CodeAnalysis.IMethodSymbol? method = pair.Method;
    if (method is null)
    {
        return default;
    }

    foreach (Microsoft.CodeAnalysis.AttributeData attribute in method.GetAttributes())
    {
        token.ThrowIfCancellationRequested();
        if (Microsoft.CodeAnalysis.SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, pair.Type))
        {
            return (method, attribute.ConstructorArguments[0].Value as string);
        }
    }

    return default;
}

FileEmbed属性は常に非nullなstringを引数に取るのでattribute.ConstructorArguments[0].Value as stringでその値を取得します。

ProjectDirを読み取る

ここまでISGを作ってきましたが、リソースファイルを読み込むためには完全なパスが必要です。
FileEmbedではプロジェクトフォルダを起点とした相対パスでリソースファイルを取得しますのでMSBuildのProjectDirプロパティを知らねばなりません。

Options.cs
namespace EmbedResourceCSharp;

internal sealed class Options
{
    public readonly string ProjectDirectory;

    public Options(string projectDirectory)
    {
        ProjectDirectory = projectDirectory;
    }
}

単純にstring ProjectDirectoryだけでも良いのですが、他にも取得したいMSBuildプロパティがあることが多いので専用のOptions型を定義するのが便利です。
気が向けばいつかOptions型を自動生成するISGを作るかもしれません。

Generated.cs
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(GenerateInitialCode);
    var file = ...;
    var files = ...;
    IncrementalValueProvider<Options> options = context.AnalyzerConfigOptionsProvider
        .Select(SelectOptions);
}

private Options SelectOptions(Microsoft.CodeAnalysis.Diagnostics.AnalyzerConfigOptionsProvider provider, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    if (!provider.GlobalOptions.TryGetValue("build_property.ProjectDir", out string? projectDirectory) || projectDirectory is null)
    {
        projectDirectory = "";
    }

    return new(projectDirectory);
}

MSBuildのプロパティを得る場合はMicrosoft.CodeAnalysis.Diagnostics.AnalyzerConfigOptionsProviderGlobalOptionsプロパティに対してTryGetValueします。
"build_property."を先頭に付けるのを忘れないようにしましょう。

C#コードだけだとMSBuildプロパティを読み取れないという罠があります。
.propsまたは.targetsファイルも追加で作成して色々小細工する必要があります。

EmbedResourceCSharp.props
<?xml version="1.0" encoding="utf-8" ?>
<Project>
  <ItemGroup>
    <CompilerVisibleProperty Include="ProjectDir" />
  </ItemGroup>
</Project>

ISGを利用する側のcsprojでCompilerVisiblePropertyを設定してくれないとTryGetValueは常にfalseを返します……

Nugetパッケージとしてpublishする場合は.props/.targetsファイルを強制的に読み込ませてCompilerVisiblePropertyを強制設定させることが可能ですが、ProjectReferenceの場合は無理なようです。

ソースコード生成

Generated.cs
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(GenerateInitialCode);
    var file = ...;
    var files = ...;
    var options = ...;
    
    context.RegisterSourceOutput(files.Combine(options), GenerateFileEmbed);
}

private void GenerateFileEmbed(Microsoft.CodeAnalysis.SourceProductionContext context, ((IMethodSymbol Method, string Path) Left, Options Options) pair)
{
    var token = context.CancellationToken;
    token.ThrowIfCancellationRequested();
    IMethodSymbol method = pair.Left.Method;
    
    string filePath = System.IO.Path.Combine(pair.Options.ProjectDirectory, pair.Left.Path);
    if (!System.IO.File.Exists(filePath))
    {
        Microsoft.CodeAnalysis.Location location = Location.None;
        if (method.AssociatedSymbol is { Locations: { Length: > 0 } locations })
        {
            location = locations[0];
        }

        context.ReportDiagnostic(Microsoft.CodeAnalysis.Diagnostic.Create(DiagnosticsHelper.FileNotFoundError, location, filePath));
        return;
    }

    System.Text.StringBuilder builder = new();
    Utility.ProcessFile(builder, method, filePath, token);
    var source = builder.ToString();
    var hintName = Utility.CalcHintName(builder, method, ".file.g.cs");
    context.AddSource(hintName, source);
}
その他実装詳細
Utility.cs
using System;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace EmbedResourceCSharp;

internal static class Utility
{
    public static string CalcHintName(StringBuilder builder, IMethodSymbol method, string suffix)
    {
        return builder.Clear()
            .Append(method.ContainingType.Name)
            .Replace('<', '_').Replace('>', '_')
            .Append("____")
            .Append(method.Name)
            .Append(suffix)
            .ToString();
    }
    
    public static void ProcessFile(StringBuilder buffer, IMethodSymbol method, string filePath, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        Header(buffer, method);
        buffer.Append("()").AppendLine();
        buffer.Append("        {").AppendLine();
        buffer.Append("            return ");
        EmbedArray(buffer, File.ReadAllBytes(filePath), cancellationToken);
        buffer.Append("        }");
        Footer(buffer);
    }
    
    private static void Header(StringBuilder buffer, IMethodSymbol method)
    {
        buffer.Append("namespace ");
        buffer.Append(method.ContainingNamespace.ToDisplayString()).AppendLine();
        buffer.Append('{').AppendLine();
        buffer.Append("    ");
        var type = method.ContainingType;
        PrintAccessibility(buffer, type.DeclaredAccessibility);
        if (type.IsStatic)
        {
            buffer.Append(" static");
        }

        if (type.IsRecord)
        {
            buffer.Append(type.IsReferenceType ? " partial record " : " partial record struct ");
        }
        else
        {
            buffer.Append(type.IsReferenceType ? " partial class " : " partial struct ");
        }

        buffer.Append(type.Name).AppendLine();
        buffer.Append("    {").AppendLine();
        buffer.Append("        ");
        PrintAccessibility(buffer, method.DeclaredAccessibility);
        buffer.Append(" static partial global::System.ReadOnlySpan<byte> ");
        buffer.Append(method.Name);
    }
    
    private static void PrintAccessibility(StringBuilder buffer, Accessibility accessibility)
    {
        switch (accessibility)
        {
            case Accessibility.NotApplicable:
                break;
            case Accessibility.Private:
                buffer.Append("private");
                break;
            case Accessibility.ProtectedAndInternal:
                buffer.Append("private protected");
                break;
            case Accessibility.Protected:
                buffer.Append("protected");
                break;
            case Accessibility.Internal:
                buffer.Append("internal");
                break;
            case Accessibility.ProtectedOrInternal:
                buffer.Append("protected internal");
                break;
            case Accessibility.Public:
                buffer.Append("public");
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }
    
    private static void Footer(StringBuilder buffer)
    {
        buffer.AppendLine().Append("    }").AppendLine().Append('}').AppendLine().AppendLine();
    }
    
    private static void EmbedArray(StringBuilder buffer, byte[] content, System.Threading.CancellationToken cancellationToken)
    {
        if (content.Length == 0)
        {
            buffer.Append("default;");
        }
        else
        {
            buffer.Append("new byte[] { ");
            buffer.Append(content[0]);
            for (var i = 1; i < content.Length; i++)
            {
                cancellationToken.ThrowIfCancellationRequested();
                buffer.Append(", ");
                buffer.Append(content[i]);
            }

            buffer.Append(" };");
        }

        buffer.AppendLine();
    }
}
DiagnosticsHelper.cs
using Microsoft.CodeAnalysis;

namespace EmbedResourceCSharp;

internal sealed class DiagnosticsHelper
{
    internal static readonly DiagnosticDescriptor FileNotFoundError = new(
        id: "EMBED001",
        title: "File Not Found",
        messageFormat: "File '{0}' is not found",
        category: "ResourceEmbedCSharp",
        DiagnosticSeverity.Error,
        true);
}

IncrementalGeneratorInitializationContextにソースコードを出力するにはRegisterSourceOutputを使用します。

GenerateFileEmbed

((IMethodSymbol Method, string Path) Left, Options Options) pairパラメータのValueTupleの面倒くささよ……

public static IncrementalValuesProvider<ValueTuple<T0, T1, T2>> Zip(this IncrementalValuesProvider<ValueTuple<T0, T1> left, IncrementalValueProvider<T2> right)
=> left.Combine(right).Select(static (x, _) => (x.Item1.Item1, x.Item1.Item2, x.Item2));

大規模なISGを作るなら上記のようなヘルパーメソッドを定義するべきかもしれません。

string filePath = Path.Combine(pair.Options.ProjectDirectory, pair.Left.Path);
if (!File.Exists(filePath))
{
    Location location = Location.None;
    if (method.AssociatedSymbol is { Locations: { Length: > 0 } locations })
    {
        location = locations[0];
    }

    context.ReportDiagnostic(Diagnostic.Create(DiagnosticsHelper.FileNotFoundError, location, filePath));
    return;
}

リソースファイルの正しいパスを得るのはただ単にSystem.IO.Path.Combineメソッドしているだけです。
リソースファイルが存在しない場合はソースコードを出力せず、context.ReportDiagnosticでエラーを通知しています。
エラー文を通知するにあたりどこが悪いのか知るにはISymbol? AssociatedSymbolLocationsプロパティから適当に0番目を選ぶと良いのではないでしょうか。

System.Text.StringBuilder builder = new();
Utility.ProcessFile(builder, method, filePath, token);
var source = builder.ToString();
var hintName = Utility.CalcHintName(builder, method, ".file.g.cs");
context.AddSource(hintName, source);

リソースファイルが存在するならばこれまで得られた情報からソースコードを生成しましょう。
Runtime T4 Templateでも良いですし、ZStringでも良いでしょう。
Nugetパッケージを生成する際に外部ライブラリ依存を適切に処理するのに手作業が必要なので私はあまり使いたくありません。
.NET 6でstring interpolationの性能が上がったこともあって早くnet6.0使えるようになってほしいものです。

AddSourceメソッドの第1引数に擬似的なC#ファイル名を与えますが、同一ISG内では重複禁止だそうです。
ファイル名として不適切な文字'/'とか'<'とか'>'を含む場合例外を吐いて即死しますので気をつけてください。

おめでとうございます!
一番最初のISGが出来ました!

結果

すくしょがぞう

テストコードのcsproj

FileTests.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>9</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
    <PackageReference Include="xunit" Version="2.4.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="3.1.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <Import Project="..\..\src\EmbedResourceCSharp\build\EmbedResourceCSharp.props" />

  <ItemGroup>
    <ProjectReference Include="..\..\src\EmbedResourceCSharp\EmbedResourceCSharp.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>
EmbedResourceCSharp.props
<?xml version="1.0" encoding="utf-8" ?>
<Project>
  <ItemGroup>
    <CompilerVisibleProperty Include="ProjectDir" />
    <CompilerVisibleProperty Include="DesignTimeBuild" />
  </ItemGroup>
</Project>

.props/.targetsファイルでCompilerVisiblePropertyの指定をするのを忘れないようにしましょう。
そしてそれを使用側のcsprojでImportするのを忘れないようにしましょう。

性能改善 - キャッシュ最適化

「チョット待って!キャッシュされてないやん!!」という内なる声が聞こえてきませんか?
私は先のコードを実装した翌日に聞こえてきました。

Initialize(IncrementalGeneratorInitializationContext context)を見直しましょう。

Generated.cs
context.RegisterPostInitializationOutput(GenerateInitialCode);

var options = context.AnalyzerConfigOptionsProvider
    .Select(Utility.SelectOptions);
var file = context.CompilationProvider
    .Select(static (compilation, token) =>
    {
        token.ThrowIfCancellationRequested();
        return compilation.GetTypeByMetadataName("EmbedResourceCSharp.FileEmbedAttribute") ?? throw new NullReferenceException("FileEmbedAttribute not found");
    });
var files = context.SyntaxProvider
    .CreateSyntaxProvider(Predicate, Transform)
    .Combine(file)
    .Select(PostTransformFile)
    .Where(x => x.Method is not null && x.Path is not null)!;

context.RegisterSourceOutput(files.Combine(options), GenerateFileEmbed);

こう…… フィルタリングはしていますが、同一の中身を持つオブジェクトがSelectされた場合に後続で同一の再計算が行われてしまいますよね……。
変更がなければ後続で再計算が起きないようにする目的で使用されるのがIncrementalValueProviderExtensions.WithComparer拡張メソッドです。
System.Collections.Generic.IEqualityComparer<T>を引数に渡せばもう安心ですね!

Generated.cs WithComparer
context.RegisterPostInitializationOutput(GenerateInitialCode);

var options = context.AnalyzerConfigOptionsProvider
    .Select(Utility.SelectOptions)
    .WithComparer(Options.Comparer.Instance);
var file = context.CompilationProvider
    .Select(static (compilation, token) =>
    {
        token.ThrowIfCancellationRequested();
        return compilation.GetTypeByMetadataName("EmbedResourceCSharp.FileEmbedAttribute") ?? throw new NullReferenceException("FileEmbedAttribute not found");
    })
    .WithComparer(SymbolEqualityComparer.Default);
var files = context.SyntaxProvider
    .CreateSyntaxProvider(Predicate, Transform)
    .Combine(file)
    .Select(PostTransformFile)
    .Where(x => x.Method is not null && x.Path is not null)!
    .WithComparer(FileAttributeComparer.Instance);

context.RegisterSourceOutput(files.Combine(options), GenerateFileEmbed);
Options.cs
internal sealed class Options : IEquatable<Options>
{
    public readonly string ProjectDirectory;
    public bool Equals(Options other)
    {
        return other == this || (other.ProjectDirectory == ProjectDirectory);
    }
    public override int GetHashCode() => ...;
    public sealed class Comparer : IEqualityComparer<Options>
    {
        public static readonly Comparer Instance = new();
        public bool Equals(Options x, Options y) => x.Equals(y);
        public int GetHashCode(Options obj) => obj.GetHashCode();
    }
}
Comparer.cs
internal sealed class FileAttributeComparer : IEqualityComparer<ValueTuple<IMethodSymbol, string>>
{
    public static readonly FileAttributeComparer Instance = new();

    public bool Equals((IMethodSymbol, string) x, (IMethodSymbol, string) y) => x.Item2.Equals(y.Item2) && SymbolEqualityComparer.Default.Equals(x.Item1, y.Item1);

    public int GetHashCode((IMethodSymbol, string) obj) => SymbolEqualityComparer.Default.GetHashCode(obj.Item1);
}

このことからSelect結果は等値性比較が出来るものに限るべきとわかります。

エディタ体験改善 - DesignTimeBuild

ここまで作ってきたはいいのですが、リソースファイルが例えば100MB × 1024とかそういうレベルになるとエディタ上ではファイルIOしないでもらいたくなります。

エディタ上で編集中なのか、それともビルド時なのか知りたい、という願いが生じるのは当然のことでした。

IncrementalGeneratorInitializationContext.RegisterImplementationSourceOutputメソッドはその願いを片手落ちに叶えてくれる存在です。
RegisterSourceOutputとの違いがMSDocsに何も書かれていないのですが、RegisterImplementationSourceOutputはビルド時にのみ呼ばれ、エディタ上での編集時にはスキップされるという想定で追加されたAPIです。

partialメソッドに対するISGだとエディタ上では何も出力してくれないのでエラーを吐き出すだけになります。

対になるAPIは提案されていますが2021年12月現在実装されていません。

DesignTimeBuild MSBuildプロパティ

MSBuildの提供するプロパティにDesignTimeBuildというものが実はあります。
通常ビルド時には未定義だったり空文字列だったりするそうです。
エディタ上では'true'という文字列が設定されることもあるそうです。

完璧にエディタ上でのISGを軽くしたいという用途にフィットしていますね。

Options.cs
using System;
using System.Collections.Generic;

namespace EmbedResourceCSharp;

internal sealed class Options : IEquatable<Options>
{
    public readonly bool IsDesignTimeBuild;
    public readonly string ProjectDirectory;

    public Options(bool isDesignTimeBuild, string projectDirectory)
    {
        IsDesignTimeBuild = isDesignTimeBuild;
        ProjectDirectory = projectDirectory;
    }

    public bool Equals(Options other)
    {
        return other == this || (other.IsDesignTimeBuild == IsDesignTimeBuild && other.ProjectDirectory == ProjectDirectory);
    }

    public override int GetHashCode()
    {
        var hashCode = ProjectDirectory.GetHashCode();
        return IsDesignTimeBuild ? hashCode : -hashCode;
    }

    public sealed class Comparer : IEqualityComparer<Options>
    {
        public static readonly Comparer Instance = new();

        public bool Equals(Options x, Options y) => x.Equals(y);

        public int GetHashCode(Options obj) => obj.GetHashCode();
    }
}
public static Options SelectOptions(AnalyzerConfigOptionsProvider provider, CancellationToken token)
{
    token.ThrowIfCancellationRequested();
    var isDesignTimeBuild = provider.GlobalOptions.TryGetValue("build_property.DesignTimeBuild", out var designTimeBuild) && designTimeBuild == "true";
    if (!provider.GlobalOptions.TryGetValue("build_property.ProjectDir", out var projectDirectory) || projectDirectory is null)
    {
        isDesignTimeBuild = true;
        projectDirectory = "";
    }

    return new(isDesignTimeBuild, projectDirectory);
}

Optionsbool IsDesignTimeBuildを追加しましょう。
そしてコード生成部分GenerateFileEmbedも書き換えましょう。

GenerateFileEmbed
StringBuilder builder;

var token = context.CancellationToken;
token.ThrowIfCancellationRequested();
var method = pair.Left.Method;
var path = pair.Left.Path;

var filePath = Path.Combine(pair.Options.ProjectDirectory, path);
if (!File.Exists(filePath))
{
    var location = Location.None;
    if (method.AssociatedSymbol is { Locations: { Length: > 0 } locations })
    {
        location = locations[0];
    }

    context.ReportDiagnostic(Diagnostic.Create(DiagnosticsHelper.FileNotFoundError, location, filePath));
    return;
}

builder = new StringBuilder();
if (pair.Options.IsDesignTimeBuild)
{
    // ファイルIOなし
    Utility.ProcessFileDesignTimeBuild(builder, method);
}
else
{
    Utility.ProcessFile(builder, method, filePath, token);
}

var source = builder.ToString();
var hintName = Utility.CalcHintName(builder, method, ".file.g.cs");
context.AddSource(hintName, source);

でざいんたいむ

Nugetパッケージ化

よほどプロジェクト固有なのでなければISG書いたら公開したくなるのは当然のことですね。
Nugetパッケージとして公開するにあたってISG特有のハックが存在します。
この章ではそれを紹介していきます。

IncrementalではないSource Generatorを作る

EmbedResourceCSharp.Roslyn3.csprojと名付けたC#のプロジェクトを作ってください。
何故かですって? Visual Studio 2019などRoslyn4以前の環境でも動かせるようにするためです。

こうぞう

EmbedResourceCSharp.Roslyn3.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <Compile Include="../EmbedResourceCSharp/*.cs" Exclude="../EmbedResourceCSharp/Generator.cs" />
  </ItemGroup>
</Project>

Roslyn3.csprojではRoslyn3系のサポートを行います。
殆どのソースコードを流用しますのでItemGroup/CompileEmbedResourceCSharp/Generator.cs以外のcsファイルを全てIncludeします。

IncrementalでないSource Generator向けのソースコードはGitHubに置いてあります。

csproj編集

EmbedResourceCSharp.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <DevelopmentDependency>true</DevelopmentDependency>
    <IsPackable>true</IsPackable>
    <PackageId>EmbedResourceCSharp</PackageId>
    <Title>EmbedResourceCSharp C# Source Generator</Title>
    <Description>SourceGenerator for resource file embedding with EmbedResourceCSharp.</Description>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <PackageTags>SourceGenerator</PackageTags>
    <PackageReadmeFile>README.md</PackageReadmeFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../EmbedResourceCSharp.Roslyn3/EmbedResourceCSharp.Roslyn3.csproj" ReferenceOutputAssembly="false" />
  </ItemGroup>

  <ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn4.0" Visible="false" />
    <None Include="..\..\README.md" Pack="true" PackagePath="\" Visible="false" />

    <Content Include="build\EmbedResourceCSharp.props" Pack="true" PackagePath="build" />
    <Content Include="build\EmbedResourceCSharp.targets" Pack="true" PackagePath="build" />
  </ItemGroup>

  <Target Name="ReferenceCrossTargeting" BeforeTargets="_GetPackageFiles">
    <MSBuild Projects="../EmbedResourceCSharp.Roslyn3/EmbedResourceCSharp.Roslyn3.csproj" Targets="GetTargetPath">
      <Output ItemName="Roslyn3Assembly" TaskParameter="TargetOutputs" />
    </MSBuild>

    <ItemGroup>
      <None Include="%(Roslyn3Assembly.Identity)" Pack="true" PackagePath="analyzers/dotnet/roslyn3.11" Visible="false" />
    </ItemGroup>
  </Target>
</Project>

【C#】アナライザー・ソースジェネレーター開発のポイントDevelopmentDependencyIncludeBuildOutputの解説が記されていますので、ここでは説明しません。

<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/roslyn4.0" Visible="false" />

Roslyn4の時代から同一のNugetパッケージにRoslyn3向けとRoslyn4向けのdllを一緒に置けるようになりました。
故にPackagePathanalyzers/dotnet/roslyn4.0/csになったのですね。

<Target Name="ReferenceCrossTargeting" BeforeTargets="_GetPackageFiles">
  <MSBuild Projects="../EmbedResourceCSharp.Roslyn3/EmbedResourceCSharp.Roslyn3.csproj" Targets="GetTargetPath">
    <Output ItemName="Roslyn3Assembly" TaskParameter="TargetOutputs" />
  </MSBuild>

  <ItemGroup>
    <None Include="%(Roslyn3Assembly.Identity)" Pack="true" PackagePath="analyzers/dotnet/roslyn3.11" Visible="false" />
  </ItemGroup>
</Target>

Roslyn3向けのSGはNugetパッケージをパックする前にTargetを差し込みます。
MSBuildでEmbedResourceCSharp.Roslyn3.csprojをビルドし、それをanalyzers/dotnet/roslyn3.11フォルダに配置すればRoslyn3環境でもRoslyn4環境でも同じ様に動作するNugetパッケージのdll部分が出来ます。

.targets

しかし、これだけでは.NET 5環境乃至Visual Studio 2019では動きません。
roslyn4.0とかroslyn3.11とかのバージョン分けの規約を古いエディタは理解しないからです。

理解しているエディタやMSBuildはSupportsRoslynComponentVersioningプロパティに'true'を設定しています。
Roslyn4環境なら必ず'true'になりますので、非'true'ならAnalyzer集合からEmbedResourceCSharp.dllを除外します。

EmbedResourceCSharp.targets
<Project>
  <Target Name="_EmbedResourceCSharpMultiTargetRoslyn3" Condition="'$(SupportsRoslynComponentVersioning)' != 'true'" BeforeTargets="CoreCompile">
    <ItemGroup>
      <Analyzer Remove="@(Analyzer)" Condition="$([System.String]::Copy('%(Analyzer.Identity)').IndexOf('EmbedResourceCSharp.dll')) &gt;= 0"/>
    </ItemGroup>
  </Target>
  <Target Name="_EmbedResourceCSharpMultiTargetRoslyn4" Condition="'$(SupportsRoslynComponentVersioning)' == 'true'" BeforeTargets="CoreCompile">
    <ItemGroup>
      <Analyzer Remove="@(Analyzer)" Condition="$([System.String]::Copy('%(Analyzer.Identity)').IndexOf('EmbedResourceCSharp.Roslyn3.dll')) &gt;= 0"/>
    </ItemGroup>
  </Target>
</Project>

このEmbedResourceCSharp.targetsは.propsに追加しても別に問題はないです。
これも忘れずにNugetパッケージに梱包しましょう。

終わりに

もなふわすい~とる~むはイイゾ!!!

Source Generatorでボイラープレートコードをゴリゴリに削っていきましょう!
良いお年を!

参考文献

Discussion

ログインするとコメントできます