ぷろじぇくと、みすじら。

RoslynをT4テンプレート内で使う

Created at:

Visual StudioにはT4テンプレートというソースコードを生成する機能があるのですが、その中からRoslynを使おうというお話です。

T4テンプレートはVisual Studioと統合されていることとC#でテンプレートのコードを書いて、C#, VB, TypeScriptなどのコードを吐き出せます。

例えば…

のような感じで、アセンブリを読み込んでコードを生成するという使い方はよく見かけます(プロキシコードの生成とか)。

アセンブリを読み込んでコードを生成するということは「アセンブリがコンパイルされている」ことが前提になります。

例えばプロキシクラスみたいなものはそれで問題ないので、大体はそれでいいのですが「元になるコードと生成したコードを同一のアセンブリに含めたい」場合にはニワトリ卵というか「元になるコードをコンパイル」「コンパイル結果をもとにコード生成」「コード生成を含めてコンパイル」という手順が必要です。

もちろんリフレクションとかファクトリとか登録する式にすればコード生成いらないよね?という場合もあると思いますが、そこはまあ要件しだいというやつです。生成したいときもある、はず…。

例えばEnvDTEを避ける

そんなわけで「コンパイルしてできたアセンブリを元にコードを生成」するのではなく、「現在のコードを元にコードを生成」したいというときはどうするのかというとコードの構造を読み取るためにVisual Studioにアクセスする方法をとります。

一般的にT4テンプレートからVisual StudioにアクセスするにはEnvDTEというかっこいいオブジェクトモデルと仲良くすることになります。EnvDTEはVisual Studioの機能を公開するCOMライブラリなのなので、絶妙な使いづらさがあります。何よりEnvDTEはVisual Studioにくっついている都合、ConsoleApplicationやLINQPadなどで試しに書いてみるというのが難易度高いです。

まあサンプルコードを書く気にならない程度には面倒だということです。

そこでVisual Studio 2015以降ではコンパイラー基盤がRoslynになっているのでそれを使えばいいのではというのが今回のアイデアです。

T4の中でRoslynを使ってコードを読み込む

さてどうやってRoslynを使って読み込むのかという話ですが、残念なことにVisual Studio 2015の内部で「現在動いているRoslynのインスタンス」に直接アクセスすることはできません。

ですが、Visual Studio 2015に含まれているRoslynを使うことはできます。Roslynは C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\IDE\PrivateAssemblies におかれているので、それらしいアセンブリを参照して名前空間をインポートします。

<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.CSharp.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.Workspaces.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.Workspaces.Desktop.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\System.Collections.Immutable.dll" #>

<#@ import namespace="Microsoft.CodeAnalysis" #>
<#@ import namespace="Microsoft.CodeAnalysis.CSharp" #>
<#@ import namespace="Microsoft.CodeAnalysis.CSharp.Syntax" #>
<#@ import namespace="Microsoft.CodeAnalysis.MSBuild" #>

これで使えるようになります!

あとは単純にRoslynを使ってソリューションを読み込んで、コードを解析すればいい感じに処理できるでしょう。

と言ってもいきなりT4でRoslynプログラミングはそれはそれで難しいのでConsoleApplicationかLINQPadで書いてみるのがよいでしょう。

T4の外でコードを書いてみる

とりあえずどこかのC#プロジェクトにあるクラスの一覧を適当に出力する、というコードを書いてみることにします。Roslynのコードの詳しいことは今回割愛しますので別途調べてください。

というわけでLINQPadやConsoleApplicationで動きそうな単純なコードは以下のようになります。

void Main()
{
	// 対象となるプロジェクトの.csprojのパス
	var projPath = @"C:\Users\Tomoyo\Documents\visual studio 2015\Projects\ConsoleApplication1\ConsoleApplication1\ConsoleApplication1.csproj";
	var classNames = GetClassNamesAsync(projPath).Result;
	classNames.Dump();
}
	
