Roslynで作るC#コンパイラー拡張(3)

Roslynで作るC#コンパイラー拡張(3)

Analyzerの作り方と、各メソッドの使い方

2016年10月3日

.NETコンパイラープラットフォーム「Roslyn」でコンパイラー拡張を作ってみよう。Analyzer with Code FixプロジェクトでAnalyzerを実装するために必要な各メソッドの使い方と、Analyzerの作り方を説明する。

  • このエントリーをはてなブックマークに追加

 Analyzer with Code Fixプロジェクトでは、Analyzer(Analyzerクラス)とCode Fix Action(CodeFixProviderクラス)が必要となる。今回はそのうち、Analyzerクラスで必要となる各種メソッドの使用法を具体例とともに紹介しながら、Analyzerの実装方法を説明する。なお、もう一方のCodeFixProviderクラスについては次回紹介する。

Initializeメソッド内で必要なRegisterXXXActionメソッド

 前回の「Initializeメソッド」の項で説明した通り、Analyzerの挙動はまず、このInitializeメソッド内で、その引数のAnalysisContextRegisterXXXActionメソッドを呼び出してActionを登録することから始まる。AnalysisContextクラスに定義されているRegisterXXXActionメソッドとしてはどのようなものがあるかを、箇条書きで紹介しよう。各項目の右にある説明は、各メソッドで登録したActionがどのタイミングで実行されるかを示している。

  • RegisterSymbolAction(Action<SymbolAnalysisContext> action, params SymbolKind[] symbolKinds), RegisterSymbolAction(Action<SymbolAnalysisContext> action, ImmutableArray<SymbolKind> symbolKinds): 指定した種類のシンボルに対するセマンティック解析(Semantic Analysis:意味論的解析)完了時
  • RegisterSyntaxNodeAction<TLanguageKindEnum>(Action<SyntaxNodeAnalysisContext> action, params TLanguageKindEnum[] syntaxKinds), RegisterSyntaxNodeAction<TLanguageKindEnum>(Action<SyntaxNodeAnalysisContext> action, ImmutableArray<TLanguageKindEnum> syntaxKinds): SyntaxNodeごとのセマンティック解析完了時
  • RegisterSyntaxTreeAction(Action<SyntaxTreeAnalysisContext> action): ドキュメントの字句解析とパーサー完了時
  • RegisterSemanticModelAction(Action<SemanticModelAnalysisContext> action): ドキュメントのセマンティック解析完了時
  • RegisterCompilationStartAction(Action<CompilationStartAnalysisContext> action): コンパイルの開始時
  • RegisterCompilationAction(Action<CompilationAnalysisContext> action) : コンパイルの終了時
  • RegisterCodeBlockStartAction<TLanguageKindEnum>(Action<CodeBlockStartAnalysisContext<TLanguageKindEnum>> action)および
    RegisterOperationBlockStartAction(Action<OperationBlockStartAnalysisContext> action): コードブロックやオペレーションブロック*1(=いずれもメソッド本体もしくはメソッド外にある式)のセマンティック解析の開始時
  • RegisterCodeBlockAction(Action<CodeBlockAnalysisContext> action)および
    RegisterOperationBlockAction(Action<OperationBlockAnalysisContext> action): コードブロックやオペレーションブロック*1のセマンティック解析の終了時
  • RegisterOperationAction(Action<OperationAnalysisContext> action, params OperationKind[] operationKinds)および
    RegisterOperationAction(Action<OperationAnalysisContext> action, ImmutableArray<OperationKind> operationKinds): 指定したオペレーション*1ごとのセマンティック解析完了時
  • *1 オペレーション(Operation)はC#の「文」や「式」を表し、オペレーションブロック(OperationBlock)は複数のオペレーションがまとめられた「ブロック」を表す。コードブロック(CodeBlock)はオペレーションブロックを取得する前の「C#コード上のブロック」を表す。結果的に、コードブロックとオペレーションブロックのアクション呼び出しは同じタイミングになる。

 今回はこれらRegisterXXXActionメソッドのうち、よく使われるものについて簡単な使い方と合わせて、その機能内容をもう少し詳しく説明しよう。

