VS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する)
- 2014-11-20
Visual Studio 2015 Previewが発表されました!この中にはC# 6.0やRoslynも含まれていて、今から試すことができます。C#の言語機能は他の人が適当にまとめてくれるので私はノータッチということで、新機能であるRoslynで拡張を作っていきましょう。
Roslynによる拡張は、ン年前に最初のPreviewが出た時は、Visual Studioの解析エンジン自体がRoslynになるから簡単にアレもコレも出来るぜ!と夢いっぱいのこと言ってましたが、実のところ最終的に現在(VS2015 Preview)ではかなり萎んでしまいました。「Code Refactoring」と「Diagnostic with Code Fix」だけです。何ができるかは、まぁ名前から察しということで、あんま大したことはできないです。がっくし。とはいえ、しかし全然使いドコロはあるし簡単に作れはするので、とにかく見て行きましょう。
下準備としてVS2015 Previewのインストールの他に、Visual Studio 2015 Preview SDKと.NET Compiler Platform SDK Templates、そして.NET Compiler Platform Syntax Visualizerを入れてください。
Diagnostic with Code Fix
今回は「Diagnostic with Code Fix」を作ります。まずテンプレートのVisual C#→Extensibilityから「Diagnostic with Code Fix(NuGet + VSIX)」を選んでください。NuGet + VSIXというのが面白いところなんですが、とりあえずこのテンプレート(はサンプルになってます)をビルドしましょう(Testプロジェクトは無視していいです)。そして、ReferencesのAnalyzers(ここがVS2015から追加されたものです!)からAdd Analyzerを選び、さっきビルドしたdllを追加してみてください。
するとコード解析が追加されて、クラス名のところにQuick Fixが光るようになります。
サンプルコードのものはMakeUpperCaseということで、クラス名に小文字が含まれていたら警告を出す&全部大文字に修正するQuickFixが有効になります。
つまりDiagnostic with Code Fixは、よーするに今までもあったCode Analysis、FxCopです。ただし、Roslynによって自由に解析でき、追加できます。また、ReferencesのAnalyzersに追加できるということで、ユーザーのVisual Studio依存ではなく、プロジェクト内に直接含めることができます。追加/インストールはdllをNuGetで配ることが可能(だからVSIX + NuGetなんですね、もちろんVSIXでも配れます)。より気軽に、よりパワフルにコード解析が作れるようになったということで、地味に中々革命的に便利なのではないでしょうか?
このまま、そのサンプルコードのMakeUpperCaseの解説、をしてもつまらないので、続けて実用的(?)なものを一個作りました。
namespaceの修正
うちの会社ではUnityを使ってモバイルゲーム開発を行っていますが、LINQもガリガリ使います。その辺のことはLINQ to GameObjectによるUnityでのLINQの活用にも書いたのですが、困ったことに標準UnityではLINQ to Objectsを使うとAOTで死にます。Unity + iOSのAOTでの例外の発生パターンと対処法で書いたように対処事態は可能なんですが、最終的に標準LINQを置き換える独自実装をSystem.LinqExネームスペースに用意することになりました。で、それを使うには「using System.LinqEx;」する必要があります。「using System.Linq;」のかわりに。むしろ「using System.Linq;」はAOTで死ぬので禁止したいし、全面的に「using System.LinqEx;」して欲しい。すみやかに。どうやって……?
そこでDiagnostic with Code Fixなんですね。既存コードの全てに検査をかけることもできるし(ソリューションエクスプローラーから対象プロジェクトを右クリックしてAnalyze→Run Code Analysis)、書いてる側からリアルタイムに警告も出せるし、ワンポチでSystem.LinqExに置き換えてくれる。このぐらいなら全ファイルから「using System.Linq;」を置換すりゃあいいだけなんですが、リアルタイムに警告してくれるとうっかり忘れもなくなるし(CIで警告すればいいといえばいいけど、その前に自分で気づいて欲しいよね)、もっと複雑な要件でも、RoslynでSyntaxTreeを弄って置き換えるので、テキスト置換のような誤爆の可能性があったり、そもそも複雑で警告/置換不能、みたいなことがなくなるので、とても有益です。
というわけで「using System.Linq;」を見つけたら「using System.LinqEx;」に書き換える拡張を作りましょう!(うちの会社にとっては)実用的で有益で、かつ、はぢめての拡張のテーマとしてもシンプルで作りやすそうでちょうどいいですね!
DiagnosticAnalyzer
コード解析はDiagnosticAnalyzer、コード置換はCodeFixProviderが担当します。必要なファイルはこの2ファイルだけ(シンプル!)、コード置換が不要ならDiagnosticAnalyzerだけ用意すればOK。というわけで、以下がDiagnosticAnalyzerのコードです。
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
namespace UseLinqEx
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class UseLinqExAnalyzer : DiagnosticAnalyzer
{
// この辺はテンプレートのままに適当に書き換え
public const string DiagnosticId = "UseLinqEx";
internal const string Title = "System.Linq is unsafe in Unity. Must use System.LinqEx.";
internal const string MessageFormat = "System.Linq is unsafe in Unity. Must use System.LinqEx."; // 同じの書いてる(テキトウ)
internal const string Category = "Usage"; // Categoryの適切なのってナンダロウ
internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
// namespaceを引っ掛ける
public override void Initialize(AnalysisContext context)
{
// なにをRegisterすればいいのか問題、テンプレではRegisterSymbolActionですが、
// SymbolActionにはなさそうだなー、と思ったら他のRegisterHogeを使いましょう
// ここではRegisterSyntaxNodeActionでSyntaxKind.UsingDirectiveを呼びます
// SyntaxKindの判定はRoslyn Syntax Visualizerに助けてもらいましょう
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.UsingDirective);
}
static void Analyze(SyntaxNodeAnalysisContext context)
{
// Nodeの中身はSyntaxKindで何を選んだかで変わるので適宜キャスト
var syntax = (UsingDirectiveSyntax)context.Node;
if (syntax.Name.NormalizeWhitespace().ToFullString() == "System.Linq")
{
var diagnostic = Diagnostic.Create(Rule, syntax.GetLocation());
context.ReportDiagnostic(diagnostic);
}
}
}
}
SupportedDiagnosticsより上のものは見た通りのコンフィグなので、まぁ見たとおりに適当に弄っておけばいいでしょう。コード本体はInitializeです。ここで、対象のノードの変更があったら起こすアクションを登録します。で、まずいきなり難しいのは、何をRegisterすればいいのか!ということだったりして。そこで手助けになるのがSyntax Visualizerです。入れましたか?入れましたよね?View -> Other Window -> Roslyn Syntax Visualizerを開くと、あとはエディタ上で選択している箇所のSyntaxTreeを表示してくれます。例えば、今回の対象であるusingの部分を選択すると「using System.Linq;」は……
と、いうわけで、たかがusingの一行ですが、めっちゃいっぱい入ってます。Node(でっかいの), Token(こまかいの), Trivia(どうでもいいの)というぐらいに覚えておけばいいでしょう(適当)。さて、というわけでusingの部分はUsingDirectiveであることが大判明しました。これ以外にもとにかくSyntaxTreeの操作は、何がどこに入ってて何を置換すればいいのかを見極める作業が必要なので、Syntax Visualizerはマストです。めっちゃ大事。めっちゃ助かる。超絶神ツール。
あとは、まぁ、見たまんまな感じで、これで警告は出してくれます。WarningじゃなくてErrorにしたいとか、Infoにしたいとかって場合はRuleからDiagnosticSeverityを変えればOK。
CodeFixProvider
続いてCodeFixProviderに行きましょう。まずはコード全体像を。
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace UseLinqEx
{
[ExportCodeFixProvider("UseLinqExCodeFixProvider", LanguageNames.CSharp), Shared]
public class UseLinqExCodeFixProvider : CodeFixProvider
{
public sealed override ImmutableArray<string> GetFixableDiagnosticIds()
{
// このDiagnosticIdでAnalyzerと起動するCodeFixProviderが紐付けられてる
return ImmutableArray.Create(UseLinqExAnalyzer.DiagnosticId);
}
public sealed override FixAllProvider GetFixAllProvider()
{
return WellKnownFixAllProviders.BatchFixer;
}
public sealed override async Task ComputeFixesAsync(CodeFixContext context)
{
// ドキュメントのルート
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
var diagnostic = context.Diagnostics.First(); // 警告だしてるとこ
var diagnosticSpan = diagnostic.Location.SourceSpan; // の、ソース上の位置みたいなの
// ↑を使って、目的のモノを見つける独自コードを書く!
// 何が何だか分からないので、ウォッチウィンドウで手探るに書きまくって探し当てるといいでしょふ
// "UsingDirectiveSyntax UsingDirective using System.Linq;" が見つかる
var usingDirective = root.FindNode(diagnosticSpan);
// で、作って登録する
var codeAction = CodeAction.Create("ReplaceTo System.LinqEx", c => ReplaceToLinqEx(context.Document, root, usingDirective, c));
context.RegisterFix(codeAction, diagnostic);
}
static Task<Document> ReplaceToLinqEx(Document document, SyntaxNode root, SyntaxNode usingDirective, CancellationToken cancellationToken)
{
// たんなるusingDirectiveでも、中にはキーワード・スペース、;や\r\nが含まれているので、
// 純粋に新しいusingを作って置換するだけだと、付加情報がうまく置換できない可能性が高い
// ので、(面倒くさくても)既存ノードからReplaceしていったほうが無難
var linqSyntax = usingDirective.DescendantNodes().OfType<IdentifierNameSyntax>().First(x => x.ToFullString() == "Linq");
var linqEx = usingDirective.ReplaceNode(linqSyntax, SyntaxFactory.IdentifierName("LinqEx"));
// ルートのほうにリプレースリプレース
var newRoot = root.ReplaceNode(usingDirective, linqEx);
var newDocument = document.WithSyntaxRoot(newRoot); // ルート差し替えでフィニッシュ
return Task.FromResult(newDocument);
}
}
}
ここでの作業は、変更対象のノードを見つけることと、差し替えることです。ノードを見つけるための下準備に関しては、とりあえずサンプルコードのまんま(diagnostic/diagnosticSpan)でいいかな、と。そこから先は独自に探し出す必要があります。今回はUsingDirectiveを見つけたかったんですが、幸いルートからのFindNode一発で済みました、楽ちん。あとは置換するだけです。
置換に関しては、コード上に書いたように、大きい単位で新しいSyntaxNodeを作って差し替える、のはやめたほうがいいです。そうするとトリビアを取りこぼす可能性が高く、うまく修正かけられなかったりします。面倒くさくても、置き換えたいものをピンポイントに絞って置換かけましょう。ノードを探索するにはLINQ to XMLスタイルでのDescendantsやAncestors、ChildNodesとかがあります。LINQ to SyntaxTreeってところで、この辺はまさにLINQ to XMLとは何であるのか。ツリー構造に対するLINQ的操作のリファレンスデザインだと捉えることができるって感じですね。
さて、置換といっても、Roslynのコードは全てイミュータブル(不変)なので、戻り値をうまく使ってルートに伝えていく必要があります。Replace一発では済まないのです。これは面倒くさいんですが、まぁ慣れればこんなものかなー、と思えるでしょう、多分きっと。
ともあれ、これで出来上がりました!ちなみにデバッグはVsixプロジェクトをデバッグ実行すれば、拡張ロード済みの新しいVSが立ち上がる&アタッチされているので、サクッとデバッグできます。これは相当楽だし助かる(いかんせん慣れないRoslynプログラムは試行錯誤しまくるので!)。また、生成物に関してはAnalyzersにdllを手配置もいいですが、ビルドプロジェクト自体に.nupkg生成が含まれているので、そいつを使ってもいいでせう。その辺のことはテンプレートに入ってるReadMe.txtに書いてあるので一回読んでおくといいかな。
Unityで使う
新しいVSが出ると拡張が対応してくれるか、が最大の懸念になるのですが、なんとVisual Studio Tools for Unity(VSTU/旧UnityVS)は初日から対応してくれました!まさにMicrosoft買収のお陰という感じで、非常に嬉しい。遠慮無くVisual Studio 2015 Preview Tools for Unityを入れましょう。VSTUについてはVisual Studio Tools for Unity(UnityVS) - Unity開発におけるVisual Studioのすすめを見てね。
基本的にはUnityのプロジェクトにも全く問題なくAnalyzerを追加できて解析できます。素晴らしい!んですが、問題が一点だけあります。それはVSTUはUnity側に何か変更があった時に.csprojを自動生成するんですが、その自動生成によってせっかく追加したAnalyzerも吹っ飛びます。Oh……。
という時のためにVSTUはProject File Generationという仕組みを用意してくれています。これによってプロジェクトとソリューションの自動生成をフックできます(ちなみに実例として、うちの会社ではソリューションにサーバーサイドとか色々なプロジェクトをぶら下げてるのでソリューション自動生成を抑制したり、Unityプロジェクト側にT4テンプレートを使った自動生成コードを入れているので、VSTUのcsprojの自動生成時に.ttファイルを復元してやったり、とか色々な処理を入れてます)
今回は自動生成で消滅するAnalyzerを復元してやる処理を書きましょう。Editor拡張として作るので、Editorフォルダ以下にProjectFileHook.csを追加し、以下のコードを追加。
using System.IO;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using UnityEditor;
[InitializeOnLoad]
public class ProjectFileHook
{
// necessary for XLinq to save the xml project file in utf8
private class Utf8StringWriter : StringWriter
{
public override Encoding Encoding
{
get { return Encoding.UTF8; }
}
}
static ProjectFileHook()
{
SyntaxTree.VisualStudio.Unity.Bridge.ProjectFilesGenerator.ProjectFileGeneration += (string name, string content) =>
{
// ファイルがない場合はスルー(初回生成時)
if (!File.Exists(name)) return content;
// 現在のcsprojをnameから引っ張ってきてAnalyzerを探す
var currentContent = XDocument.Load(name);
var ns = currentContent.Root.Name.Namespace;
var analyzers = currentContent.Descendants(ns + "Analyzer").ToArray();
// content(VSTUが生成した新しいcsprojにAnalyzerを注入)
var newContent = XDocument.Parse(content);
newContent.Root.Add(new XElement(ns + "ItemGroup", analyzers));
// したのを返す
using (var sw = new Utf8StringWriter())
{
newContent.Save(sw);
return sw.ToString();
}
};
}
}
nameにファイルパス、contentにVSTUが生成した新しいcsprojのテキストが渡ってくるので、それを使ってモニョモニョ処理。csprojはXMLなので、LINQ to XML使ってゴソゴソするのが楽ちんでしょう。
これでUnityでもRoslynパワーを100%活かせます!やったね!
まとめ
あんだけ盛大に吹聴してたわりには、コード解析とリファクタリングだけかよ……、という感はなきにしも非ずですが、そのかわりすっごく簡単に作れる、追加できる仕組みを用意してくれたのは評価できます(えらそう)。かなり便利なので、早速是非是非遊んでみるといいんじゃないかな、とオモイマス。
ところで今回の例、CodeFixProviderはナシにしてAnalyzerだけにして、AnalyzerのレベルをWarningではなくDiagnosticSeverity.Errorにすることで、「LINQ禁止」を暗黙のルールじゃなくコンパイル不可能レベルで実現できます。拡張メソッドを明示的に呼び出せば回避できますが、ルールにプラスしてEnumerableの静的メソッドも殺せば、もう完全に死亡!恐ろしい恐ろしい。あ、勿論やらないでくださいね!