.NET Compiler Platform SDK の構文解析で楽しくコーディング

現在の C# は [.NET Compiler Platform SDK (Roslyn API)](https://docs.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/) を通して構文解析などが開発者が自由に扱えるようになっています。これを利用することで、プロジェクトで設定したコーディングガイドラインに違反している構文を、IDE を通してリアルタイムで指摘できるようになりました。

ここでは、特定の構文に対してコンパイラーとしてエラーを出すようにする仕組みを作成する方法をご紹介します。

## 下準備
Visual Studio を使用します。拡張機能扱いになるので、「Visual Studio 拡張機能の開発」ツールセットを追加し、個別に「.NET Compiler Platform SDK」も追加するようにする必要があります。「.NET Compiler Platform SDK」はデフォルトでチェックが入っていないので忘れがちです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/1799bc2d-8279-8f94-74af-69866ab7dc7c.png)

準備ができたら、Visual Studio で新規プロジェクトから `Analyzer with Code Fix (.NET Standard)` を選択して作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/ec22e1d6-52d2-4eb6-87f9-b1991478ff42.png)

作成すると型名が camelCase になってるのを指摘するアナライザーがテンプレートになるプロジェクトができあがっていますので、これをもとに新しく作ります。

## とりあえず作ってみる
コメント以外のコード中に全角スペースがあった際にエラーとなるアナライザーを作ってみます。
指定したプロジェクト名に `.CodeFixes` や `.Package` がついていないプロジェクトに新しいクラスファイルを作成します。名前は適当に `AL0001` みたいにしておきます。
`Resource.resx` を開き、以下のように名前と値を設定します。

|名前|値|
|---|---|
|AL0001Title|Do Not Use Wide Space|
|AL0001Description|全角スペースは使用しないでください。|
|AL0001MessageFormat|全角スペースは使用しないでください。|

`AL0001.cs` へ戻り、テンプレートを参考に以下のように実装します。

```c#:AL0001.cs
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class AL0001 : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "AL0001";
        private const string Category = "Spacing";

        private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AL0001Title), Resources.ResourceManager, typeof(Resources));
        private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AL0001MessageFormat), Resources.ResourceManager, typeof(Resources));
        private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AL0001Description), Resources.ResourceManager, typeof(Resources));

        private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, true, Description);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

        public override void Initialize(AnalysisContext context)
        {

        }
    }
}
```

このあたりはほぼテンプレートに近いので、複数作る時はコピペするなり T4 テンプレートにするなりしておくと便利かも。

ここまで実装したら次は実際にコード解析をおこないます。複雑な構文木を覚えていられないので、ここでは IDE の力を使います。下準備で「.NET Compiler Platform SDK」を Visual Studio にインストールしていれば、`表示` メニューから `その他のウィンドウ` をポイントすると `Syntax Visualizer` が追加されているので、これを選択します。これを使うと、今見ているコードの構文木を種類単位で見ることができるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/0bb2ad99-535e-ad8e-8388-6457acfba33e.png)
開いただけだとピン留めされておらず、コード部分をクリックするたびに引っ込んでしまうので、使う時はピン留めしておくと楽です。

### 解析に介入するタイミング
コンパイラーが構文解析する中で、いくつかのタイミングに介入して独自に処理を追加することができます。[実装する際によく見ているこの記事](https://www.buildinsider.net/enterprise/roslynextension/03)からざっくりと紹介すると

|メソッド名|タイミング|使い道|
|---------|---------|------|
|RegisterSymbolAction|各種シンボル (メソッド名、クラス名など) のセマンティック解析終了時|当該のメソッド名が属するクラスや名前空間を知りたいときなど|
|RegisterSyntaxTreeAction|ドキュメントの構文解析終了時|構文の細かい部分 (スペースやコメントなど) を見たいときなど|
|RegisterSyntaxNodeAction|特定のノード (メンバーアクセス構文や変数宣言構文など) を対象としたセマンティック解析終了時|特定のノードに対して詳しく知りたいとき|
|RegisterSemanticModelAction|ドキュメントのセマンティック解析終了時|幅広く詳しく知りたいとき|

この辺を使うとある程度作れるかなあという感じです。

### 実際に作ってみる
ここからが結構地味で、テスト用プロジェクトを作って指摘したいコードを実装しておき、Syntax Visualizer を使って当該部分のツリーを見ながら、アタリをつけてデバッグ実行してブレークポイントを置いて値の変化を見ながら…などやっていくことになります。

今回は「全角スペース」が使われている箇所を調べるように実装したいので、`RegisterSyntaxTreeAction` でよいかと思います。
Syntax Visualizer を見ると、スペースは `WhitespaceTrivia` として解析されているようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/f89a0d52-eda1-10da-04c7-b6c1b93a55c6.png)
ということで、以下のように実装します。

