13
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

KLab EngineerAdvent Calendar 2020

Day 12

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

Last updated at Posted at 2020-12-11

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

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

下準備

Visual Studio を使用します。拡張機能扱いになるので、「Visual Studio 拡張機能の開発」ツールセットを追加し、個別に「.NET Compiler Platform SDK」も追加するようにする必要があります。「.NET Compiler Platform SDK」はデフォルトでチェックが入っていないので忘れがちです。
image.png

準備ができたら、Visual Studio で新規プロジェクトから Analyzer with Code Fix (.NET Standard) を選択して作成します。
image.png

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

とりあえず作ってみる

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

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

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

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
開いただけだとピン留めされておらず、コード部分をクリックするたびに引っ込んでしまうので、使う時はピン留めしておくと楽です。

解析に介入するタイミング

コンパイラーが構文解析する中で、いくつかのタイミングに介入して独自に処理を追加することができます。実装する際によく見ているこの記事からざっくりと紹介すると

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

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

実際に作ってみる

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

今回は「全角スペース」が使われている箇所を調べるように実装したいので、RegisterSyntaxTreeAction でよいかと思います。
Syntax Visualizer を見ると、スペースは WhitespaceTrivia として解析されているようです。
image.png
ということで、以下のように実装します。

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 です。

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

action.ReportDiagnostic(diagnostic);

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

もうちょっと工夫してみる

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

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

ここまでの全体構文

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
image.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 から追加しても消えてしまいます。それを防ぐために、以下のコードを作成しておきます。

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 を見ているかを確認するようにしています。

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

コード修正提案について

特定ルールに対して検知したものを通知するだけでなく、どうすればいいかの修正を提案することもできます。
image.png
これを作成するには、.CodeFixes がついたプロジェクトを使用します。こちらに以下のようなコードファイルを追加します。

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
上記コードについての詳しいことはこの記事を見てください。ルールも修正提案も、この記事にだいぶ助けられています…。

ここまで

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

参考文献一覧

13
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?