2021年のC# Roslyn Analyzerの開発手法、或いはUnityでの利用法

C#のAnalyzer、.NET 5時代の現在では標準でも幾つか入ってきたり、dotnet/roslyn-analyzersとして準標準なものも整備されてきたり(非同期関連だと他にmicrosoft/vs-threadingのAnalyzerも便利)、Unity 2020.2からはUnityもAnalyzer対応したり、MicrosoftもUnity向けのmicrosoft/Microsoft.Unity.Analyzersという便利Analyzerが登場してきたりと、特に意識せずとも自然に使い始めている感じになってきました。

Analyzerって何?というと、まぁlintです。lintなのですが、Roslyn(C#で書かれたC# Compiler)から抽象構文木を取り出せるので、それによってユーザーが自由にルールを作って、警告にしたりエラーにしたりできる、というのがミソです。更に高度な機能として、CodeFix(コードを任意に修正)もついているのですが、それはそれとして。

このサイトでも幾つか書いてきましたが、初出の2014年-2015年辺りに固まってますね。もう6年前!

実用的という点では、MessagePack for C#に同梱しているMessagePackAnalyzerは今も現役でしっかり便利に使える代物になっています。

と、いうわけで使う分にはいい感じになってきた、のですが、作る側はそうでもありません。初出の2015年辺りからテンプレートは変わってなくて、NuGetからすんなり入れれる時代になっても、VSIXがついてくるようなヘヴィなテンプレート。このクロスプラットフォームの時代に.NET Frameworkべったり、Visual Studioベッタリって……。Analyzerと似たようなシステムを使うSource Generator(UnitGenerator - C# 9.0 SourceGeneratorによるValueObjectパターンの自動実装とSourceGenerator実装Tips )は、まぁまぁ今風のそこそこ作りやすい環境になってきたのに、Analyzerは取り残されている雰囲気があります。

AnalyzerはCodeFixまで作ると非常に面倒なのですが、Analyzer単体でも非常に有益なんですよね。そしてプロジェクト固有の柔軟なエラー処理というのは、あって然りであり、もっとカジュアルに作れるべきなのです。が、もはや私でも腰が重くなってしまうぐらいに、2021年に作りたくないVisual Studio 2019のAnalyzerテンプレート……。

どうしたものかなー、と思っていたのですが、非常に良い記事を見つけました、2つ!

前者の記事ではVS2019 16.10 preview2で ソースジェネレーターのデバッガーサポートが追加された、 <IsRoslynComponent>true</IsRoslynComponent> とすればいい。という話。なるほどめっちゃ便利そう、でもソースジェネレーターばっか便利になってくのはいいんですがAnalyzer置いてきぼりですかぁ?と思ったんですが、 IsRoslynComponent だし、なんか挙動的にも別にAnalyzerで動いても良さそうな雰囲気を醸し出してる。と、いうわけで試してみたら無事動いた!最高!VS2019 16.10はまだpreviewですが(現時点では16.9が安定版の最新)、これはもうこれだけでpreview入れる価値ありますよ(あと少し待てば普通に正式版になると思うので待っても別にいいですが)

後者の記事は .NET 5 時代のすっきりしたAnalyzerのcsprojの書き方を解説されています。つまり、この2つを合体させればシンプルにAnalyzerを開発できますね……?

というわけでやっていきましょう。中身は本当に上記2つの記事そのものなので、そちらのほうも参照してください。

SuperSimpleAnalyzerをシンプル構成で作る

まずは Visual Studio 2019 16.10 をインストールします。16.10はついこないだ正式版になったばかりなので、バージョンを確認して16.10未満の場合はアップデートしておきましょう。

Analyzerはnetstarndard2.0、Analyzerを参照するテスト用のConsoleAppプロジェクトをnet5.0で作成します。最終的には以下のようなソリューション構造にします。

image

さて、ではSuperSimpleAnalyzerのほうのcsprojをコピペ的に以下のものにしましょう。

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>library</OutputType>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>latest</LangVersion>
        <Nullable>enable</Nullable>
        <IsRoslynComponent>true</IsRoslynComponent>
        <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <IncludeSymbols>false</IncludeSymbols>
        <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.8.0" />
    </ItemGroup>

    <Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
        <ItemGroup>
            <TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
            <TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
        </ItemGroup>
    </Target>
</Project>

基本的に【C#】アナライザー・ソースジェネレーター開発のポイントから丸コピペさせてもらっちゃっているので、それぞれの詳しい説明は参照先記事に譲ります……!幾つか重要な点を出すと、Microsoft.CodeAnalysis.CSharpのバージョンは新しすぎると詰みます。現在の最新は3.9.0ですが、3.9.0だと、今の正式版VS2019(16.9)だと動かない(動かなかったです、私の環境では、どうなんですかね?)ので、ちょっと古めの3.8.0にしておきます。

もう一つは、件の <IsRoslynComponent>true</IsRoslynComponent> の追加です。

では、次にConsoleApp.csprojのほうに行きましょう。

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

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\AnalyzerDemo\SuperSimpleAnalyzer.csproj">
            <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
            <OutputItemType>Analyzer</OutputItemType>
        </ProjectReference>
    </ItemGroup>

</Project>

こちらは別に特段変わったことなく、Analyzerのcsprojを参照するだけです。その際に <OutputItemType>Analyzer</OutputItemType>を欠かさずに。

では再び SuperSimpleAnalyzer に戻って、プロパティ→デバッグから、「起動」をRoslyn Componentに変更すると以下のような形にできます。

image

(この時、Target Projectが真っ白で何も選択できなかったら、ConsoleAppのほうでAnalyzer参照をしてるか確認の後、とりあえずVisual Studioを再起動しましょう)

これで、SuperSimpleAnalyzerをF5するとAnalyzerがConsoleAppで動いてる状態でデバッガがアタッチされます!

のですが、最後にじゃあそのAnalyzerの実体をコピペできるように置いておきます。

#pragma warning disable RS2008

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

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SuperSimpleAnalyzer : DiagnosticAnalyzer
{
    // どうせローカライズなんてしないのでString直書きしてやりましょう
    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        id: "SuperSimpleAnalyzer",
        title: "SuperSimpleAnalyzer",
        messageFormat: "MyMessageFormat",
        category: "Naming",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Nanika suru.");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
    
    public override void Initialize(AnalysisContext context)
    {
        // お約束。
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // 解析起動させたい部分を選ぶ。あとRegisterなんとかかんとかの種類は他にもいっぱいある。
        context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }

    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
        // ここを適当に書き換える(これはサンプル通りの全部Lowerじゃないクラス名があった場合に警告を出す)
        var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

        if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
        {
            // Diagnosticを作ってReportDiagnosticに詰める。
            var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);
            context.ReportDiagnostic(diagnostic);
        }
    }
}