RegisterSymbolActionメソッド

 テンプレートから生成されるプロジェクトのひな型コードでも利用されているRegisterSymbolActionメソッドは、セマンティックモデル(SemanticModel)とSyntax(構文)の情報なしに、シンボル(Symbol)の情報のみで判断できるDiagnosticsを登録するときに使われる。「セマンティックモデルの情報」とは、例えばそのシンボルがMatchメソッドの呼び出しだった場合に、「どの名前空間のどのクラスに属するMatchメソッドなのか」という参照先を含めた情報のことである。つまりRegisterSymbolActionメソッドは、参照先の情報なしに判断できるDiagnosticsということで、シンボルの名前・プロパティから命名規約への違反を検知するのに使われることが多い。

 インターフェースの定義名がIで始まっていないときと、名前空間の定義名が小文字で始まっているときにDiagnosticsを作成する例を、リスト1とリスト2に紹介する。各リストに示しているように、対象とする「シンボルの種類」を第2引数に指定すると、第1引数に指定したAction<SymbolAnalysisContext>型のメソッド(この例ではAnalyzeTypeSymbolAnalyzeNamespaceSymbolメソッド)にその種類に応じたシンボルがコンテキスト(=SymbolAnalysisContextオブジェクト)経由で渡されるので、各メソッド内で適宜(この例ではINamedTypeSymbolINamespaceSymbol型に)キャストして扱っている。

C#
public override void Initialize(AnalysisContext context)
{
  context.RegisterSymbolAction(AnalyzeTypeSymbol, SymbolKind.NamedType);
}

private static void AnalyzeTypeSymbol(SymbolAnalysisContext context)
{
  var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;
  if (namedTypeSymbol.TypeKind == TypeKind.Interface && !namedTypeSymbol.Name.StartsWith("I"))
  {
    var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);
    context.ReportDiagnostic(diagnostic);
  }
}
リスト1 インターフェース名がIで始まっていないときにDiagnosticsを作成するコード
C#
public override void Initialize(AnalysisContext context)
{
  context.RegisterSymbolAction(AnalyzeNamespaceSymbol, SymbolKind.Namespace);
}

private static void AnalyzeNamespaceSymbol(SymbolAnalysisContext context)
{
  var namespaceSymbol = (INamespaceSymbol)context.Symbol;
  if (!string.IsNullOrEmpty(namespaceSymbol.Name) && char.IsLower(namespaceSymbol.Name[0]))
  {
    var diagnostic = Diagnostic.Create(Rule, namespaceSymbol.Locations[0], namespaceSymbol.Name);
    context.ReportDiagnostic(diagnostic);
  }
}
リスト2 名前空間名が小文字で始まっているときにDiagnosticsを作成するコード

RegisterSyntaxNodeActionメソッド

 RegisterSyntaxNodeActionメソッドは、RegisterSymbolActionと違い、Syntax情報にアクセスできるため、少し複雑な検知をする場合にはこちらを使うことが多くなるだろう。

 例えば、リスト3に示しているコードでは、Matchという名前のメソッド呼び出しがSystem.Text.RegularExpressions.RegexクラスのMatchメソッドを呼び出していることが検知できる。ここからさらに引数の値も取得できるので、引数に渡しているのが定数文字列の場合に正規表現として正しい値か(=実行時に正規表現のコンパイルエラーが出ないか)を確認することもできる。

C#
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
……省略……

public override void Initialize(AnalysisContext context)
{
  //var match = System.Text.RegularExpressions.Regex.Match("a", "a"); // 後述のSyntax Visualizerで試用する

  context.RegisterSyntaxNodeAction(AnalyzeSyntax, SyntaxKind.InvocationExpression);
}

private static void AnalyzeSyntax(SyntaxNodeAnalysisContext context)
{
  // RegisterSyntaxNodeActionメソッドの第2引数に指定した「Syntaxの種類」に応じて適切な型にキャストする
  var invocationExpr = (InvocationExpressionSyntax)context.Node;
  var memberAccessExpr = invocationExpr.Expression as MemberAccessExpressionSyntax;

  // 「Match」という名前のメソッド呼び出しでなければ検知を終了
  if (memberAccessExpr?.Name.ToString() != "Match")
    return;

  // セマンティックモデルの情報を取得する
  var memberSymbol = context.SemanticModel.GetSymbolInfo(memberAccessExpr, context.CancellationToken).Symbol as IMethodSymbol;

  // セマンティックモデルの情報からMatchメソッドの属するクラス名が「System.Text.RegularExpressions.Regex.Match」のもののみ検知する
  if (!memberSymbol?.ToString().StartsWith("System.Text.RegularExpressions.Regex.Match") ?? true)
    return;

  // 例えば、引数の数はこのように取得する
  var argumentCount = invocationExpr.ArgumentList.Arguments.Count;
}
リスト3 System.Text.RegularExpressions.RegexクラスのMatchメソッドの呼び出しを検知するコード

Syntax Visualizer

 Syntaxには非常に多くの種類があり、またDiagnosticsの内容によっては木構造をたどる必要もあるため、どのSyntaxを対象にすればよいか悩むことも多いだろう。そのような場合には、.NET Compiler Platform SDKでインストールされるSyntax Visualizerを使ってほしい。Visual Studioの(メニューバーにある)[表示]メニューから、[その他のウィンドウ]を選ぶと[Syntax Visualizer]が追加されているはずだ(図1)。

