VS2015+RoslynによるCodeRefactoringProviderの作り方と活用法

この記事はC# Advent Calendar 2014のための記事になります。私は去年のAdvent Calendarでは非同期時代のLINQというものを書いていました、うん、中々良い記事であった(自分で言う)。今年のテーマはRoslynです。

先月にVS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する)という記事を書きましたが、VS2015 PreviewではRoslynで作る拡張にもう一つ、Code Refactoringがあります。こちらも簡単に作れて、中々ベンリなので(前にVS2015のRoslynは以前から後退して「あんま大したことはできない」と言いましたが、それはそれでかなり役立ちです)、是非作っていきましょう。Code Analyzerと同じく非常に簡単に作れるのと、テンプレートがよくできていてちゃんとガイドになってるので、すんなり入れるかと思います。なお、こちらはCode Analyzerと違いNuGet配布やプロジェクト単位での参照は不可能、VSIXのみ。そこはちょっと残念……。

Code Refactoring

下準備としてはCode Analyzerの時と同じくVisual Studio 2015 Previewのインストールの他に、Visual Studio 2015 Preview SDK.NET Compiler Platform SDK Templates、そして.NET Compiler Platform Syntax Visualizerを入れてください。

さて、まずテンプレートのVisual C#→Extensibilityから「Code Refactoring(VSIX)」を選びます。とりあえずこのテンプレート(がサンプルになってます)をCtrl+F5で実行しましょう。これで立ち上がるVSは通常のVSに影響を及ぼさず自作拡張がインストールされる特殊なインスタンスになってます(devenv.exeを引数「/rootsuffix Roslyn」で立ち上げてる、のがDebugのとこで確認できる)。というわけで、このテンプレートの拡張によりクラス名をCtrl+.することにより

