🙄

C#でAnalyzerを自作する

に公開

はじめに

コンパイルは通っても書くべきではないコードは世の中にはたくさんあります。基本的にはレビュー指摘で抑えることになるのですが、機械的なところにレビューの時間を割きたくないです。こういう時にコンパイラを拡張して早期にビルドエラーになるようにしておくと便利です。

自作する前に以下の記事を参考に出来合いのものが使えないかを見ておくのがよいと思います。.editorconfigやcsprojの設定だけでもそれなりの質の解析が行えます。
https://zenn.dev/inuinu/articles/c60abf13d5f47a

また、ビルド手順がややこしくなるので、本記事ではNuGet配布については一切触れません。メインで開発しているリポジトリに直接組み込んで、そのリポジトリ専用の解析をかける目的で開発します。NuGet配布をしたい場合は以下の記事を参考にするとよいと思います。

https://neue.cc/2021/05/08_600.html
https://zenn.dev/naminodarie/articles/32973a36fcbe99

本記事でトータルでやることとしては以下のようになります。

  1. コンパイラの処理に割り込むための専用クラスを作成する
  2. 作成した専用クラスを参照してビルドする(ここで所望の警告を出す)
  3. 正しく見つけられているかどうかをテストで確認する

事前準備

以下の記事を参考に、VisualStudioと拡張機能開発のワークロードを入れておいてください。
https://www.meziantou.net/writing-a-roslyn-analyzer.htm

本記事ではDirectory.Packages.propsは以下のようにしています。

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="coverlet.collector" Version="6.0.4" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
    <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
    <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.7.0" />
    <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0" />
    <PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
    <PackageVersion Include="NUnit" Version="4.4.0" />
    <PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
    <PackageVersion Include="NUnit3TestAdapter" Version="6.0.1" />
  </ItemGroup>
</Project>

インストールが終わったらAnalyzerのプロジェクトを作成します。クラスライブラリのテンプレートで作ってから以下のように書き換えてください。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!--Visual Studioの都合でnetstandardにせざるを得ない-->
    <TargetFramework>netstandard2.0</TargetFramework>
    <!--Roslyn専用Analyzer(Fileクラスを使うなとか)の有効化-->
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

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

Analyzerを実装する

やることとしてはDiagnosticAnalyzerを継承したクラスを作成するだけです。「Banned」という名前を含むクラスを見つけると警告するAnalyzerを例にとって説明します。全体としては以下のようになります。

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace BannedAnalyzer
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CustomAnalyzer : DiagnosticAnalyzer
    {
        //1. ビルド警告に出すためのメッセージなどを定義する。実際の定義は後述
        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(CustomAnalyzerRules.BannedRule);

        public override void Initialize(AnalysisContext context)
        {
            //並列実行の有効化。Roslyn向けAnalyzerで挿入する旨の指摘が出る
            context.EnableConcurrentExecution();
            //自動生成コード(g.csなど)への解析設定。警告出されても困るので無効化。
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

            //2. 解析の本体部分
            context.RegisterSyntaxNodeAction(AnalyzeContext, SyntaxKind.ClassDeclaration);
        }
        
        private void AnalyzeContext(SyntaxNodeAnalysisContext context)
        {
            var syntax = (ClassDeclarationSyntax)context.Node;
            if (syntax.Identifier.Text.Contains("Banned"))
            {
                var diagnostic = Diagnostic.Create(CustomAnalyzerRules.BannedRule, syntax.Identifier.GetLocation(), syntax.Identifier.Text);
                //特定の条件を満たしたときにReportすれば警告が出せる
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}

ビルド警告に出すためのメッセージなどを定義する

DiagnosticDescriptorを返せばよいです。あんまり適当に作るとCI/CDのメッセージが意味不明になったり後々他の人が追加したくなった時に困るので、ある程度基準を決めてカテゴリごとにまとめた方がよいと思います。ここでは、テストの期待結果を作るときに必要になるので別クラスに抜き出してpublic公開しておきます。

using Microsoft.CodeAnalysis;

namespace BannedAnalyzer
{
    public static class CustomAnalyzerRules
    {

        public static DiagnosticDescriptor BannedRule =>
            new DiagnosticDescriptor(
                            id: "MY0001",
                            title: "Do not use banned class",
                            messageFormat: "Rename class {0}.",
                            category: "Usage",
                            defaultSeverity: DiagnosticSeverity.Warning,
                            isEnabledByDefault: true,
                            description: "Do not use banned class description"
                        );
    }
}

いろいろコンストラクタに渡しましたが実際にユーザー側に表示されるのはidとmessageFormatです。あと、経験上defaultSeverityをwarningにするとたいていの人が放置してくるので実際はErrorにするか何がしか増えないための対策を打った方がいいです。

解析の本体部分

RegisterXXXActionに、実際の解析を行うデリゲートと解析対象を渡します。LINQPadのTreeタブか、Visual StudioのSyntax Visualizerを見るのがおすすめです。ちょっとしたサンプルを書きたいだけなのと、後者はたまに更新に追随しなくなって困るので、私は前者をよく使います。
https://www.linqpad.net
https://learn.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/syntax-visualizer?tabs=csharp

今回でいうとクラスの名前に「Banned」が含まれるかどうかを判定すればよいです。クラスを作ってからTreeタブを見るとClassDeclarationSyntaxのIdentifierプロパティを見ればよいことがわかります。

これを踏まえて(試行錯誤を挟みつつ)書いたものが提示したサンプルコードです。SyntaxNodeで解析を書きましたが、Symbol(意味解析が終わった後)を解析して書くこともできて、その場合は以下のようになります。どちらかというとこちらの方がとっつきやすいですね。基本的にSymbolの方が取れる情報が多いのですが、アセンブリ参照とかをちゃんと作ってあげるのがかなり手間だったりするので、一長一短です。

//中略
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);

private void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var symbol = context.Symbol;
    if (symbol == null) { return; }

    if (symbol.Name.Contains("Banned"))
    {
        var diagnostic = Diagnostic.Create(CustomAnalyzerRules.BannedRule, symbol.Locations[0], symbol.Name);
        context.ReportDiagnostic(diagnostic);
    }
}