図1 Syntax Visualizerビューを表示するメニュー

 リスト3に載せたMatchメソッドの呼び出し部分をSyntax Visualizerで表示すると図2のようになる。InvocationExpressionとその子にSimpleMemberAccessExpression(コードではその親クラスのMemberAccessExpressionを検知するようにキャストしている)とArgumentListがいることが分かるだろう。

図2 Syntax VisualizerでMatchメソッドを表示している様子

RegisterSyntaxTreeActionメソッド

 RegisterSyntaxTreeActionは、ファイルのSyntax Tree(構文木)全体の解析が必要なDiagnosticsを登録する際に利用する。特定のクラスやメソッドを利用しているケースの検出ではなく、ファイル全体を対象としたい場合、つまりファイル内での空白文字やタブの利用方法や、ファイル内の名前空間宣言やクラス宣言の数や、ファイル名とクラス名の一致などを解析する場合に利用することになるだろう。

 リスト4は、ファイル内に複数の名前空間が宣言されていたら(階層で宣言する場合も含める)、Diagnosticsをレポートするコードである。

C#
public override void Initialize(AnalysisContext context)
{
  context.RegisterSyntaxTreeAction(SyntaxTreeAction);
}

private static void SyntaxTreeAction(SyntaxTreeAnalysisContext context)
{
  var syntaxRoot = context.Tree.GetRoot(context.CancellationToken);
  var descentNamespaceNodes = syntaxRoot
    // クラス宣言の子Nodeに名前空間宣言は来ないので除外する
    .DescendantNodes(node => !node?.IsKind(SyntaxKind.ClassDeclaration) ?? false)
    // 子Node一覧から名前空間宣言のみ取り出す
    .Where(node => node.IsKind(SyntaxKind.NamespaceDeclaration))
    .ToArray();
  if (descentNamespaceNodes.Length >= 2)
  {
    // 2個目の名前空間宣言の位置でDiagnosticsを生成
    var diagnostic = Diagnostic.Create(Rule, descentNamespaceNodes[1].GetLocation());
    context.ReportDiagnostic(diagnostic);
  }
}
リスト4 ファイル内に複数の名前空間宣言があった場合にDiagnosticsをレポートするコード

RegisterSemanticModelActionメソッド

 リスト3のRegisterSyntaxNodeActionメソッドの例ではセマンティックモデルの情報にアクセスする必要があったため、SyntaxNodeAnalysisContextSemanticModelプロパティにアクセスしている。リスト3のようなケースとは異なり、対象を絞り込めない状況でセマンティックモデルの情報が必要な場合は、RegisterSemanticModelActionが利用できる。

 リスト5に例として、string型同士を+演算子で結合している箇所を検知するコードを載せた。実際には、+演算を検出し、その左辺もしくは右辺が組み込みのstring型である場合を検出している。なお、実用的にはさらに条件を絞って、for文中での+演算やToStringメソッドをオーバーライドしていないオブジェクトとの+演算(暗黙的にToStringメソッドが呼ばれるため、オーバーライド漏れが想定される)を検知する、といった使い方が考えられる。

C#
public override void Initialize(AnalysisContext context)
{
  context.RegisterSemanticModelAction(SemanticModelAction);
}

private void SemanticModelAction(SemanticModelAnalysisContext context)
{
  var binaryAddExpressions =
    context.SemanticModel.SyntaxTree.GetRoot()
      .DescendantNodesAndSelf()
      .OfType<BinaryExpressionSyntax>()
      .Where(expr => expr.IsKind(SyntaxKind.AddExpression));

  foreach (var binaryAddExpression in binaryAddExpressions)
  {
    var left = context.SemanticModel.GetTypeInfo(binaryAddExpression.Left);
    var right = context.SemanticModel.GetTypeInfo(binaryAddExpression.Right);
    var stringType = context.SemanticModel.Compilation.GetSpecialType(SpecialType.System_String);
    if (stringType.Equals(left.Type) || stringType.Equals(right.Type))
    {
      var diagnostic = Diagnostic.Create(Rule, binaryAddExpression.GetLocation());
      context.ReportDiagnostic(diagnostic);
    }
  }
}
リスト5 文字列の+演算を検知するコード