```c#
public override void Initialize(AnalysisContext context)
{
    context.RegisterSyntaxTreeAction(
        action =>
        {
            // コード全体を取得する
            var root = action.Tree.GetRoot(action.CancellationToken);

            // Whitespace Trivia だけ取り出して
            var triviaList = root.DescendantTrivia()
                .Where(w => w.IsKind(SyntaxKind.WhitespaceTrivia))
                .ToArray();

            foreach (var trivia in triviaList)
            {
                // 全角スペースを拾う
                if (!trivia.ToString().Contains("　"))
                {
                    continue;
                }

                // 違反位置を特定して通報
            }
        });
}
```

`Descendant~~~` メソッドを使うと構文木の下方向への検索ができるので、検索したい内容にあわせて `DescendantNodes` `DescendantTokens` を使う感じです。ちなみに逆方向 `Ancestors` もあります。特定の構文に対してどの変数に代入・宣言されているかなどを見るときに便利です。

違反を IDE やコンパイラーに通知する場合はこういうコードを書けば OK です。

```c#
// 違反位置を特定して通報
var location = trivia.GetLocation();
var diagnostic = Diagnostic.Create(Rule, location);

action.ReportDiagnostic(diagnostic);
```

各種構文にはそれが書かれている位置と長さを持っているので、必要に応じて `Ancestors` したりして当該の構文全体を指摘したりすることもできます。

### もうちょっと工夫してみる
このままだと自動生成コードまで見てしまうので、このアナライザーは自動生成コードを見ないぞって決める際は `Initialize` メソッドに `context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);` を書いてあげてください。

複数同時に解析して欲しい場合も `Initialize` メソッドに `context.EnableConcurrentExecution();` を書けば OK ですが、同一アナライザーが複数回同時に呼ばれる可能性があるので、`Action` 外で状態保持をしている場合は注意が必要です。

### ここまでの全体構文
```c#:AL0001.cs
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class AL0001 : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "AL0001";
        private const string Category = "Spacing";

        private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AL0001Title), Resources.ResourceManager, typeof(Resources));
        private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AL0001MessageFormat), Resources.ResourceManager, typeof(Resources));
        private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AL0001Description), Resources.ResourceManager, typeof(Resources));

        private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, true, Description);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

        public override void Initialize(AnalysisContext context)
        {
            // 同時に実行できるようにする
            context.EnableConcurrentExecution();
            // 自動生成コードは検査しないようにする
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

            context.RegisterSyntaxTreeAction(
                action =>
                {
                    var root = action.Tree.GetRoot(action.CancellationToken);

                    // Whitespace だけ取り出して
                    var triviaList = root.DescendantTrivia()
                        .Where(w => w.IsKind(SyntaxKind.WhitespaceTrivia))
                        .ToArray();

                    foreach (var trivia in triviaList)
                    {
                        // 全角スペースを拾う
                        if (!trivia.ToString().Contains("　"))
                        {
                            continue;
                        }

                        // 違反位置を特定して通報
                        var location = trivia.GetLocation();
                        var diagnostic = Diagnostic.Create(Rule, location);

                        action.ReportDiagnostic(diagnostic);
                    }
                });
        }
    }
}
```

あとはビルドして、できあがった `dll` ファイルを使用したいプロジェクトの `アナライザー` に追加すれば動くはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/f7bd9c75-ecf6-e593-ff7d-36e6177a158b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/2fd358d3-c426-eea3-7865-86c7b39f54d6.png)

## .editorconfig を使って細かくルール設定をする
Visual Studio 2019 Version 16.2 以降、Rider 2020.2 以降から、アナライザーのルール設定が `ruleset` ファイルの他に `.editorconfig` ファイルでもできるようになりました。これにより、どのディレクトリでルールを適用するかを設定できるようになっています。
例えば Unity であれば、アセットストアから拾ってきたソースコードまで見てしまう問題が解消する訳です。

```
# 基本はルールを無視する
[*.cs]
dotnet_diagnostic.severity = none

# 特定のディレクトリ内にあるファイルはルールを適用
[Assets/Project/Scripts/**.cs]
dotnet_diagnostic.AL0001.severity = error
```

## Unity のプロジェクトで使えるようにする
Unity は `.csproj` を自動生成するため、IDE から追加しても消えてしまいます。それを防ぐために、以下のコードを作成しておきます。

```c#:CsprojAnalyzerPostProcessor.cs
public class CsprojAnalyzerPostProcessor : AssetPostprocessor
{
    const string AnalyzerDirectoryPath = "Analyzers";

    public static void OnGeneratedCSProjectFiles()
    {
        var currentDirectory = Directory.GetCurrentDirectory();
        var projectFiles = Directory.GetFiles(currentDirectory, "*.csproj");

        foreach (var file in projectFiles)
        {
            UpgradeProjectFile(file);
        }
    }

    static void UpgradeProjectFile(string projectFile)
    {
        XDocument doc;
        try
        {
            doc = XDocument.Load(projectFile);
        }
        catch (Exception)
        {
            return;
        }

        if (doc.Root == null)
        {
            return;
        }

        if (projectFile.EndsWith("Assembly-CSharp.csproj", StringComparison.InvariantCulture)
            || projectFile.EndsWith("client.csproj", StringComparison.InvariantCulture))
        {
            AddAnalyzerReference(doc.Root, doc.Root.Name.NamespaceName);
        }

        doc.Save(projectFile);
    }

    static void AddAnalyzerReference(XElement projectContentElement, XNamespace xmlns)
    {
        var itemGroup = new XElement(xmlns + "ItemGroup");

        var analyzerPath = Path.Combine(Directory.GetCurrentDirectory(), "Assets", AnalyzerDirectoryPath);
        var files = Directory.GetFiles(analyzerPath, "*.dll", SearchOption.AllDirectories);


        if (files.Length == 0)
        {
            return;
        }

        foreach (var analyzer in files)
        {
            var analyzerElement = new XElement(xmlns + "Analyzer");
            analyzerElement.Add(new XAttribute("Include", analyzer));
            itemGroup.Add(analyzerElement);
        }

        projectContentElement.Add(itemGroup);
    }
}
```

