Roslynで作るC#コンパイラー拡張(4)
Code Fix Actionの作り方
.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に引用した。
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のようになっている。
デストラクターを検出対象とした場合、コード上の最初の位置にはチルダ(~
)トークンがある。このトークンの親のノードを起点とし、親自身および祖先のノード一覧をたどって最初のデストラクターのノードを検出している。その次に変数root
として取得していたドキュメント全体の構文木からデストラクターノードを、トリビア(=空白などの付加情報)を保存せずに削除している。これは図1の例でいえば「デストラクター宣言の前の空白行を含めて削除する」という意味である。RemoveNode
メソッドでノードを削除した結果は新しい構文木であるので、これをセットしたドキュメントを修正候補として返している。
もう一つ例を見てみよう。リスト2はデフォルト値と同じ初期値(null)で初期化している「フィールドへの値の代入」を省略するコード修正候補の例だ。
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にフィールド宣言の構文木を載せているが、WithInitializer
はEqualsValueClauseSyntax
を引数に取っている。もし逆にフィールドの初期値に何か値を入れるコード修正を作成したい場合は、該当するEqualsValueClauseSyntax
を指定すればよい。このようにSyntaxNodeの種類によってはAPIを呼び出すことによって必要なSyntaxNodeを生成できるため、処理対象となるSyntaxNodeのAPIをまず確認するのがよいだろう。
さらにフォーマットするためにWithAdditionalAnnotations(Formatter.Annotation)
メソッドを呼び出して取得したSyntaxNodeを、元のSyntaxNodeと入れ替えることによって、コード修正候補を生成している。このフォーマット処理を行わないと、例えば図3のように余計な空白が残った状態で修正されることになる。
= null
が削除されたコード修正候補が、変数名a
と;
の間に余計な空白が入った状態になっている。
コードを追加する
次にコードを追加する例として、switch
~case
文のcase
節内の処理に波カッコを追加する修正候補を見てみよう(リスト3)。コード修正候補を作成する処理は、AddBracesAsync
メソッドだが、波カッコを追加する処理はAddBraces
メソッドに切り出されておりこのメソッドのみを引用している。
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に示している。switch
~case
文の1つのcase
節はSwitchSectionSyntax
に対応しており、さらにLabels
とStatements
に分かれている。Statements全体を波カッコでくくるため、SyntaxFactory.Block
メソッドに元のStatementsを追加したBlockSyntax
を生成して、新しいStatementsに指定している。
コードを文字列から追加する
コードを追加する際、追加するコードが定型であるなどの理由で、追加するコードの文字列とその位置が分かりきっている場合があるだろう。その場合、SyntaxFactory.ParseStatement
メソッドを使うことで、文字列から構文木を作成して、必要な場所に挿入できる。リスト4はcatch
節内で捕捉した例外をthrow
句の後ろに指定して再スローしている場合に、指定しない形式に変更するコード修正候補である(具体的にはthrow e;
しているところをthrow;
に変更する処理)。
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に引用した。
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を併せて見てほしい。
このコード全体がAwaitExpression
であり、AwaitKeyword
とInvocationExpression
に分かれている。元のInvocationExpression(図6の上の水色地)を基にConfigureAwait(false)
を追加した、新しいInvocationExpression(図6の下のオレンジ地)を生成して置換している。InvocationExpressionはSimpleMemberAccessExpression
とArgumentList
から構成されており、ArgumentListは、今回の場合は固定なのでそのまま生成している。SimpleMemberAccessExpressionは「インスタンス+アクセスするメンバー名」で構成されるが、今回の場合、インスタンスは元のInvocationExpressionとなる。そしてアクセスするメンバーがメソッド名のConfigureAwait
なので文字列からIdentifireNameSyntax
を生成している。このようにメソッド呼び出し一つを追加するのも何段階も手順を踏まないといけないが、ぜひSyntax Visualizerを活用して手順を確認してほしい。
AnalyzerからCodeFixProviderに付加情報を送信する
基本的に、Analyzerで行うコード解析と同じことが、CodeFixProviderのコード修正候補を作成する処理でも実行できる。それでは、「どのような場合に、Analyzerで行ったコード解析で得られる情報をCodeFixProviderに送信したいか」というと、コード修正候補のメッセージにコード解析の結果から得られる情報を付加したい場合が挙げられる。RegisterCodeFix
メソッドに渡すコード修正候補を生成する処理は必要なときまで遅延実行されるが、表示するメッセージは頻繁に評価される処理のため、メッセージを表示するだけのために何度も「コード解析」といった重い処理を行うことは避けた方がよい(つまり、Analyzerで行った既存のコード解析を受け取るだけの方がよい)。その例として、図7に表示されているような不要なLINQのWhere
メソッドの省略を説明しよう。
Analyzerによるコード解析で「Where
メソッドは削除し、First
メソッドの引数に条件式を指定すればよい」(図7では英語表記)とコード修正候補を出している。このとき表示されているメッセージの「First」という部分がそれに当たる。実際のコードをリスト6とリスト7に載せた。
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);
}
|
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の中のDiagnostics
のProperties
から指定したキーと値のペアを取得できるようになっている。
参照しているコードを含めて修正する
メソッドやプロパティなどを変更すると、参照しているコードも修正しないとコンパイルエラーになるケースがある。その例としてメソッドを静的メソッドに変更するコード修正の例をリスト8に引用した。
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回。
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のメッセージをローカライズする方法について説明する。連載最終回。