Resourcesとか別に使う必要ないと思うので、ハイパーベタ書きの.csファイル一個に収めてあります。これでF5をすると……

image

もちろんConsoleAppのほうでは、実際に動いて警告出している様が確認できます。

image

昔のVSIXの時は、別のVisual Studioを起動させていたりしたので重たくて面倒くさかったのですが、今回の IsRoslynComponent では、普通のデバッグの感覚で実行できるので、めちゃくちゃ楽です。最高に書きやすい、これが2021年……!

ユニットテストもする

ユニットテストのいいところは、テストをデバッグ実行すればコードの中身をダイレクトにステップ実行できるところにもあります。ある程度、上のように実コードでデバッグ実行して雰囲気を作れた後は、ユニットテスト上で再現コードを作っていくと、より捗るでしょう。

基本的にはxUnitのテンプレートでプロジェクトを作って、 Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit を参照に追加するだけ。ではあるのですが、net5でシンプルに作ったら連なってる依存関係のせいなのか .NET Frameworkのものの参照が入って警告されたりで鬱陶しいことになったので、とりあえず以下のが警告の出ないパターン(?)で作ったものになります。netcoreapp3.1で。

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

    <PropertyGroup>
        <TargetFramework>netcoreapp3.1</TargetFramework>
        <IsPackable>false</IsPackable>
    </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.8.0" />
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" />
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.0" />

        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
        <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>
    </ItemGroup>
</Project>

このプロジェクトに作ったAnalyzerの参照を足して、以下のようなテストコードを書きます。

        [Fact]
        public async Task SimpleTest2()
        {
            var testCode = @"
class Program
{
    static void Main()
    {
    }
}";

            await Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<SuperSimpleAnalyzer>
                .VerifyAnalyzerAsync(testCode, new DiagnosticResult("SuperSimpleAnalyzer", DiagnosticSeverity.Warning).WithSpan(0, 0, 0, 0));
        }