クラス名が逆になる、という(実にどうでもいい)機能拡張が追加されました!と、いうわけで、Code RefactoringはCode Analyzerと同じく「Light Bulb」によるCode Actionが実装可能になります。Code AnalyzerはDiagnosticが起点でしたが、こちらは、指し示された位置を起点にコードの削除/追加/変更を行えるという感じ。「リファクタリング」というとコード修正のイメージが個人的には強いんですが、RoslynのCode Refactoringはどちらかというと「コード生成」に使えるな、という印象です。例えばプロパティを選択してCode RefactoringでINotifyPropertyChangedのコードに展開してしまうとか。大量に作る場合、一個一個コードスニペットで作るより、そっちのほうが速く作れそうですよね?など、色々使い手はあるでしょふ。結構可能性を感じるし、良い機能だと思っています(それR#で今までも出来たよ!とかそれEclipseで既に!とか言いたいことはあるかもしれませんが!)

ただまあ、Code Refactoringって名前は好きじゃない。けれど、じゃあ何がいいかっていうと、なんでしょうねぇ。Code Generate、ジェネレートだけじゃないから、まぁCode Actionかなぁ。AnalyzerもCode Actionだから区別付かなくて嫌だって可能性もあるか、うーん、うーん、ま、いっか……。

ArgumentNullExceptionProvider

サンプルコードがたった1ファイルのように、作るのはとても簡単です。CodeRefactoringProviderを継承してComputeRefactoringsAsyncを実装する、だけ。適当にシンタックスツリーを探索して、もしLight Bulbを出したければcontext.RegisterRefactoringにCodeActionを追加する、と。

というわけで早速何か一個作ってみましょう。実用的なのがいいなぁ、ということでnullチェック、if(hoge == null) throw new ArgumetNullException(); というクソ面倒くさい恒例のアレを自動生成しよう!絶対使うし、あるとめちゃくちゃ捗りますものね!

上の画像のが完成品です。便利そう!便利そう!

さて、まずはLight Bulbをどこで出したいか。メソッドの引数で出すんで、引数を選択してたらそれは対象にしたいかなぁ?あと、一々選択するのも面倒だから、メソッド名でも出しましょうか。ふむ、とりあえず実装が簡単そうなメソッド名だけで行きましょう。何事も作る時は単純なところから広げていくのが一番、特に初めてのものはね。

// ArgumentNullExceptionProviderという名前でプロジェクト作ったらクラス名が酷いことに、その辺はちゃんと調整しましょふ
[ExportCodeRefactoringProvider(ArgumentNullExceptionProviderCodeRefactoringProvider.RefactoringId, LanguageNames.CSharp), Shared]
internal class ArgumentNullExceptionProviderCodeRefactoringProvider : CodeRefactoringProvider
{
    public const string RefactoringId = "ArgumentNullExceptionProvider";

    public sealed override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
    {
        // とりあえずコード全体を取る(これはほとんど定形)
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        
        // SemanticModel(コードをテキストとしてではなく意味を持ったモデルとして取るようにするもの、これもほぼ必須)
        var model = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
        
        // context.Spanが選択位置、ということで選択位置のコードを取る(この辺もほぼ定形かな)
        // もし選択範囲に含まれてるものから一部のものを取り出す、とかならroot.DescendantNodes(context.Span).OfType<XxxSyntax>() という手とか色々
        var node = root.FindNode(context.Span);
        
        // メソッド定義じゃなかったら無視
        var methodDecl = node as MethodDeclarationSyntax;
        if (methodDecl == null) return;
        
        // コード生成作る
        var action = CodeAction.Create("Generate ArgumentNullException", c => GenerateArgumentNullException(context.Document, model, root, methodDecl, c));
        
        // とりあえず追加
        context.RegisterRefactoring(action);
    }

    async Task<Document> GenerateArgumentNullException(Document document, SemanticModel model, SyntaxNode root, MethodDeclarationSyntax methodDecl, CancellationToken cancellationToken)
    {
        // あとで書く:)
        return document;
    }
}

まずはこんなとこですね、ようはサンプルからClassDeclarationSyntaxをMethodDeclarationSyntaxに変えただけ + CodeActionを作るところの引数にSemanticModelとrootのSyntaxNodeを足してあります。この辺はほとんど定形で必要になってくるので、とりあえず覚えておくといいでしょう。さて、一旦こいつで実行してみて、ちゃんとLight Bulbが思ったところに出るか確認してから次に行きましょー。続いて本題のコード生成部分。

Task<Document> GenerateArgumentNullException(Document document, SemanticModel model, SyntaxNode root, MethodDeclarationSyntax methodDecl, CancellationToken cancellationToken)
{
    // 引数はParameterListから取れる。
    // この辺はVSでMethodDeclarationSyntaxをF12で飛んで、metadataからそれっぽいのを探しだすといいんじゃないかな?
    // ドキュメントがなくてもVisual StudioとIntelliSenseがあれば、なんとなく作れてしまうのがC#のいいところだからね!
    var parameterList = methodDecl.ParameterList;

    // ただのType(TypeSyntax)はコード上のテキスト以上の意味を持たない、
    // そこからstructかclassか、など型としての情報を取るにはSemanticModelから照合する必要がある
    var targets = parameterList.Parameters
        .Where(x =>
        {
            var typeSymbol = model.GetTypeInfo(x.Type).Type;
            return typeSymbol != null && typeSymbol.IsReferenceType;
        });

    // C#コードを手組みするのは(Trivia対応とか入れると)死ぬほど面倒なのでParseする
    var statements = targets.Select(x =>
    {
        var name = x.Identifier.Text;
        // String Interpolationベンリ(ただし文法はまだ変更される模様……)
        return SyntaxFactory.ParseStatement("if (\{name} == null) throw new ArgumentNullException(nameof(\{name}));");
    }).ToArray();

    // 追加、メソッドBodyはBody以下なのでそこの先頭に(AddStatementsだと一番下に置かれてしまうのでダメ、nullチェックは「先頭」にしたい)
    var newBody = methodDecl.Body.WithStatements(methodDecl.Body.Statements.InsertRange(0, statements));

    // 入れ替え
    var newRoot = root.ReplaceNode(methodDecl.Body, newBody);
    var newDocument = document.WithSyntaxRoot(newRoot);

    return Task.FromResult(newDocument);
}

SemanticModelがキーです。SyntaxTreeから取ってきただけのものは、何の情報も持ってません、ほんとただのコード上の字面だけです。今回で言うとnullチェックしたいのは参照型だけですが、それを識別することが出来ません。そこからTypeInfoを取り出すことができるのがSemanticModelになります。例えば「Dictionary」から「System.Collections.Generic.Dictionary」といったフルネームを取り出したりなど、とかくコード操作するには重要です。今回はこれでIsReferenceTypeを引き出しています。

あとは、コードの手組みは辛すぎるのでSyntaxFactory.ParseXxxを活用して済ませちゃうのは楽です。その後は、rootからReplaceと、documentから丸っと差し替えなどは定形ですね。あ、そうそう、あとnameofはC# 6.0の新機能です。ベンリベンリ。

では、実際使ってみると……。

// これが
static void Hoge(string[] a, string b, Dictionary<int, int> huga, int tako)
{
}

// こうなる、あ、れ……?
static void Hoge(string[] a, string b, Dictionary<int, int> huga, int tako)
{
if (a == null) throw new ArgumentNullException(nameof(a));if (b == null) throw new ArgumentNullException(nameof(b));if (huga == null) throw new ArgumentNullException(nameof(huga));}

はい。ちゃんと機能するコードができてはいます。が、なんじゃこりゃーーーーー。なんでかっていうとTrivia(空白とか改行)が一切考慮されてないから、なんですね。Code Refactoringを作る上でメンドウクサイのは、こうしたTriviaへの考慮です。置換なら既存のTriviaをそのまま使えるんですが、コード追加系だと、自分でTrivia入れたり削ったりの調整しないと見れたもんじゃなくなります。というわけで、こっから先が地獄……。まぁ、頑張りましょう。さすがにそのままだと使えないので、調整しましょう。

まずは改行です。改行は結構簡単で、(大抵の場合)TrailingTrivia(後方のTrivia(空白や改行など))にCRLFを仕込むだけ。

// statementsのところをこう変更すると
var statements = targets.Select(x =>
{
    var name = x.Identifier.Text;
    var statement = SyntaxFactory.ParseStatement("if (\{name} == null) throw new ArgumentNullException(nameof(\{name}));");
    // TrailingTriviaは行末、というわけで改行を仕込む
    return statement.WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
})
.ToArray();

        // 置換結果はこうなります、だいぶ良くなった!
        static void Hoge(string[] a, string b, Dictionary<int, int> huga, int tako)
        {
if (a == null) throw new ArgumentNullException(nameof(a));
if (b == null) throw new ArgumentNullException(nameof(b));
if (huga == null) throw new ArgumentNullException(nameof(huga));
        }

だいぶいい線いってますね!このぐらいできれば、あとは実行後にCtrl+K, D(ドキュメントフォーマット)押してね、で済むんで全然妥協ラインです。が、もう少し完璧にしたいならインデントも挟みましょうか。インデントの量は直前の{から引っ張ってきて調整してみましょふ。

// WithLeadingTriviaの追加
var statements = targets.Select(x =>
{
    var name = x.Identifier.Text;
    var statement = SyntaxFactory.ParseStatement("if (\{name} == null) throw new ArgumentNullException(nameof(\{name}));");

    // LeadingTriviaに「{」のとこのインデント + 4つ分の空白を入れる
    return statement
        .WithLeadingTrivia(methodDecl.Body.OpenBraceToken.LeadingTrivia.Add(SyntaxFactory.Whitespace("    ")))
        .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
})
.ToArray();

        // 置換結果はこうなります、完璧!
        static void Hoge(string[] a, string b, Dictionary<int, int> huga, int tako)
        {
            if (a == null) throw new ArgumentNullException(nameof(a));
            if (b == null) throw new ArgumentNullException(nameof(b));
            if (huga == null) throw new ArgumentNullException(nameof(huga));
        }

こんなところですね。さて、勿論このコードはOpenBraceTokenが改行された位置にあることを前提にしているので、後ろに{を入れるスタイルのコードには適用できません。また、空白4つをインデントとして使うというのが決め打ちされています。また、なんども実行しても大丈夫なように既にthrow new ArgumentNullExceptionが記述されてる引数は無視したいよねえ、などなど、完璧を求めるとキリがありません。キリがないということは、適当なところでやめておくのが無難ということです、適度な妥協大事!

フォーマットする

とはいえ、フォーマットはもう少しきちんとやりたいところです。実は簡単にやる手段が用意されていて、.WithAdditionalAnnotations(Formatter.Annotation) を呼ぶことで、その部分だけフォーマットがかかります。正確にはフォーマットが可能になるタイミングでフォーマットがかかるようになります、どういうことかというと、例えばインデントのフォーマットは前後のコード情報がなければかけることは出来ません。このコード例でいうとif()...は前後空白もない完全一行だけなのでフォーマットもなにもできない。なのでAnnotationのついたコード片がフォーマット可能なドキュメントにくっついたタイミングで自動でかかるようになります。AnnotationはRoslynの構文木内でのみ使われるオプション情報とでも思ってもらえれば。

var statements = targetParameters
    .Select(x =>
    {
        var name = x.Identifier.Text;
        var statement = SyntaxFactory.ParseStatement("if (\{name} == null) throw new ArgumentNullException(nameof(\{name}));");

        // Formatter.Annotationつけてフォーマット
        return statement
            .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)
            .WithAdditionalAnnotations(Formatter.Annotation);
    })
    .ToArray();

これは簡単でイイですね!!!なのでTriviaの付与は、最低限のCRLFぐらいを部分的に入れるだけでOK。これは、これからもめちゃくちゃ多用するのではかと思われます。他にAnnotationにはSimplifier.Annotationなどが用意されてます。

フルコード

最初に妥協した(?)引数を選択してたらそれも対象に、ってコードも入れましょうか。というのを含めたフルコードは以下になります。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using System.Collections.Generic;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Grani.RoslynTools
{
    [ExportCodeRefactoringProvider(GenerateArgumentNullExceptionCodeRefactoringProvider.RefactoringId, LanguageNames.CSharp), Shared]
    internal class GenerateArgumentNullExceptionCodeRefactoringProvider : CodeRefactoringProvider
    {
        public const string RefactoringId = "GenerateArgumentNullException";

        public sealed override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
        {
            // とりあえずコード全体を取る(これはほとんど定形)
            var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

            // SemanticModel(コードをテキストとしてではなく意味を持ったモデルとして取るようにするもの、これもほぼ必須)
            var model = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);

            // context.Spanが選択位置、ということで選択位置のコードを取る(この辺もほぼ定形かな)
            var node = root.FindNode(context.Span);

            // パラメータリストの場合はそれだけ、クラス名だったらその定義の全部の引数を取る
            MethodDeclarationSyntax methodDecl;
            IEnumerable<ParameterSyntax> selectedParameters;
            if (node is MethodDeclarationSyntax)
            {
                methodDecl = (MethodDeclarationSyntax)node;
                selectedParameters = methodDecl.ParameterList.Parameters;
            }
            else
            {
                // 単品選択のばやい(名前の部分選択でParameterSyntax、型の部分選択でParentがParameterSyntax)
                if (node is ParameterSyntax || node.Parent is ParameterSyntax)
                {
                    var targetParameter = (node as ParameterSyntax) ?? (node.Parent as ParameterSyntax);
                    // 親方向にMethodDeclarationSyntaxを探す
                    methodDecl = targetParameter.Ancestors().OfType<MethodDeclarationSyntax>().FirstOrDefault();
                    selectedParameters = new[] { targetParameter };
                }
                else
                {
                    // 選択範囲から取り出すばやい
                    var parameters = root.DescendantNodes(context.Span).OfType<ParameterSyntax>().ToArray();
                    if (parameters.Length == 0) return;
                    methodDecl = parameters[0].Ancestors().OfType<MethodDeclarationSyntax>().FirstOrDefault();
                    selectedParameters = parameters;
                }
            }
            if (methodDecl == null) return;

            // ただのType(TypeSyntax)はコード上のテキスト以上の意味を持たない、
            // そこからstructかclassか、など型としての情報を取るにはSemanticModelから照合する必要がある
            var replaceTargets = selectedParameters
                .Where(x =>
                {
                    var typeSymbol = model.GetTypeInfo(x.Type).Type;
                    // ジェネリック型で型引数がclassでstructでもない場合はIsXxxが両方false、これはif(xxx == null)の対象にする
                    return typeSymbol != null && typeSymbol.IsReferenceType || (!typeSymbol.IsReferenceType && !typeSymbol.IsValueType);
                })
                .ToArray();

            if (replaceTargets.Length == 0) return;

            // コード生成作る(nameof利用の有無で2つ作ってみたり)
            var action1 = CodeAction.Create("Generate ArgumentNullException", c => GenerateArgumentNullException(context.Document, root, methodDecl, replaceTargets, true, c));
            var action2 = CodeAction.Create("Generate ArgumentNullException(unuse nameof)", c => GenerateArgumentNullException(context.Document, root, methodDecl, replaceTargets, false, c));

            // 追加
            context.RegisterRefactoring(action1);
            context.RegisterRefactoring(action2);
        }
        
        Task<Document> GenerateArgumentNullException(Document document, SyntaxNode root, MethodDeclarationSyntax methodDecl, ParameterSyntax[] targetParameters, bool useNameof, CancellationToken cancellationToken)
        {
            // nameof版と非nameof版を用意
            var template = (useNameof)
                ? "if ({0} == null) throw new ArgumentNullException(nameof({0}));"
                : "if ({0} == null) throw new ArgumentNullException(\"{0}\");";

            var statements = targetParameters
                .Select(x =>
                {
                    // C#コードを手組みするのは(Trivia対応とか入れると)死ぬほど面倒なのでParseする
                    var name = x.Identifier.Text;
                    var statement = SyntaxFactory.ParseStatement(string.Format(template, name));

                    // Formatter.Annotationつけてフォーマット
                    return statement
                        .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)
                        .WithAdditionalAnnotations(Formatter.Annotation);
                })
                .ToArray();

            // 追加
            var newBody = methodDecl.Body.WithStatements(methodDecl.Body.Statements.InsertRange(0, statements));

            // 入れ替え
            var newRoot = root.ReplaceNode(methodDecl.Body, newBody);
            var newDocument = document.WithSyntaxRoot(newRoot);

            return Task.FromResult(newDocument);
        }
    }
}

