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

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

Code Fix Actionの作り方

2016年12月28日

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

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

 連載第4回では、Analyzer with Code Fixプロジェクトで必要となるAnalyzerとCode Fix Actionのうち、(Analyzerの実装方法は前回説明したので)残りのCode Fix Actionの実装方法を具体例とともに紹介する。

 これを作成するためには、抽象クラスであるCodeFixProviderを継承したクラスを作成する必要がある。CodeFixProviderクラスの概要については連載第2回で説明しているため、今回はGitHubで公開されている実際のCodeFixProviderクラスの実装を例に挙げながら、具体的な事例について説明していきたい。

Analyzerとの関連付け

自作以外のAnalyzerに対するCode Fix Actionを登録する

 Code Fix Actionは1つ以上のDiagnosticsに対応しているが、どのDiagnosticsに対応しているかを定義するのがCodeFixProviderクラスのFixableDiagnosticIdsプロパティである。Diagnosticsごとに一意に定義するIDを文字列として参照しているため、IDさえ分かれば、自分の作成している拡張以外のDiagnosticsに対する修正候補も記述できるようになっている。例えば「CS」で始まるC#で定義されているコンパイルエラーの番号はDiagnostics IDになっているため、デフォルトのコンパイルエラーに対するコード修正候補を作成することも可能になっている。

複数のDiagnosticsに対するCode Fix Actionを登録する

 「1つのCodeFixProviderクラスで複数のDiagnosticsを対応させるか」「1つのDiagnosticsごとに1つのCodeFixProviderクラスを作成するか」の使い分けについては、いくつかの実装例を見ると参考になるが、自作する場合は、1つのCodeFixProviderでは1つのDiagnosticsに対応するケースが多くなるだろう。

 というのは、1つのDiagnosticsに対応させるのであれば、検知したDiagnosticsの情報をコード修正に利用できるが、複数のDiagnosticsに対応させる場合の多くは、修正対象となる範囲をCodeFixProvider側で検知し直す必要があるためである。例えば、RoslynのSimplifyTypeNamesCodeFixProviderは、型名の参照をより単純にするためのCode Fix Actionを提供しているが、コードの修正候補を作成する際にドキュメント内の全てのコードを検知し、対象となる箇所全てに対してまとめてコード修正候補を作成している。

 なお、1つのDiagnosticsに複数のコード修正を提示したい場合は、1つのCodeFixProviderの中で複数の候補を登録することができる。

コード修正候補の作成例

 Code Fix Actionが提示するコード修正候補は、CodeActionクラス(Microsoft.CodeAnalysis.CodeActions名前空間)のCreateメソッドで登録するが、このとき実際のコード修正候補を記述する処理では、Task<Solution>型もしくはTask<Document>型の返り値が必要となる。修正範囲が1つのドキュメント、例えばC#のの.csファイルのみに限定される場合はTask<Document>を、修正範囲が複数のドキュメントに及ぶ場合はTask<Solution>を返すことになる。RegisterCodeFixesAsyncメソッドで引数として渡される、CodeFixContextクラス(Microsoft.CodeAnalysis.CodeFixes名前空間)のインスタンスから、診断が検知されたドキュメントとドキュメント上の位置を基に、修正後のSolutionを作成するのが典型的なコード修正候補の作成例となる。

 実際に記述するうえで注意が必要なこととして、Solutionをはじめとしたオブジェクト構造は不変(Immutable)オブジェクトとなっていることが挙げられる。修正後のコードを適用した新しい構造でSolutionもしくはDocumentのインスタンスを生成し、それを返り値とする必要がある。さらに、実際に修正候補となるコードは意味的に同じであればよいというわけではなく、空白や改行、カッコの位置といったフォーマット内容をできるだけ元のコードと合わせた方が使いやすい候補となる。そのため、一からコード要素を作成するのではなく、既存のコード要素の必要最小限の部分を抜き出し、適宜、置換・追加・削除して修正候補を作成するのがよいだろう。

 それでは、実際に作成する際の参考になるよう、よくあるコード修正候補の作成例をcode-cracker(オープンソースのコンパイラー拡張の一つ)から7つほど引用して示す。

不要なコードを削除するコード修正候補

 恐らく一番記述がシンプルな修正候補になるだろう。空のデストラクターを削除するコードをリスト1に引用した。

C#
private async static Task<Document> RemoveThrowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
{
  var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
  var sourceSpan = diagnostic.Location.SourceSpan;
  var finalizer = root.FindToken(sourceSpan.Start).Parent.AncestorsAndSelf().OfType<DestructorDeclarationSyntax>().First();
  return document.WithSyntaxRoot(root.RemoveNode(finalizer, SyntaxRemoveOptions.KeepNoTrivia));
}

 デストラクターのSyntax Tree(構文木)の構造は図1のようになっている。

図1 Syntax Visualizerでデストラクターを表示している様子

 デストラクターを検出対象とした場合、コード上の最初の位置にはチルダ(~)トークンがある。このトークンの親のノードを起点とし、親自身および祖先のノード一覧をたどって最初のデストラクターのノードを検出している。その次に変数rootとして取得していたドキュメント全体の構文木からデストラクターノードを、トリビア(=空白などの付加情報)を保存せずに削除している。これは図1の例でいえば「デストラクター宣言の前の空白行を含めて削除する」という意味である。RemoveNodeメソッドでノードを削除した結果は新しい構文木であるので、これをセットしたドキュメントを修正候補として返している。

 もう一つ例を見てみよう。リスト2はデフォルト値と同じ初期値(null)で初期化している「フィールドへの値の代入」を省略するコード修正候補の例だ。

C#
private async static Task<Document> RemoveAssignmentAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
{
  var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
  var variable = root.FindNode(diagnostic.Location.SourceSpan) as VariableDeclaratorSyntax;
  var newVariable = variable.WithInitializer(null).WithAdditionalAnnotations(Formatter.Annotation);
  var newRoot = root.ReplaceNode(variable, newVariable);
  var newDocument = document.WithSyntaxRoot(newRoot);
  return newDocument;
}

 この修正では代入部分(=とその右辺の値)を削除することになるが、構文木の処理としてはまず、診断を検知した位置を基に、フィールド宣言に相当するVariableDeclaratorSyntaxを取得している。その後、そのSyntaxNodeに対してnullを引数にWithInitializerメソッドを呼び出すことによって、該当するフィールド宣言の代入が行われないSyntaxNodeを取得している。図2にフィールド宣言の構文木を載せているが、WithInitializerEqualsValueClauseSyntaxを引数に取っている。もし逆にフィールドの初期値に何か値を入れるコード修正を作成したい場合は、該当するEqualsValueClauseSyntaxを指定すればよい。このようにSyntaxNodeの種類によってはAPIを呼び出すことによって必要なSyntaxNodeを生成できるため、処理対象となるSyntaxNodeのAPIをまず確認するのがよいだろう。

図2 フィールド宣言をSyntax Visualizerで見た様子
図2 フィールド宣言をSyntax Visualizerで見た様子

 さらにフォーマットするためにWithAdditionalAnnotations(Formatter.Annotation)メソッドを呼び出して取得したSyntaxNodeを、元のSyntaxNodeと入れ替えることによって、コード修正候補を生成している。このフォーマット処理を行わないと、例えば図3のように余計な空白が残った状態で修正されることになる。

図3 リスト2に引用しているコードをWithAdditionalAnnotations(Formatter.Annotation)を呼び出さないように修正した場合のコード修正候補の表示

= nullが削除されたコード修正候補が、変数名a;の間に余計な空白が入った状態になっている。

コードを追加する

 次にコードを追加する例として、switchcase文のcase節内の処理に波カッコを追加する修正候補を見てみよう(リスト3)。コード修正候補を作成する処理は、AddBracesAsyncメソッドだが、波カッコを追加する処理はAddBracesメソッドに切り出されておりこのメソッドのみを引用している。

C#
private static SwitchSectionSyntax AddBraces(SwitchSectionSyntax section)
{
  StatementSyntax blockStatement = SyntaxFactory.Block(section.Statements).WithoutTrailingTrivia();
  return section.Update(section.Labels, SyntaxFactory.SingletonList(blockStatement));
}

 case文をSyntax Visualizerで表示した様子を図4に示している。switchcase文の1つのcase節はSwitchSectionSyntaxに対応しており、さらにLabelsStatementsに分かれている。Statements全体を波カッコでくくるため、SyntaxFactory.Blockメソッドに元のStatementsを追加したBlockSyntaxを生成して、新しいStatementsに指定している。

図4 switch~case文の構文木をSyntax Visualizerで表示した様子

コードを文字列から追加する

 コードを追加する際、追加するコードが定型であるなどの理由で、追加するコードの文字列とその位置が分かりきっている場合があるだろう。その場合、SyntaxFactory.ParseStatementメソッドを使うことで、文字列から構文木を作成して、必要な場所に挿入できる。リスト4はcatch節内で捕捉した例外をthrow句の後ろに指定して再スローしている場合に、指定しない形式に変更するコード修正候補である(具体的にはthrow e;しているところをthrow;に変更する処理)。