やることはVerifyAnalyzerAsyncに、それによって発生するエラー部分をDianogsticResultで指定する、という感じです。

シンプルなケースはそれでいいのですが、テストコードにNuGetで外部ライブラリ参照があったり、プロジェクト参照があったりすると、これだけだとテストできません。そこで、そうしたケースが必要な場合は CSharpAnalyzerTest に追加の参照関係を指定してあげる必要があります( XUnit.AnalyzerVerifier は CSharpAnalyzerTest をxUnitのシンプルなケースに特化してラップしただけのものです)。

例えばMessagePipeでは以下のようなユーティリティを用意してテストしました。

static async Task VerifyAsync(string testCode, int startLine, int startColumn, int endLine, int endColumn)
{

    await new CSharpAnalyzerTest<MessagePipeAnalyzer, XUnitVerifier>
    {
        ReferenceAssemblies = ReferenceAssemblies.Default.WithPackages(ImmutableArray.Create(new PackageIdentity("MessagePipe", "1.4.0"))),
        ExpectedDiagnostics = { new DiagnosticResult("MPA001", DiagnosticSeverity.Error).WithSpan(startLine, startColumn, endLine, endColumn) },
        TestCode = testCode
    }.RunAsync();
}

static async Task VerifyNoErrorAsync(string testCode)
{

    await new CSharpAnalyzerTest<MessagePipeAnalyzer, XUnitVerifier>
    {
        ReferenceAssemblies = ReferenceAssemblies.Default.WithPackages(ImmutableArray.Create(new PackageIdentity("MessagePipe", "1.4.0"))),
        ExpectedDiagnostics = { },
        TestCode = testCode
    }.RunAsync();
}

これで

        [Fact]
        public async Task SimpleTest()
        {
            var testCode = @"using MessagePipe;

class C
{
    public void M(ISubscriber<int> subscriber)
    {
        subscriber.Subscribe(x => { });
    }
}";

            await VerifyAsync(testCode, 7, 9, 7, 39);
        }

        [Fact]
        public async Task NoErrorReport()
        {
            var testCode = @"using MessagePipe;

class C
{
    public void M(ISubscriber<int> subscriber)
    {
        var d = subscriber.Subscribe(x => { });
    }
}";

            await VerifyNoErrorAsync(testCode);
        }

のようにテストが書けました。

まとめ

というわけでAnalyzer書いていきましょう。今現在は結局Visual Studioだけかよ!みたいな気もしなくもないですが、そのうちVS CodeとかRiderでも出来るようになるんじゃないでしょうか、どうだろうね、そのへんはわかりません。

ところでUnity 2020.2からAnalyzerが使えると言いましたが、そのサポート状況はなんだかヘンテコで、ぶっちゃけあんま使えないんじゃ疑惑があります。特に問題は、Unity Editor側では有効になっているけどIDE側で有効にならない場合が割とあります。これはUnityの生成したcsprojに、カスタムで追加したAnalyzerの参照が適切に入ってなかったりするせいなのですが、それだと使いづらいですよね、というかAnalyzerってコード書いてる最中にリアルタイムに警告あるのがイケてるポイントなので。

そこでCysharpでCsprojModifierというUnity用の拡張をオープンソースで公開しました。ついさっき。6時間ぐらい前に。

これがあるとUnityでも正しくAnalyzerの参照の入ったcsprojを使える他に、例えばBannedApiAnalyzersという、任意のクラスやメソッド、プロパティの呼び出しを禁止するという、かなり使えるAnalyzerがあるんですが(例えばUnityだとGameObject.Find絶対禁止マンとかが作れます)、これはどのメソッドの呼び出しを禁止するかをBannedSymbols.txtというファイルに書く必要があり、Unityのcsproj生成まんまだとこのBannedSymbols.txtへの参照が作れないんですね。で、CsprojModifierなら、参照を入れたcsprojが作れるので、問題なくUnityでBannedApiAnalyzersが使えるようになるというわけです。

というわけで改めて、Analyzer、書いていきましょう……!

実際こないだリリースしたMessagePipe用に、Subscribe放置を絶対に許さない(エラー化する)Analyzerを公開しました。

こういうの、必要だし、そしてちゃんと導入するととても強力なんですよね。せっかくのC#の強力な機能なので、やっていきましょう。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive