.NET+VS Codeでもデザイン時 T4 テキスト テンプレートを使いたかった

## TL;DR
- C#に追加された[ソース ジェネレーター](https://docs.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/source-generators-overview)機能を使って「デザイン時 T4 テキスト テンプレート」を再現してみたかった
  - C# ソースファイル(.cs)以外からソースコード生成する方法を解説
- が、`System.CodeDom`絡みのエラーで断念
- [前回](https://qiita.com/nogic1008/items/2c4049d43a11e83df15b)と同様の手段で「デザイン時 T4 テキスト テンプレート」を使う手段を紹介
    - 方法だけ知りたい人は[こちら](#net-ローカルツールを使う)

## 環境
- Windows 10 Pro x64
  - 下記SDKが使えるならば、Linuxとかでもできるはず(未確認)
- .NET 6 SDK
  - C# 9.0に追加された[ソース ジェネレーター](https://docs.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/source-generators-overview)機能を使いたいので、.NET 5以降が必須です
- Visual Studio Code
  - [C#拡張機能](https://marketplace.visualstudio.com/items?itemName=ms-vscode.csharp)を入れておきましょう
  - [T4 Support](https://marketplace.visualstudio.com/items?itemName=zbecknell.t4-support)も入れておくと.ttファイルがハイライトされて見やすいです

## T4とは
- [コード生成と T4 テキスト テンプレート](https://docs.microsoft.com/ja-jp/visualstudio/modeling/code-generation-and-t4-text-templates)

T4はText Template Transformation Toolkitの略。
端的に言えば「Visual Studioでテキストファイルを自動生成するシステム」。

「いつファイルを生成するか」でおもに2通りある。

- 実行時 T4 テキスト テンプレート
  - 「テンプレートに沿ったテキストを吐き出す**クラス**」を生成する。
      - プログラム側から、そのクラスを明示的に利用することになる
  - [前回](https://qiita.com/nogic1008/items/2c4049d43a11e83df15b)利用したのはこっち
- デザイン時 T4 テキスト テンプレート
  - **コンパイル前**(≒テンプレート保存時)に、テンプレートに沿ったファイルを生成する。
      - 基本的に、Visual Studio(VSCodeではない)が自動でやる
  - ほぼC#(VB)のソースコード自動生成用

## Source Generatorを使う(失敗)

### ソース ジェネレータープロジェクトの作成

```
> dotnet new classlib
```

生成されたcsprojの`<TargetFramework>`を`netstandard2.0`に変更します。
また、`<IsRoslynComponent>true</IsRoslynComponent>`を追加します。

```diff_xml:csproj
  <PropertyGroup>
-   <TargetFramework>net6.0</TargetFramework>
+   <TargetFramework>netstandard2.0</TargetFramework>
+   <IsRoslynComponent>true</IsRoslynComponent>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
```

### 必要なパッケージを追加

`dotnet add package`コマンドで必要なパッケージを追加します。

- [Microsoft.CodeAnalysis.CSharp](https://www.nuget.org/packages/Microsoft.CodeAnalysis.CSharp/) - ソースジェネレーター利用に必須
- [Mono.TextTemplating.Roslyn](https://www.nuget.org/packages/Mono.TextTemplating.Roslyn/) - T4テンプレートのコンパイルに利用
  - [Mono.TextTemplating](https://www.nuget.org/packages/Mono.TextTemplating/)
  - [System.CodeDom](https://www.nuget.org/packages/System.CodeDom/)

追加したのち、csprojを以下のように編集します。

```diff_xml
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
+   <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" />
-   <PackageReference Include="Mono.TextTemplating" Version="2.2.1" />
-   <PackageReference Include="Mono.TextTemplating.Roslyn" Version="2.2.1" />
-   <PackageReference Include="System.CodeDom" Version="4.4.0" />
+   <!-- 下記のTarget内で使うため、GeneratePathPropertyを設定する -->
+   <!-- 利用側では必要ないため、PrivateAssetsを設定する -->
+   <PackageReference Include="Mono.TextTemplating" Version="2.2.1" GeneratePathProperty="true" PrivateAssets="all" />
+   <PackageReference Include="Mono.TextTemplating.Roslyn" Version="2.2.1" GeneratePathProperty="true" PrivateAssets="all" />
+   <PackageReference Include="System.CodeDom" Version="4.4.0" GeneratePathProperty="true" PrivateAssets="all" />
  </ItemGroup>
+
+ <Target Name="GetDependencyTargetPaths">
+   <ItemGroup>
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgSystem_CodeDom)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgMono_TextTemplating)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgMono_TextTemplating_Roslyn)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+   </ItemGroup>
+ </Target>
```

### ISourceGeneratorの実装

```csharp:Generator.cs
using System;
using System.CodeDom.Compiler;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Mono.TextTemplating;
using Location = Microsoft.CodeAnalysis.Location;

namespace T4Generator;

[Generator]
public class T4Generator : ISourceGenerator
{
  // 今回のGeneratorには不要
  public void Initialize(GeneratorInitializationContext context) { }

  public void Execute(GeneratorExecutionContext context)
  {
    // csprojの<AdditionalFiles>に一致したファイルはここに入る
    var t4Files = context.AdditionalFiles.Where(at => at.Path.EndsWith(".tt"));
    if (t4Files?.Any() != true) return;

    // T4テンプレート変換クラスの生成+Roslynコンパイラーの使用
    var generator = new TemplateGenerator();
    generator.UseInProcessCompiler();

    foreach (var file in t4Files)
    {
      var content = file.GetText(context.CancellationToken);
      if (content is null) continue;

      try
      {
        // T4テンプレートのコンパイル
        var template = generator.CompileTemplate(content.ToString());
        if (template is null)
        {
          // コンパイルエラー内容を取得し、ソースジェネレーター側にエラーを通知
          var error = generator.Errors.Cast<CompilerError>().FirstOrDefault();
          if (error is not null)
            context.ReportDiagnostic(CreateT4ErrorDiagnostic(error));
          continue;
        }

        // T4テンプレートの生成結果を取得
        string sourceCode = template.Process();

        // {ファイル名}.Generated.{ハッシュ}.csで保存
        string fileName = Path.GetFileNameWithoutExtension(file.Path);
        context.AddSource($"{fileName}.Generated.{file.GetHashCode():x8}.cs", sourceCode);
      }
      catch (Exception ex)
      {
        // ソースジェネレーター側にエラーを通知
        if (ex is not OperationCanceledException)
          context.ReportDiagnostic(Diagnostic.Create(_complieError, Location.None, ex));
      }
    }

    static Diagnostic CreateT4ErrorDiagnostic(CompilerError err)
      => Diagnostic.Create(_invalidFileWarning, Location.None, err.ErrorNumber, err.ErrorText);
  }

  private static readonly DiagnosticDescriptor _complieError = new(
    "T4GEN001",
    "Couldn't parse T4 file",
    "{0}",
    "T4Generator",
    DiagnosticSeverity.Warning,
    true);

  private static readonly DiagnosticDescriptor _invalidFileWarning = new(
    "T4GEN002",
    "Couldn't parse T4 file",
    "{0}: {1}",
    "T4Generator",
    DiagnosticSeverity.Warning,
    true);
}
```

### ソース ジェネレーターを使うプロジェクトの作成

```
> dotnet new console
```

### ソース ジェネレーターを参照する

```diff_xml:csproj
+ <ItemGroup>
+   <!-- ソースジェネレーターでファイルを扱うには、AdditionalFilesが必要 -->
+   <AdditionalFiles Include="./**/*.tt" />
+ </ItemGroup>
+
+ <!-- プロジェクトをAnalyzerとして読み込む -->
+ <ItemGroup>
+   <ProjectReference Include="../../src/Nogic.T4Generator/Nogic.T4Generator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
+ </ItemGroup>
```

### T4ファイルを作成する

ついでに`Program.cs`も削除しています。

```csharp:Program.tt
<#@ template hostspecific="false" language="C#" #>
<#@ output extension=".g.cs" #>
/// <auto-generated/>

<# string message = "Hello, World from T4!!!"; #>
Console.WriteLine("<#= message #>");
```

### コンパイル(失敗)

```console
> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  T4Generator -> T4Generator.dll
CSC : warning T4GEN001: System.IO.FileNotFoundException: Could not load file or assembly 'System.CodeDom, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. 指定されたファイルが見つかりません。 [Sample.csproj]
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [Sample.csproj]

ビルドに失敗しました。

CSC : warning T4GEN001: System.IO.FileNotFoundException: Could not load file or assembly 'System.CodeDom, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. 指定されたファイルが見つかりません。 [Sample.csproj]
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [Sample.csproj]
    1 個の警告
    1 エラー
```

`System.CodeDom`のアセンブリが読み込めず失敗。

## .NET ローカルツールを使う(成功)

### プロジェクトの作成

省略

### ローカルツールのインストール
```console
> dotnet new tool-manifest
> dotnet tool install dotnet-t4
```
### 「ビルド前」「クリーンアップ後」の処理を追加
```xml:csproj
<ItemGroup>
  <!-- T4ファイルをプロジェクトから参照する (TextTemplateは好きな名前でよい) -->
  <TextTemplate Include="./**/*.tt" />
  <!-- 生成後のファイルをプロジェクトから参照する (Generatedは好きな名前でよい) -->
  <Generated Include="./**/*.g.cs" />
</ItemGroup>

<!-- ビルド前にdotnet-t4をTextTemplateに指定された各ファイルに対して呼び出すタスク -->
<Target Name="TextTemplateTransform" BeforeTargets="BeforeBuild">
  <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet t4 %(TextTemplate.FullPath)" />
</Target>
<!-- クリーンアップ後にGeneratedに指定されたファイルを削除するタスク -->
<Target Name="TextTemplateClean" AfterTargets="Clean">
  <Delete Files="@(Generated)" />
</Target>
```

### T4ファイルを作成する

ついでに`Program.cs`も削除しています。

```csharp:Program.tt
<#@ template hostspecific="false" language="C#" #>
<# // extensionは、csprojのGeneratedで指定したものに合わせる #>
<#@ output extension=".g.cs" #>
/// <auto-generated/>

<# string message = "Hello, World from T4!!!"; #>
Console.WriteLine("<#= message #>");
```

### ビルド

```console
> dotnet build
```

ビルドプロセス上で、以下のファイルが生成される。
生成されたファイルも、Gitなどのバージョン管理に追加したほうがよさげ？(プロジェクトの方針による)

```csharp:Program.g.cs
/// <auto-generated/>

Console.WriteLine("Hello, World from T4!!!");
```

### 既知の問題
#### 初回ビルド時に失敗することがある
```console
> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  復元対象のプロジェクトを決定しています...
  復元対象のすべてのプロジェクトは最新です。
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [xxx.csproj]

ビルドに失敗しました。

CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [xxx.csproj]
    0 個の警告
    1 エラー
```

- `Program.g.cs`がコンパイル対象になる前に、ビルド実行されてしまうのが原因？
- もう一度ビルド実行することで解決する

#### T4ファイルが存在しない場合にエラー
```console
> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  復元対象のプロジェクトを決定しています...
  Input is empty
xxx.csproj(16,5): error MSB3073: コマンド "dotnet t4 " はコード 1 で終了しました。    
```

- `<TextTemplate>`に当てはまるファイルが存在しない場合、`dotnet-t4`を呼び出すコマンドに空の値が渡ってしまうことが原因
- T4を使わなくなった場合は、ちゃんと後片付けしましょう

## Visual Studioとの共存
上記環境を構築すると、Visual Studio側からT4テンプレートをソリューションに追加する手法が取れなくなります。
また、すでにVisual Studio側にてT4テンプレートを使っている場合は、上記設定をすることでファイル生成が二重に行われてしまいます。
Visual Studio側の動作を止める場合は、`<ItemGroup>`内にある`xxx.tt`を指定している項目を削除します。

## まとめ
- ソースジェネレーターはいいぞ
  - JSON Schemaから「Entityクラス」と「Web APIを呼び出すサービスクラス」とか作れそう
  - ただ、NuGetパッケージまわりの項目はまだ難しい
- ソースジェネレーターで外部ファイルを読み込むには`<AdditionalFiles>`を追加する
- ビルド周りで何かしたければMSBuildの黒魔術(というほどでもないけど)に頼る

## 参考
- [ソース ジェネレーター](https://docs.microsoft.com/dotnet/csharp/roslyn-sdk/source-generators-overview)
- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md)
- [MSBuild リファレンス](https://docs.microsoft.com/visualstudio/msbuild/msbuild-reference)