C#
private async static Task<Document> MakeThrowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
{
  var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
  var throwStatement = root.FindToken(diagnostic.Location.SourceSpan.Start).Parent.AncestorsAndSelf().OfType<ThrowStatementSyntax>().First();
  var semanticModel = await document.GetSemanticModelAsync(cancellationToken);
  var newThrow = (ThrowStatementSyntax)SyntaxFactory.ParseStatement("throw;")
    .WithLeadingTrivia(throwStatement.GetLeadingTrivia())
    .WithTrailingTrivia(throwStatement.GetTrailingTrivia())
    .WithAdditionalAnnotations(Formatter.Annotation);
  var newRoot = root.ReplaceNode(throwStatement, newThrow);
  var newDocument = document.WithSyntaxRoot(newRoot);
  return newDocument;
}

 なお、このコード修正を行いたい理由は、C#においては同じ例外インスタンスを再度throwすると、throwした時点のスタックトレースで元のスタックトレースが上書きされてしまうためである。これを防ぐには、元の例外インスタンスを内包する新しい例外インスタンスを生成する方法もあり、引用しているクラスではこちらのコード修正も登録している。throw節を置き換えるために、"throw;"という文字列から構文木を作成している。その後、WithLeadingTriviaメソッドとWithTrailingTriviaメソッドで前後のトリヴィアを挿入前のものと同じになるようにしたうえで、WithAdditionalAnnotations(Formatter.Annotation)メソッドでフォーマットを行っている。

メソッド呼び出しを追加する

 同様に、文字列を基に追加するコードを生成する例だが、今度はConfigureAwaitメソッド呼び出しを追加する例を見てみよう。参照されて使われることが前提のライブラリでは、TaskもしくはTask<T>をawaitするときには、awaitするタスクをConfigureAwait(false)しておくのが、デッドロックを避けるための一つのデザインガイドラインとされている(詳細は、英語になるがTechEdのセッションビデオ、日本語の資料ではneuecc氏のスライドを参考にしてほしい)。このデザインパターンに倣うときに、ConfigureAwait(false)メソッドを呼ばすにawaitしているところに呼び出しを追加するコード修正候補を生成しているコードをリスト5に引用した。

C#
private async static Task<Document> CreateUseConfigureAwaitAsync(Document document, TextSpan textSpan, CancellationToken cancellationToken)
{
  var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
  var awaitExpression = (AwaitExpressionSyntax)root.FindNode(textSpan);
  var newExpression = SyntaxFactory.InvocationExpression(
    SyntaxFactory.MemberAccessExpression(
      SyntaxKind.SimpleMemberAccessExpression,
      awaitExpression.Expression,
      SyntaxFactory.IdentifierName("ConfigureAwait")),
    SyntaxFactory.ArgumentList(
      SyntaxFactory.SingletonSeparatedList(
        SyntaxFactory.Argument(
          SyntaxFactory.LiteralExpression(SyntaxKind.FalseLiteralExpression)))))
    .WithAdditionalAnnotations(Formatter.Annotation);

  var newRoot = root.ReplaceNode(awaitExpression.Expression, newExpression);
  var newDocument = document.WithSyntaxRoot(newRoot);
  return newDocument;
}

 生成しているコードの構文木が複雑なので、Syntax Visualizerで構文木を表示した図5と新しい構文木を生成している様子を図解した図6を併せて見てほしい。

図5 「await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false)」をSyntax Visualizerで表示した様子
図5 「await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false)」をSyntax Visualizerで表示した様子
図6 ConfigureAwait(false)を呼び出していないInvocationExpressionを基に、呼び出しを追加した構文木を生成する模式図

 このコード全体がAwaitExpressionであり、AwaitKeywordInvocationExpressionに分かれている。元のInvocationExpression(図6の上の水色地)を基にConfigureAwait(false)を追加した、新しいInvocationExpression(図6の下のオレンジ地)を生成して置換している。InvocationExpressionはSimpleMemberAccessExpressionArgumentListから構成されており、ArgumentListは、今回の場合は固定なのでそのまま生成している。SimpleMemberAccessExpressionは「インスタンス+アクセスするメンバー名」で構成されるが、今回の場合、インスタンスは元のInvocationExpressionとなる。そしてアクセスするメンバーがメソッド名のConfigureAwaitなので文字列からIdentifireNameSyntaxを生成している。このようにメソッド呼び出し一つを追加するのも何段階も手順を踏まないといけないが、ぜひSyntax Visualizerを活用して手順を確認してほしい。