解析を組み込む

作成したプロジェクトをちょっと特殊な形で参照します。コンソールアプリを作成して、csprojに以下のようにProjectReferenceを追加します。毎回書くのはめんどくさいので、Directory.Build.propsにProjectReferenceの記述を持っていくのがよいです。

<Project Sdk="Microsoft.NET.Sdk">
  <!--なんでもいい-->
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <!--ここはDirectory.Build.propsに定義を書いた方がいい-->
  <ItemGroup>
    <ProjectReference Include="..\BannedAnalyzer\BannedAnalyzer.csproj">
      <!--必須。ないと動かないうえに出力ディレクトリにコピーがばらまかれてえらいことになる-->
      <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
      <OutputItemType>Analyzer</OutputItemType>
    </ProjectReference>
  </ItemGroup>
</Project>

ここまでうまくいっていれば、Bannedを名前に含むクラスを作るとビルドログに以下のような警告が出ます。
BannedSymbol.cs(3,18,3,30): warning MY0001: Rename class BannedSymbol.
エディタ上では、対象となるクラスに以下のように警告が表示されます。

テストを書く

一発で正しく動くことは稀ですし、誤検出や検出漏れもよくあります。デバッグ実行をするためにも、テストを書いておいた方がいいです。いいのですが、ドキュメントが古くなっていたり、参照関係で警告やエラーを吐きまくったり、検出位置やメッセージ文言の一語一句まで検証するせいでめちゃくちゃ合格判定が厳しかったりで非常に手間です。

ここではNUnitでテストを書きます[1]。XUnitでも同様のはずです。csproj[2]は以下のようになります。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <LangVersion>14.0</LangVersion>
    <!--CodeAnalysis.CSharp.Analyzer.Testingの都合でnet10.0とかにすると警告が出る-->
    <TargetFramework>net481</TargetFramework>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="coverlet.collector" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <!--これが本体。他はフレームワークによって変わる-->
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
    <PackageReference Include="NUnit" />
    <PackageReference Include="NUnit.Analyzers">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="NUnit3TestAdapter" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\BannedAnalyzer\BannedAnalyzer.csproj" />
  </ItemGroup>

</Project>

実際のテストを書くにあたって必要なものは以下です。

  1. 解析対象のソースコード(string)
  2. 検出結果のDiagnosticDescriptor(Analyzerのプロジェクトでpublic公開して参照)

