👀

[C#] Visual Studioで独自の警告を作ってチームで共有する

2023/01/23に公開約13,100字

こんにちは。tackmeと申します。Zennに投稿するのはこれが初めてです。
趣味でVisual Studioの拡張機能Chromeの拡張機能を作ったり、ちょっとしたライブラリ等を書いたりしています。仕事ではReact, .NET CoreでWebアプリの開発を担当しています。

さて、私が働いている会社では、コードレビューのチェック項目のうち機械的にチェックできるものはIDE上に警告をだして実装時に気がつけるようにしています。
例えば以下のように日本語の文字列を多言語化せずに(_Tを使わずに)入力すると警告を出してくれます。

この独自の警告はコードアナライザーと呼ばれる機能で実現しており、Visual Studioを使って簡単に作ることができます。今回はアナライザーの作り方と、それを開発チームで共有する(git管理する)方法を紹介したいと思います。

開発環境の構築

以下、Visual Studio 2022で説明します。2017でも作ったことありますが大体おなじです。

まずVisual Studio Installerで「Visual Studio 拡張機能の開発」をインストールします。この際、.NET Compiler Platform SDKも一緒に追加してください。

Analyzer with Code Fixというテンプレートが追加されるので、このテンプレートで新しいプロジェクトを作成します(以下、プロジェクト名はMyAnalyzerで説明します)。

ソリューションは以下のような構成になっています。

プロジェクト名 目的
MyAnalyzer コードのアナライザーの実装
MyAnalyzer.CodeFixes コードの自動修正(コードフィックス)の実装
MyAnalyzer.Package NuGetパッケージのビルド用
MyAnalyzer.Test 単体テスト用
MyAnalyzer.Vsix Visual Studio拡張機能のビルド用

MyAnalyzerにはサンプルとして、クラス名に小文字が含まれていたら警告を出すアナライザーが実装されています。
MyAnalyzer.Vsixをスタートアッププロジェクトに設定してデバッグ実行するともう一つVisual Studioが立ち上がり、そこで警告が表示されることを確認できます。

アナライザーを実装する

例として、冒頭のデモでもお見せした日本語文字列に多言語化メソッド_Tを使っていない場合に警告を出すRequireTranslationというアナライザーを実装してみましょう。

MyAnalyzerプロジェクトのMyAnalyzerAnalyzer.csをベースとしてアナライザーを実装していきます。
コードを要約すると以下のような構成になっています。

MyAnalyzerAnalyzer.cs
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class MyAnalyzerAnalyzer : DiagnosticAnalyzer
{
    // アナライザーの設定    
    public const string DiagnosticId = "MyAnalyzer";
    private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AnalyzerTitle), Resources.ResourceManager, typeof(Resources));
    ...(略)

    public override void Initialize(AnalysisContext context)
    {
        // アナライザーの登録
    }

    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
        // アナライザーの実装
    }
}

まずはアナライザーの設定を変更します。

メンバー 設定内容
DiagnosticId 任意のアナライザーのID。警告の抑止のpragmaなどに使われます。
Title アナライザーのタイトル
MessageFormat アナライザーの警告文。画面上に表示されるやつ。
Description アナライザーの説明文
Category アナライザーのカテゴリー。DBとかi18nとか、それっぽい値を入れればOKです。

今回は以下のように設定しました。

MyAnalyzerAnalyzer.cs
- public const string DiagnosticId = "MyAnalyzer";
+ public const string DiagnosticId = "RequireTranslation";
 
  // You can change these strings in the Resources.resx file. If you do not want your analyzer to be localize-able, you can use regular strings for Title and MessageFormat.
  // See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Localizing%20Analyzers.md for more on localization
  private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AnalyzerTitle), Resources.ResourceManager, typeof(Resources));
  private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
  private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources));

- private const string Category = "Naming";
+ private const string Category = "i18n";

つぎにInitializeメソッド内でcontext.RegisterSyntaxNodeActionを呼び出し、第1引数にアナライザーの処理、第2引数にアナライザーの対象となるノードの種類を指定します。
ノードの種類はSyntax Visualizer(表示>その他ウィンドウ)を使うことで調べることができます。

今回は文字列を対象とするアナライザーを作るので第2引数にSyntaxKind.StringLiteralExpressionを指定しました。第1引数にはとりあえず空のメソッドを作成して渡しておきましょう(もともとあったAnalyzeSymbolは消してOKです)。

MyAnalyzerAnalyzer.cs
 public override void Initialize(AnalysisContext context)
 {
     context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
     context.EnableConcurrentExecution();
 
     // TODO: Consider registering other actions that act on syntax instead of or in addition to symbols
     // See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md for more information
-    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
+    context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.StringLiteralExpression);
 }

+private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
+{
+
+} 

AnalyzeNodeメソッドの中身を書いていきます。
まずは文字列が日本語だったら警告を出すようにしてみましょう。引数のcontext.Nodeにチェック対象のノードが入ってくるので、ここから文字列を取得して正規表現でチェックします。