生成したアナライザーの DLL ファイルは、Unity プロジェクトディレクトリ `Assets/` 内に `Analyzers` ディレクトリを作り、そこに入れておきます。

## 実例
実際に運用しているルールをひとつご紹介します。
プロジェクトでは空文字を指定するのに `string.Empty` 変数を使用せず、 `""` を使うようルールを定めています。`string.Empty` を使った際にエラーとするルールが用意されています。このルールは単に `string.Empty` かどうかを見ているだけでなく、 `System.String.Empty` を見ているかを確認するようにしています。

```c#
public override void Initialize(AnalysisContext context)
{
    context.EnableConcurrentExecution();
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

    context.RegisterSyntaxNodeAction(
        action =>
        {
            // メンバーアクセスでないのをフィルター
            if (!(action.Node is MemberAccessExpressionSyntax expression))
            {
                return;
            }

            // Name がフィールド変数かどうか
            if(!(action.SemanticModel.GetSymbolInfo(expression.Name).Symbol is IFieldSymbol field))
            {
                return;
            }

            // Name が Empty でない
            if (field.Name.ToLower() != "empty")
            {
                return;
            }

            // System.String でない
            if (field.Type.SpecialType != SpecialType.System_String)
            {
                return;
            }

            // string.Empty と断定し通報
            var diagnostic = Diagnostic.Create(Rule, expression.GetLocation(), expression.Expression.GetText());
            action.ReportDiagnostic(diagnostic);
        },
        SyntaxKind.SimpleMemberAccessExpression);
}
```

こんな感じで出ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/38c38c77-dc62-a4c9-13ca-6af1ae0f3de9.png)

## コード修正提案について
特定ルールに対して検知したものを通知するだけでなく、どうすればいいかの修正を提案することもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314249/f929ee9d-427e-6c23-5ed0-480a3510d2b1.png)
これを作成するには、`.CodeFixes` がついたプロジェクトを使用します。こちらに以下のようなコードファイルを追加します。

```c#:AL0002Fix.cs
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AL0002Fix)), Shared]
public class AL0002Fix : CodeFixProvider
{
    private static LocalizableString FixMessage { get; } =
        new LocalizableResourceString(nameof(CodeFixResources.AL0002FixTitle), CodeFixResources.ResourceManager,
            typeof(CodeFixResources));

    public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(AL0002.DiagnosticId);

    public override FixAllProvider GetFixAllProvider()
    {
        return WellKnownFixAllProviders.BatchFixer;
    }

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
        var diagnostic = context.Diagnostics.First();
        var diagnosticSpan = diagnostic.Location.SourceSpan;

        var token = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf()
            .OfType<MemberAccessExpressionSyntax>()
            .First();

        context.RegisterCodeFix(
            CodeAction.Create(FixMessage.ToString(),
                c => FixStringEmptyToLiteral(context.Document, root, token)), diagnostic);
    }

    private Task<Document> FixStringEmptyToLiteral(Document document, SyntaxNode root, MemberAccessExpressionSyntax token)
    {
        var newToken = SyntaxFactory.ParseExpression("\"\"");
        var newRoot = root.ReplaceNode(token, newToken);
        var newDocument = document.WithSyntaxRoot(newRoot);

        return Task.FromResult(newDocument);
    }
}
```

ビルドすると別バイナリになるので、これも別途参照設定に追加してください。Unity であれば、先ほど作った Analyzers ディレクトリに入れれば OK
上記コードについての[詳しいことはこの記事を見てください](https://www.buildinsider.net/enterprise/roslynextension/04)。ルールも修正提案も、この記事にだいぶ助けられています…。

## ここまで
「.NET Compiler Platform SDK」により、静的解析だけではできなかった指摘もできるようになり、かつコード修正の提案もできるようになっています。うまく活用することで作業者にリアルタイムで規約違反を指摘・修正のエスコートまでできる最強のツールになること間違い無しです。

## 参考文献一覧
- [.NETコンパイラープラットフォーム「Roslyn」の概要とコンパイラー拡張 - Build Insider](https://www.buildinsider.net/enterprise/roslynextension/01)
- [EditorConfig](https://editorconfig.org/)
- [アナライザーの構成 - Visual Studio | Microsoft Docs](https://docs.microsoft.com/ja-jp/visualstudio/code-quality/use-roslyn-analyzers?view=vs-2019)