サンプルコードを見てもらった方が早いと思うので以下に書いておきます。ソースコードに含まれる{|#0:BannedClass|}は特殊な記法(本来はBannedClassのみ)で、これで番号を振っておいて.WithLocation(0)のように番号で参照します。

using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using NUnit.Framework;
//コメントでどこかに入れておくと、以降生文字リテラルがハイライトされて見やすい
/* lang=C#-test */

//デフォルトで用意されている静的メソッドを使いたいが、あまりに長いのでusing定義
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<BannedAnalyzer.CustomAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

namespace BannedAnalyzer.Tests
{
    public class DisposableAnalyzerTests
    {
        [Test]
        public async Task FindBannedDefault()
        {
            //静的メソッドで検出。こちらが簡単
            await Verifier.VerifyAnalyzerAsync(TestCode(),
                new DiagnosticResult(CustomAnalyzerRules.BannedRule)
                .WithLocation(0)
                .WithArguments("BannedClass"));
        }

        [Test]
        public async Task FindBannedAnalyzerTest()
        {
            //いろいろカスタマイズしたい場合はCSharpAnalyzerTestのインスタンスを作る
            var context = new CSharpAnalyzerTest<CustomAnalyzer, DefaultVerifier>();
            context.TestCode = TestCode();
            context.ExpectedDiagnostics.Add(new DiagnosticResult(CustomAnalyzerRules.BannedRule)
                .WithLocation(0)
                .WithArguments("BannedClass"));
            await context.RunAsync();
        }

        //LangVersionは生文字リテラルが使える11.0以降がよい
        private static string TestCode() => """
            using System;
            namespace DisposableAnalyzer.Tests
            {
                public class {|#0:BannedClass|}
                {
                    public void Hello()
                    {
                        Console.WriteLine("Hello World!");
                    }
                }
            }
            """;
    }
}

デフォルトを使えないケース

いろいろカスタムしたい場合の例として、C#12.0のコードを認識できずにエラーになるときの対応方法を書きます。自分で継承クラスを用意するのが一番確実です。

public class CustomTest<TAnalyzer, TVerifier> : CSharpAnalyzerTest<TAnalyzer, TVerifier>
    where TAnalyzer : DiagnosticAnalyzer, new()
    where TVerifier : IVerifier, new()
{
    protected override ParseOptions CreateParseOptions()
    {
        //デフォルト実装はここがC#6.0なのでPreviewで上書き(C#11.0までしかないのでPreview)
        return new CSharpParseOptions(LanguageVersion.Preview, DocumentationMode.Diagnose);
    }
}

これを使って、以下のような形でテストを書けます。

 [Test]
 public async Task FindCs12Code_Inherit()
 {
     var context = new CustomTest<CustomAnalyzer, DefaultVerifier>();
     context.TestCode = Cs12TestCode();
     context.ExpectedDiagnostics.Add(new DiagnosticResult(CustomAnalyzerRules.BannedRule)
         .WithLocation(0)
         .WithArguments("BannedClass2"));
     await context.RunAsync();
 }

 //デフォルトのままだとコレクション式が認識できずコンパイルエラー扱いになる
 private static string Cs12TestCode() => """
     using System.Collections.Generic;

     namespace DisposableAnalyzer.Tests
     {
         public class {|#0:BannedClass2|}
         {
             private List<string> strings = ["Hello"];
         }
     }
     """;

以下のようなコードを書いても対応できますが、内部実装を見ていかないと意味が分からないですし、継承の方がよいと思います。

context.SolutionTransforms.Add((solution, projectId) =>
{
    var project = solution.GetProject(projectId)!.WithParseOptions(new CSharpParseOptions(LanguageVersion.Preview));
    return project.Solution;
});

まとめ

C#のAnalyzerについての記事はすでにいくつかありますが、直接リポジトリに組み込む目的で解説しているものが少なかったので書いてみました。

品質はそこまで求められないし、身もふたもないことを言うとAIに投げたらそこそこいい感じの実装を作ってくれるので2020年前後に比べるとかなり手を付けやすくなっていると思います。いちいちレビュー指摘をする必要がないのでとても楽ですし、もっと流行って書ける人が増えるといいですね。

参考

雰囲気つかむだけなら公式見ながら手を動かすのが一番良いと思います。
https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix

参考にしたAnalyzerのリポジトリ

dotnet roslynはちょっと読むのがしんどかったです。
https://github.com/meziantou/Meziantou.Analyzer
https://github.com/thomhurst/TUnit/tree/main/TUnit.Analyzers.Tests

参考にした記事

Meziantou氏のブログが(Analyzerに限らずですが)特におすすめです。
https://www.meziantou.net/roslyn-analyzers-how-to.htm
https://zenn.dev/inuinu/scraps/98b5c84f1485b0

脚注
  1. TUnitで書こうと思ったのですが、SourceGeneratorがエラーを吐いて変なワークアラウンドを入れないと動作しないので止めました。 ↩︎

  2. net481が気に食わない場合はワークアラウンドがある様子なので、例えばTUnitのリポジトリをご確認ください。 ↩︎

Discussion