MyAnalyzerAnalyzer.cs
private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
    // チェク対象のノードを取得(RegisterSyntaxNodeActionで登録した種類のノードが入っています)
    var stringNode = context.Node;

    // ノードから文字列を取得
    var text = ((LiteralExpressionSyntax)stringNode).Token.ValueText;

    // 日本語かどうかチェック
    if (!Regex.IsMatch(text, @"[\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}]"))
    {
        return;
    }

    // 警告の作成・登録
    var diagnostic = Diagnostic.Create(Rule, stringNode.GetLocation());
    context.ReportDiagnostic(diagnostic);    
}

この状態でデバッグ実行して動作を確認すると、日本語の文字列に警告が表示されるはずです。

あとは文字列が多言語化メソッド_Tの引数かどうかを確認する処理を加えればよさそうです。

Syntax Visualizerでメソッド呼び出しのコードを確認してみると、3つ親のノードがInvocationExpressionSyntaxになっていることがわかります。
さらにPropertiesにあるように、InvocationExpressionSyntaxExpressionプロパティにメソッド名が入っています。

これを踏まえてコードの続きを書くと、以下のようになります。

MyAnalyzerAnalyzer.cs
private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
    // チェク対象のノード
    var stringNode = context.Node;

    // ノードから文字列を取得
    var text = ((LiteralExpressionSyntax)stringNode).Token.ValueText;

    // 日本語かどうかチェック
    if (!Regex.IsMatch(text, @"[\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}]"))
    {
        return;
    }

    // 文字列がメソッドの引数かつメソッドが多言語化メソッドならOK
    if (stringNode.Parent.Parent.Parent is InvocationExpressionSyntax invocation &&
        invocation.Expression.ToString() == "_T")
    {
        return;
    }

    // 警告の作成
    var diagnostic = Diagnostic.Create(Rule, stringNode.GetLocation());
    context.ReportDiagnostic(diagnostic);
}

最後にもう一度デバッグして動作を確認してみましょう。

問題なさそうですね。

コードフィックスを実装する

次にコードフィックスを作ってみます。コードフィックスを実装することで、以下のように警告箇所の自動修正ができるようになります。

まずはMyAnalyzer.CodeFixesプロジェクトのCodeFixResources.resxを開いて、CodeFixTitleの値をそれっぽいものに変更しておきましょう。上記の動画で「考えられる修正内容を表示する」を押すと出てくるテキストです。

次にMyAnalyzerCodeFixProvider.csを開いて、RegisterCodeFixesAsyncメソッドを実装していきましょう(サンプルのコードは一旦削除してOKです)。

まず最初に、警告箇所のノードを取得します。context.Documentにコード全体の情報が、context.Diagnosticsに警告の位置情報が含まれているので、これをもとにすると対象のノードを取得できます。

MyAnalyzerCodeFixProvider.cs
// コード全体のノードを取得
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

// アナライザーで登録した警告(と位置情報)を取得
var diagnostic = context.Diagnostics.First();
var diagnosticSpan = diagnostic.Location.SourceSpan;

// 警告箇所のノードを取得
var stringNode = root.FindToken(diagnosticSpan.Start).Parent as LiteralExpressionSyntax;

ここまでは他のコードフィックスの実装でもだいたい同じだと思うので、「こういう風に書くのか~」くらいの認識でOKです。

次にCodeAction.Createメソッドを使って、具体的な修正ロジックを書いていきます。
SyntaxFactoryクラスに用意されたメソッドを使って新しい(修正された)ノードを作成し、DocumentEditorで警告箇所のノードを新しいノードに置き換えるのが基本的な流れになるかと思います。

今回は警告の出た文字列を多言語化させる(_Tの引数に渡す)ように修正すればOKなので、以下のように実装しました。

MyAnalyzerCodeFixProvider.cs
var action = CodeAction.Create(CodeFixResources.CodeFixTitle, async token =>
{
    // 文字列を'_T'で囲ったノードを作成
    var newNode = SyntaxFactory.ParseExpression($"_T({stringNode})");

    // 警告箇所のノードを新しいノードに置換
    var editor = await DocumentEditor.CreateAsync(context.Document, token);
    editor.ReplaceNode(stringNode, newNode);

    return editor.GetChangedDocument();
});

SyntaxFactory.ParseExpressionは引数に渡したコードをパースしてくれる便利なメソッドです。他にもノードの種類ごとに作成用のメソッドが用意されているので、複雑な修正を行う場合にはそちらも使うことになると思います。

DocumentEditorクラスはノードの挿入や削除、置換などを行うためのクラスです。今回はReplaceNodeで警告の出たノードを修正後のノードに差し替えています。

最後にcontext.RegisterCodeFixで作成したCodeActionを登録すればOKです。以下が作成したコードの全体です。