AnalyzerからCodeFixProviderに付加情報を送信する

 基本的に、Analyzerで行うコード解析と同じことが、CodeFixProviderのコード修正候補を作成する処理でも実行できる。それでは、「どのような場合に、Analyzerで行ったコード解析で得られる情報をCodeFixProviderに送信したいか」というと、コード修正候補のメッセージにコード解析の結果から得られる情報を付加したい場合が挙げられる。RegisterCodeFixメソッドに渡すコード修正候補を生成する処理は必要なときまで遅延実行されるが、表示するメッセージは頻繁に評価される処理のため、メッセージを表示するだけのために何度も「コード解析」といった重い処理を行うことは避けた方がよい(つまり、Analyzerで行った既存のコード解析を受け取るだけの方がよい)。その例として、図7に表示されているような不要なLINQのWhereメソッドの省略を説明しよう。

図7 リスト6、リスト7に引用しているRemoveWhereWhenItIsPossibleCodeFixProviderによるコード修正の表示例

 Analyzerによるコード解析で「Whereメソッドは削除し、Firstメソッドの引数に条件式を指定すればよい」(図7では英語表記)とコード修正候補を出している。このとき表示されているメッセージの「First」という部分がそれに当たる。実際のコードをリスト6とリスト7に載せた。

C#
private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
  // ……一部省略……
  var properties = new Dictionary<string, string> { { "methodName", candidate } }.ToImmutableDictionary();
  var diagnostic = Diagnostic.Create(Rule, nameOfWhereInvoke.GetLocation(), properties, candidate);
  context.ReportDiagnostic(diagnostic);
}
C#
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
  var diagnostic = context.Diagnostics.First();
  var name = diagnostic.Properties["methodName"];
  var message = $"Remove 'Where' moving predicate to '{name}'";
  context.RegisterCodeFix(CodeAction.Create(message, c => RemoveWhereAsync(context.Document, diagnostic, c), nameof(RemoveWhereWhenItIsPossibleCodeFixProvider)), diagnostic);
  return Task.FromResult(0);
}

 Diagnostic.CreateメソッドでDiagnosticsを作成する際にPropertiesとしてキーと値のペアを指定しておく。Code Fix Action側では引数で受けたCodeFixContextの中のDiagnosticsPropertiesから指定したキーと値のペアを取得できるようになっている。

参照しているコードを含めて修正する

 メソッドやプロパティなどを変更すると、参照しているコードも修正しないとコンパイルエラーになるケースがある。その例としてメソッドを静的メソッドに変更するコード修正の例をリスト8に引用した。

C#
private static async Task<Solution> MakeMethodStaticAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken)
{
  var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
  var diagnosticSpan = diagnostic.Location.SourceSpan;
  var method = (MethodDeclarationSyntax)root.FindNode(diagnosticSpan);
  var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
  var methodSymbol = semanticModel.GetDeclaredSymbol(method);
  var references = await SymbolFinder.FindReferencesAsync(methodSymbol, document.Project.Solution, cancellationToken).ConfigureAwait(false);
  var documentGroups = references.SelectMany(r => r.Locations).GroupBy(loc => loc.Document);
  var fullMethodName = methodSymbol.GetFullName();
  var newSolution = await UpdateMainDocumentAsync(document, fullMethodName, root, method, documentGroups, cancellationToken);
  newSolution = await UpdateReferencingDocumentsAsync(document, fullMethodName, documentGroups, newSolution, cancellationToken).ConfigureAwait(false);
  return newSolution;
}

UpdateMainDocumentAsyncメソッドの実装は省略している。

 SymbolFinder.FindReferencesAsyncメソッドに参照されるシンボルと検索対象のプロジェクトを指定して、参照しているシンボル一覧を検索している。あとは参照されているメソッドを静的メソッドに変更する処理と、参照している箇所を静的メソッド呼び出しに変更する処理(例えばインスタンス.メソッドという呼び出しをしていればクラス名.静的メソッドという呼び出し方に変更するなど)をそれぞれ行っている。それぞれの詳細な実装は引用元のソースを参考にしてほしい。

 今回は、具体例としてcode-crackerのコードを引用しながら、Code Fix Actionの作り方を説明した。code-crackerはGitHubでApache 2.0ライセンスで公開されており、すぐにVisual Studioでデバッグ実行できる。そのため、Roslyn SDKの使い方を参考しつつ、自分で修正したコードを試すのも容易である。公開されているコード修正を利用するだけであれば、ソースのダウンロードは必要なく、Visual Studio拡張もしくはNuGetパッケージの追加で利用できる。ぜひ、code-crackerに触ったうえで実装方法を調べて、Code Fix Actionの作り方をより深く学んでいってほしい。

 さて次回は、連載の最終回である。設定ファイルなどの用途として、外部のファイルを拡張から扱う方法やローカライズについて説明する。

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は、以下の企業・団体の支援を受けて活動しています(募集概要)。

ゴールドレベル

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