まずLight Bulbを出すためのチェックがFindNodeだけでは済まなくなるので、root.DescendantNodes(context.Span)が活躍します。あとは、もう、色々もしゃもしゃと。とにかくコーナーケース探していくとかなり面倒くさかったり。例えば、引数の型の部分を選択した時と、名前の部分を選択した時の対処、などなど……。しょうがないけれどね。

それと、もしメソッドの中に参照型の引数がなかったらLight Bulbを出さないようにするため、replaceTargetsの生成をRegisterRefactoringの前に変更しています。それと、ジェネリックな型引数の場合で制約がついてない状態も生成対象に含めるよう微調整。といったような、この辺の細かい調整はある程度出来上がってからやってくのが良いでしょうねー。

Portable?

ところで、CodeRefactoringにせよAnalyzerにせよ、テンプレートではコア部分はPCLで生成されています。ということは、実は、System.IOとか使えない。これ、ちょっと色々な邪道な操作したい時に不便なんですよ……。ていうかVisual Studio前提なんだからPCLである必要ないじゃん!いみわからない、なんでもPCLって言っておけばいいってもんでもないでしょう!あー、もう私はPCL大嫌いだよぅー。しかもPCLで生成されたプロジェクトは普通のプロジェクトタイプには簡単には戻せないんですよ、うわぁ……。