MyAnalyzerCodeFixProvider.cs
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
    var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

    // アナライザーで登録した警告を取得
    var diagnostic = context.Diagnostics.First();
    var diagnosticSpan = diagnostic.Location.SourceSpan;

    // 警告箇所のノードを取得
    var stringNode = root.FindToken(diagnosticSpan.Start).Parent as LiteralExpressionSyntax;
    if (stringNode == null)
    {
        return;
    }

    // 修正内容
    var action = CodeAction.Create(CodeFixResources.CodeFixTitle, async token =>
    {
        // 文字列を_Tで囲ったノードを作成
        var newNode = SyntaxFactory.ParseExpression($"_T({stringNode})");

        // 警告箇所のノードを新しいノードに置換
        var editor = await DocumentEditor.CreateAsync(context.Document, token);
        editor.ReplaceNode(stringNode, newNode);

        return editor.GetChangedDocument();
    });

    // コードフィックスの登録
    context.RegisterCodeFix(action, diagnostic);
}

デバッグ実行して警告箇所にマウスオーバーすると、自動修正の提案がされるようになっているはずです。

単体テストを作る

作成したアナライザー・コードフィックスの単体テストを書いていきます。単体テストはMyAnalyzer.Testプロジェクトに記述します。

まずは警告が出るパターンの単体テストを見てみましょう。以下がそのコードです。

MyAnalyzerUnitTests.cs
[TestMethod]
public async Task Japanese_NotTranslated_Warning()
{
    var test = @"
namespace Test
{
    class TestClass
    {
        void TestMethod()
        {
            var greeting = ""こんにちは"";
        }
    }
}
";

    // アナライザーのDiagnosticIdと警告レベルを指定
    var expected = new DiagnosticResult("RequireTranslate", DiagnosticSeverity.Warning)
    // 警告の表示位置を指定
    .WithSpan(startLine: 8, startColumn: 28, endLine: 8, endColumn: 35);

    await VerifyCS.VerifyAnalyzerAsync(test, expected);
}

DiagnosticResultで期待する警告の情報を作成し、VerifyCS.VerifyAnalyzerAsyncに渡して検証していきます。

また「警告を出さないこと」をテストするには、VerifyCS.VerifyAnalyzerAsyncを第2引数なしで呼び出せばOKです。

MyAnalyzerUnitTests.cs
[TestMethod]
public async Task Japanese_Translated_NoWarning()
{
    var test = @"
namespace Test
{
    class TestClass
    {
        string _T(string text) => text;

        void TestMethod()
        {
            var greeting = _T(""こんにちは"");
        }
    }
}
";

    await VerifyCS.VerifyAnalyzerAsync(test);
}

テストコードのクラスに_Tを実装しているのは、存在しないメソッドを使うとテストに失敗するためです。

次にコードフィックスのテストを見てみましょう。

MyAnalyzerUnitTests.cs
[TestMethod]
public async Task Japanese_Warning_Fixed()
{
    // 修正前
    var test = @"
namespace Test
{
    class TestClass
    {
        string _T(string text) => text;

        void TestMethod()
        {
            var greeting = ""こんにちは"";
        }
    }
}
";
    // 修正後
    var fixtest = @"
namespace Test
{
    class TestClass
    {
        string _T(string text) => text;

        void TestMethod()
        {
            var greeting = _T(""こんにちは"");
        }
    }
}
";

    var expected = VerifyCS.Diagnostic("RequireTranslate").WithSpan(10, 28, 10, 35);
    await VerifyCS.VerifyCodeFixAsync(test, expected, fixtest);
}

今度はVerifyCS.VerifyCodeFixAsyncに修正前後のコードと、警告の情報を渡すことで検証します。

すべてのテストを実行してパスすれば単体テストはOKです。テストケースとして日本語じゃない場合に警告が出ないパターンも必要ですが、今回は省略します。

チームで共有する

せっかく作成したアナライザーも、チーム全員の環境で表示されなければ意味がありません。
アナライザーをgit管理下においてリポジトリ利用者全員に反映されるようにしてみましょう。

MyAnalyzer.Packageプロジェクトをビルドすることで、/bin/DebugフォルダにMyAnalyzer.1.0.0.nupkgという名前のnugetパッケージが生成されます。

次にアナライザーを反映させたいソリューションと同じ階層にlocal_packagesというフォルダを作成し、ここに生成されたnugetパッケージをコピーします。
さらに同じフォルダにnuget.configファイルを作成し、以下のコードを貼り付けます。

nuget.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="Local Packages" value="local_packages" />
  </packageSources>
</configuration>

これでlocal_packagesフォルダに入れたnugetパッケージをVisual Studio上でインストールできるようになります。

インストールしてちゃんと警告が表示されるかどうか試し、問題がなければリポジトリにコミットしてください。もし表示されない場合はVisual Studioを再起動を試してみてください。

またアナライザーを更新した際にはMyAnalyzer.Package.csprojファイルを編集してnugetパッケージのバージョン情報を変え、ビルドしたパッケージをlocal_packagesにコピーすればパッケージ管理マネージャーから更新できるようになります。

MyAnalyzer.Package.csproj
  <PropertyGroup>
    <PackageId>MyAnalyzer</PackageId>
    <PackageVersion>1.0.0.0</PackageVersion>

    ......

  </PropertyGroup>

参考

Discussion

ログインするとコメントできます