【C#】アナライザー・ソースジェネレーター開発のポイント
アナライザー・ソースジェネレーターをいくつか開発したので知見をまとめておきます。
C#以外の.NETの言語でも同様かと思いますが、C#での開発なのでC#記事とします。
見出しに[A/S]とあるのはアナライザー・ソースジェネレーター共通、[A]はアナライザーのみ、[S]はソースジェネレーターのみを対象とした項目を表します。
公式リポジトリのcookbookを見る[S]
ソースジェネレーターだけですが、cookbookがあるので非常に役立ちます。
おまじないをcsprojに書く[A/S]
アナライザー・ソースジェネレーターではcsprojにいろいろなおまじないが必要です。
NuGet パッケージを作る
アナライザー・ソースジェネレーターでは普段と異なる手順でNuGet パッケージを作ります。
そのため、いろいろとおまじないがあります。
<PropertyGroup>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <IncludeSymbols>false</IncludeSymbols>
 <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" />
</ItemGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
    <TargetPathWithTargetPlatformMoniker Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
<ItemGroup>
    <TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
    <TfmSpecificPackageFile Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" PackagePath="analyzers\dotnet\cs" />
    <TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
</ItemGroup>
</Target>
| 設定 | 説明 | 
|---|---|
| IncludeBuildOutput=false | パッケージにDLLを自動追加しない | 
| IncludeSymbols=false | シンボル情報は不要 | 
| TargetsForTfmSpecificContentInPackage | パッケージの構成設定 | 
| SuppressDependenciesWhenPacking=true | パッケージの依存関係を含めない | 
| DevelopmentDependency=true | パッケージをアナライザーとして扱う | 
DevelopmentDependencyをtrueに設定すると、NuGet上でアナライザーとして扱われるので、ユーザーがインストールする際にも自動でアナライザー用の設定でインストールされます。
<PackageReference Include="SourceExpander.Embedder" Version="3.0.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
DevelopmentDependencyがtrueなパッケージをユーザーがインストールすると、上記のようにPrivateAssets, IncludeAssetsが自動で設定されます。
これでユーザー側のライブラリにアナライザーの依存が漏れなくなります。
Newtonsoft.Jsonを使用する
アナライザー・ソースジェネレーターで使用するライブラリはNuGetでの依存関係を解決しません。
そのため、DLLを埋め込む必要があります。
| 設定 | 説明 | 
|---|---|
| GetTargetPathDependsOn | 依存ライブラリの解決 | 
GeneratePathPropertyをtrueにすると、Newtonsoft.Jsonならば$(PkgNewtonsoft_Json)というような変数名でライブラリがダウンロードされるパスが取得できるので、これもnupkgに埋め込みます。
Newtonsoft.JsonはMITライセンスなのできちんと表記もしておきましょう。
<PropertyGroup>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <IncludeSymbols>false</IncludeSymbols>
  <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
  <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="12.0.3" GeneratePathProperty="true" />
  <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" />
</ItemGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
    <TargetPathWithTargetPlatformMoniker Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
<ItemGroup>
    <TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
    <TfmSpecificPackageFile Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" PackagePath="analyzers\dotnet\cs" />
    <TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
</ItemGroup>
</Target>
こまめにキャンセルをチェックする[A/S]
SyntaxNodeAnalysisContextやGeneratorExecutionContextなどにはSystem.Threading.CancellationTokenが渡されています。
こまめにcontext.CancellationToken.ThrowIfCancellationRequested()を実行してキャンセルのチェックをしましょう。
Visual Studioではバックグラウンドでアナライザー・ソースジェネレーターが実行されるので頻繁にキャンセルされます。しかし、キャンセルチェックをしていないとアナライザー・ソースジェネレーターの実行が完了するまで待つことになり、フリーズしたりします。
Diagnosticの出力[A/S]
これについては好みが分かれるかと思いますが、DiagnosticDescriptor から Diagnostic を作るのはDiagnosticDescriptorごとにメソッド化してしまって良いと思います。
メッセージに渡す引数の数は固定にしたいはずなので、Diagnostic.Createで作る箇所は隠蔽してしまいましょう。
アナライザーだとDiagnosticDescriptorを渡す必要がある都合上、privateにはできませんが、それ以外では使わないほうが良いかと思います。
public static Diagnostic EMBED0001_UnknownError(string message)
    => Diagnostic.Create(EMBED0001_UnknownError_Descriptor, Location.None, message);
private static readonly DiagnosticDescriptor EMBED0001_UnknownError_Descriptor = new(
    "EMBED0001",
    new LocalizableResourceString(
        nameof(DiagnosticsResources.EMBED0001_Title),
        DiagnosticsResources.ResourceManager,
        typeof(DiagnosticsResources)),
    new LocalizableResourceString(
        nameof(DiagnosticsResources.EMBED0001_Body),
        DiagnosticsResources.ResourceManager,
        typeof(DiagnosticsResources)),
    "Error",
    DiagnosticSeverity.Warning,
    true);
マルチスレッド実行[A/S]
compilation.Options.ConcurrentBuild でコンパイルがマルチスレッド環境で実行されているかを取得できます。
マルチスレッドなときは並列実行も活用して高速に動作させたいですね。
SyntaxTree[] newTrees;
if (compilation.Options.ConcurrentBuild)
    newTrees = compilation.SyntaxTrees.AsParallel().WithCancellation(cancellationToken)
        .Select(Rewrited).ToArray();
else
    newTrees = compilation.SyntaxTrees
        .Select(Rewrited).ToArray();
アナライザーの場合は AnalysisContextでcontext.EnableConcurrentExecution()を実行しておきましょう。



Discussion