// Roslynを使ってプロジェクトのコードからクラスのコンストラクタ定義を引っこ抜く
async Task<List<string>> GetClassNamesAsync(string csprojPath)
{
	// MSBuildのワークスペース(環境みたいなもの)を作って、プロジェクトファイルを開く
	var workspace = MSBuildWorkspace.Create();
	var project = await workspace.OpenProjectAsync(csprojPath);

	// ソースコードをコンパイル的なことをする(出力するわけではなくて内部的なデータを作るやつ)
	// これでコードをパースした結果を得ることができるようになる
	var compilation = await project.GetCompilationAsync();

	var classNames = new List<string>();
	// シンタックスツリーをファイル単位で適当になめていく
	foreach (var syntaxTree in compilation.SyntaxTrees)
	{
		// セマンティックモデル(シンタックスツリーは文法で、それに対応する「コード的な意味」)を取得する
		var semModel = compilation.GetSemanticModel(syntaxTree);

		// シンタックスツリーからクラス定義のシンタックス(記述)を引っ張り出して、
		// セマンティックモデルに問い合わせることでクラス定義(意味)を引っ張り出して、ふにゃふにゃ処理する。
		classNames.AddRange(
			syntaxTree.GetRoot()
				.DescendantNodes()
				.OfType<ClassDeclarationSyntax>()
				.Select(x => semModel.GetDeclaredSymbol(x))
				.Select(x => x.ToDisplayString())
		);
	}

	return classNames;
}

実行結果はこんな感じ。

LINQPad

なおこのコードはMicrosoft.CodeAnalysis.CSharp.WorkspaceというNuGetパッケージをインストールすれば動くでしょう。

T4テンプレートの中で使う

で、これとT4テンプレートの中で使うには…という話ですが、先ほどのimportに加えて、適当にコピペしても大体動くはずです。例えば以下のように。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Runtime" #>
<#@ assembly name="System.Threading.Tasks" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.CSharp.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.Workspaces.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\Microsoft.CodeAnalysis.Workspaces.Desktop.dll" #>
<#@ assembly name="$(DevEnvDir)PrivateAssemblies\System.Collections.Immutable.dll" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Threading.Tasks" #>
<#@ import namespace="Microsoft.CodeAnalysis" #>
<#@ import namespace="Microsoft.CodeAnalysis.CSharp" #>
<#@ import namespace="Microsoft.CodeAnalysis.CSharp.Syntax" #>
<#@ import namespace="Microsoft.CodeAnalysis.MSBuild" #>
<#@ output extension=".txt" #>
<#
	// 対象となるプロジェクトの.csprojのパス
	var projPath = @"C:\Users\Tomoyo\Documents\visual studio 2015\Projects\ConsoleApplication1\ConsoleApplication1\ConsoleApplication1.csproj";
	var classNames = GetClassNamesAsync(projPath).Result;
#>

<# foreach (var className in classNames) {
#>- <#= className #>
<# } #>

<#+

// Roslynを使ってプロジェクトのコードからクラスのコンストラクタ定義を引っこ抜く
async Task<List<string>> GetClassNamesAsync(string csprojPath)
{
	// MSBuildのワークスペース(環境みたいなもの)を作って、プロジェクトファイルを開く
	var workspace = MSBuildWorkspace.Create();
	var project = await workspace.OpenProjectAsync(csprojPath);

	// ソースコードをコンパイル的なことをする(出力するわけではなくて内部的なデータを作るやつ)
	// これでコードをパースした結果を得ることができるようになる
	var compilation = await project.GetCompilationAsync();

	var classNames = new List<string>();
	// シンタックスツリーをファイル単位で適当になめていく
	foreach (var syntaxTree in compilation.SyntaxTrees)
	{
		// セマンティックモデル(シンタックスツリーは文法で、それに対応する「コード的な意味」)を取得する
		var semModel = compilation.GetSemanticModel(syntaxTree);

		// シンタックスツリーからクラス定義のシンタックス(記述)を引っ張り出して、
		// セマンティックモデルに問い合わせることでクラス定義(意味)を引っ張り出して、ふにゃふにゃ処理する。
		classNames.AddRange(
			syntaxTree.GetRoot()
				.DescendantNodes()
				.OfType<ClassDeclarationSyntax>()
				.Select(x => semModel.GetDeclaredSymbol(x))
				.Select(x => x.ToDisplayString())
		);
	}

	return classNames;
}
#>

で、このT4テンプレートを実行すると、以下のようにクラス名の一覧が記述されたファイルが吐き出されます。

- ConsoleApplication1.Hauhau
- ConsoleApplication1.Program
- ConsoleApplication1.Hoge
- ConsoleApplication1.Fuga

とても簡単ですね。

まとめ

大抵の場合においてはアセンブリを直接読み込めばいいと思いますが、もし「現在のコードを元にプロジェクトに含めるコードを生成する」という必要がある場面では役に立つのではないでしょうか。

またこの組み合わせで書いてみるとわかるのですがLINQPadでRoslynでソースコードから情報を集約するコードを書いて、それを元にジェネレートする、という手順はテンプレートからロジックの一部を切り出して実装でき、デバッグしやすいのでとてもおすすめです。