RegisterCompilationStartAction/RegisterCodeBlockStartAction/RegisterOperationBlockStartActionメソッド

 これらの、RegisterXXXStartActionという名前に「Start」が付いているメソッドは、それら単体ではDiagnosticsをレポートするのには使えず、複数のAction間で状態を共有して、その中でそれらのAction間からDiagnosticsをレポートするのに利用できる。「状態を共有」というのは、複数の診断を実行してそれらの間で結果を共有するほかに、「あるDiagnosticsが有効化されていれば自分自身は検知を行なわない」であったり、コンパイラーのオプションを見て診断方法を変えたり、といったユースケースにも利用できる。

 リスト6は特定のDiagnosticsが有効になっているかどうかをチェックするサンプルである。

C#
public override void Initialize(AnalysisContext context)
{
  context.RegisterCompilationStartAction(CompileStartAction);
}

private void CompileStartAction(CompilationStartAnalysisContext context)
{
  // 対象となるDiagnostic ID(このIDについては前回説明)を指定
  var diagnosticId = "RS1014";

  // DiagnosticのレポートがSuppress(抑制)状態であれば、何らかのAction(この例えはリスト4と同じSyntaxTreeAction)を登録する例
  if (context.Compilation.Options.SpecificDiagnosticOptions.GetValueOrDefault(diagnosticId, ReportDiagnostic.Default) == ReportDiagnostic.Suppress)
  {
    context.RegisterSyntaxTreeAction(SyntaxTreeAction);
  }
}

private static void SyntaxTreeAction(SyntaxTreeAnalysisContext context)
{
  // リスト4のメソッドをここに記述する
}
リスト6 指定したDiagnosticsが抑制されていれば検知を行う例

EnableConcurrentExecutionメソッド

 AnalysisContextにはRegisterXXXAction以外にもいくつかメソッドが用意されている。そのうち、EnableConcurrentExecutionは、Analyzerの中で登録した複数Actionの並列実行を許可するメソッドである。

 このメソッドを呼ぶと、登録したActionが並列で実行され得るため、解析にかかる時間が短くなる可能性がある。並列実行しても問題がないように、「登録するActionは状態を共有しない」など実装に注意が必要である。

ConfigureGeneratedCodeAnalysisメソッド

 ConfigureGeneratedCodeAnalysisは、自動生成したコードに対してDiagnosticsの検知を行うかどうかを制御するメソッドである。自動生成したコードを解析対象に含めない場合は、リスト7のように記述する。

C#
public override void Initialize(AnalysisContext context)
{
  context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
}
リスト7 自動生成したコードをDiagnosticsの検知対象から外す例

 自動生成とは、例えばXAMLコードをコンパイルして生成されるC#コードや自前のツールなどで生成したC#コードのことである。自動生成かどうかの判定基準はAPIコメントには記載がないが、Roslynでの実装を見ると、ファイル名が.designer.cs.g.cs.generatede.cs.g.i.csで終わっている場合(C#の場合の拡張子。VBの場合は「.cs」の部分は「.vb」になる)やファイルの先頭行のコメントで<autogenerated<auto-generatedが含まれている場合などに、自動生成されたと判断されることが分かる。もし、コード生成するツールを実装する場合は、これに倣ってコメントを入れるようにするとAnalyzerからの検知の制御が行えるようになるだろう。

 次回は、Code Fix Actionの実装方法を説明するために、CodeFixProviderを継承したクラスの実装方法について説明する予定である。

1. .NETコンパイラープラットフォーム「Roslyn」の概要とコンパイラー拡張

C# 6.0と同時にリリースされた.NETコンパイラープラットフォーム「Roslyn」。そのコンパイラー拡張の作り方を解説する連載の第1回。

2. .NETコンパイラープラットフォーム拡張の作り方

C# 6.0と同時にリリースされた.NETコンパイラープラットフォーム「Roslyn」。そのコンパイラー拡張の作り方を解説する連載の第2回。

3. 【現在、表示中】≫ Analyzerの作り方と、各メソッドの使い方

.NETコンパイラープラットフォーム「Roslyn」でコンパイラー拡張を作ってみよう。Analyzer with Code FixプロジェクトでAnalyzerを実装するために必要な各メソッドの使い方と、Analyzerの作り方を説明する。

4. Code Fix Actionの作り方

.NETコンパイラープラットフォーム「Roslyn」でコンパイラー拡張を作ってみよう。CodeFixProviderの実装方法を説明し、code-crackerのソースコードから引用する形で基本的なコード修正候補の作成例を示す。

5. 外部ファイルの読み込みとローカライズ

Roslynのコンパイラー拡張で外部ファイルを読み込んで活用する方法と、AnalyzerやCode Fix Actionのメッセージをローカライズする方法について説明する。連載最終回。

Twitterでつぶやこう!


Build Insider賛同企業・団体

Build Insiderは、以下の企業・団体の支援を受けて活動しています(募集概要)。

ゴールドレベル

  • 日本マイクロソフト株式会社
  • グレープシティ株式会社