まあcsprojを手で書き換えればできます。やりかたは

<!-- こいつを消す -->
<ProjectTypeGuids>{786C830F-07A1-408B-BD7F-6EE04809D6DB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>

<!-- こいつを消す -->
<TargetFrameworkProfile>Profile7</TargetFrameworkProfile>

<!-- こいつを消して -->
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\Portable\$(TargetFrameworkVersion)\Microsoft.Portable.CSharp.targets" />
<!-- かわりにこれを追加 -->
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

なんだかなぁー。

ビルトイン拡張

VS2015ではビルトインで幾つかCode Refactoringが入っています。が、ノーヒント(Ctrl+.を押すまではLight Bulbが出ない)なので、宝探し状態です!例えばプロパティを選択すると「Generate Constructor」が出てきたり。まぁ、これはそのうちリストが公開されるでしょう。

まとめ

拡張を作るのは簡単!うーん、んー、簡単?まぁ簡単!少なくとも今までよりは比較的遥かに簡単カジュアルに作れるようになりました。こうした自動生成って、一般的なものだけではなく、プロジェクト固有で必要になるものもあると思います。例えばうちの会社ではシリアライザにprotobuf-netを使っているので、クラスのプロパティの各属性にDataMemberをつけて回る必要がある。ちゃんとOrderの順番を連番にして。でも手作業は面倒、そこで、CodeRefactoring。

こんな風にプロパティを選択してCtrl+.でほいっと生成できる。このコードはちゃんと属性の振り直しとかも配慮してある(場合によって追加、場合によって置き換え、とかのコードを書くのはやっぱり結構面倒!フルコードはneuecc/AddDataMemberWithOrderCodeRefactoringProvider.csに置いておきます、使いたい方 もしくは CodeRefactoring作りの参考にどうぞ)

こうしたプロジェクト固有の必要な自動生成のためのコードがサクッと作れるようになったのは地味に革命的かなぁ、なんて思ってます。どんどん活用されていくといいですね、少なくともうちの会社(グラニ)では社内用に皆で色々作って配布していこうかなあと計画しています(ところでグラニはまだまだアクティブにエンジニアの求人してます!最先端のC#に興味のある方は是非是非どうぞ、「今から」VS2015を使い倒しましょう)。

AnalyzerもCode Refactoringも、すっごくミニマムな仕様に落ち着いていて、限りなく削っていった形なんだろうなぁ、と想像します。すごく小さくて、でも、すごく使い手がある。いい落とし所だと思ってます。あとはもう活用するもしないも全て自分達次第。とにかくまずは、是非触ってみてください。可能性を感じましょう。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive