LoginSignup
4

[C#] ScribanSourceGenerator によるコンパイル時コード生成

Posted at

はじめに

本記事では .NET向けのスクリプト言語エンジンの scriban を使用して、コンパイル時にC#コードを生成する ScribanSourceGenerator の使用方法を紹介します。

「scriban 言語の詳細なんて興味ねぇ! とにかくコードを生成したいんだ!」 って人向けの内容です。

私自身が scriban を使用した経験がなくBing様に助けを求めたのですが、世間話程度の回答しかもらえなかったので、 Bing様をより賢くする目的で調査した内容をまとめました。

bing_capture_230401.png

T4テキストテンプレート との比較

.NET のコード生成では 古来より T4 テキスト テンプレート が用意されていますが、残念ながら Visual Studio (for Windows) でしか使用できません。 またメンテさている様子もないことから将来性を感じません。

私自身は上記理由のみで十分に T4 を忌避するのですが、日本C#界の父であり(私が勝手に思ってる)ScribanSourceGenerator 作者の ufcpp さんが 脱T4のメリット をまとめてくださっていますので以下に引用いたします。

UfcppSample/Demo/2022/NoMoreT4 at master · ufcpp/UfcppSample より

脱 T4 メリット

  • 速い
  • 普通の C# だけで書ける
    • 補完・コード解析が働く
  • Visual Studio 以外で使える
    • 「Visual Studio 上で保存時にコード生成」がきつかった
    • この Roslyn Source Generator な時代に、誰も Source Generator 化しないあたりでお察し
  • csproj がすっきり
    • <None Update="T4Generator.tt"> みたいな謎タグ要らない
    • <Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" /> とかいう謎サービス要らない
    • <PackageReference Include="System.CodeDom" /> とかいうつらみあるパッケージ参照要らない
  • Visual Studio 自体のバージョン・表示言語に依存しない
    • TextTemplatingFilePreprocessor の生成結果は「T4 ファイルを編集して保存した PC」に依存しちゃう
    • 複数人編集すると Git 差分が悲惨なことになってた

ScribanSourceGenerator によるコード生成

0. 前提

未確認ですが ScribanSourceGenerator は Visual Studio 2022 以上(Roslyn 4 以上)でないと動作しないと思います。 ISourceGenerator はサポートしてなさそう(IIncrementalGenerator のみの提供)なためです。

1. 準備

改めて整理するほど重要な内容ではないと思っていますが、話を順に進めるためワンパス通すところまでをまとめます。

パッケージ導入

まずは .NETプロジェクトに ScribanSourceGenerator パッケージをインストールしましょう。 本記事の作成時点の最新版は 1.1.0 です。

> dotnet add package ScribanSourceGenerator

コード生成の方法

コード生成には、Source Generator (C#9 以降) を利用する方法 と scribanテキストファイルをパースする方法 の2種類があります。 今回は より便利と思ってる前者 1 のみを紹介します。

Source Generator によるコード生成

対象の partial クラスに ScribanSourceGenerator.ClassMemberAttribute を付与し、引数の文字列に scribanスクリプト を記述します。 以下は scriban を使用しておらずコード生成の旨味はないです。

生成元のコード

namespace ScribanSourceGeneratorDemo;
// 生文字列リテラル は C#11 以降
[ScribanSourceGenerator.ClassMember("""
        public void SayHello() => System.Console.WriteLine("Hello!");
    """)]
public partial class Demo1 { }

生成されたコード

// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo1 {
    public void SayHello() => System.Console.WriteLine("Hello!");
}}

コード生成のワンパス対応は以上です。

2. scribanスクリプト言語

scriban の言語仕様の中からコード生成に利用されそうな部分のみを紹介します。 言語の詳細仕様は scribanの公式ドキュメント をご確認ください。

2-1. ブロック

scribanスクリプト言語には 下記3つ のブロックが存在します。

  1. コードブロック

    {{}} で囲まれたブロックは、scribanスクリプトエンジン によって解釈されます。

    {{
      x = 5     # 変数 x に 5 が設定されます。(何も表示されません)
      x         # 5 が表示されます
      x + 1     # 6 が表示されます
      x         # 5 が表示されます
      # 565 が隙間なく表示されます
    }}
    
  2. テキストブロック

    {{}} で囲まれてないブロックは、テキストとしてそのまま出力されます。

    Hello this is {{ name }}, welcome to scriban!
    ______________          _____________________
    ^ text block            ^ text block
    
  3. エスケープブロック

    {%{}%} で文字のエスケープができますが、コード生成では使用しなさそうなので割愛します。

2-2. 言語仕様

コード生成で使用しそうな scribanスクリプト を C#コード と対比して紹介していきます。

ソースコメント

{{ $x = 1	# シングルライン
   $y = 2	## マルチ
               ライン ##
}}

変数定義

  • 動的型付け なので型指定は不要です。
  • ローカル変数には $ を付与します。
  • シンボルは スネークケース で記述します。
// cs: int x = 123;
// cs: int byte_max = 0xff;
// cs: string s1 = "abc";

{{ $x = 123
   $byte_max = 0xff
   $s1 = "abc"       # 'abc' の指定も可
}}

配列定義

  • 宣言時の各要素は [] で括ります。(C# のリストパターン ライク)
  • 配列は index でアクセスできます。(C# と同様)
  • 配列の要素数は size で取得できます。 (C# では Length プロパティ)
// cs: string[] ss = new[] { "false", "true" };
// cs: int length = ss.Length;
// cs: string s = ss[^1];

{{ $ss = ["false","true"]
   $length = $ss.size
   $s = ss[length - 1]
}}

if ステートメント

if の処理が単一文であっても必ず end で閉じる必要があります。忘れないようにしましょう。

// cs: if (i < 0) i = 0;

{{ # 複数行
   if $i < 0
     $i = 0
   end
}}

{{ if $i < 0; $i = 0; end; # 単一行 }}

for ステートメント

範囲を i..j で指定できますが、C# の Range 構造体 と異なり、scriban では j も含まれます。C# に揃えたい場合は i..<j を使用しましょう。
for の処理が単一文であっても必ず end で閉じる必要があります。忘れないようにしましょう。

// cs: for (int i = 0; i <= 3; i++) { }

{{ for $i in 0..3  # 3も含まれます }}
     ~~
{{ end }}
// cs: for (int i = 0; i < 3; i++) { }

{{ for $i in 0..<3  # 3は含まれません }}
     ~~
{{ end }}

foreach ステートメント

scriban では for を使用します。 foreach は存在しません。

// cs: string[] ss = new[] { "false", "true" };
// cs: foreach (var s in ss) { }

{{ $ss = ["false","true"]
   for $s in $ss }}
     ~~
{{ end }}

関数定義

func で定義して ret で結果を戻し end で閉じます。 デフォルト引数にも対応しています。

// cs: int Add(int x, int y, int z = 1) => x + y + z;

{{ func add(x,y,z=1)
     ret x + y + z
   end
}}
add(1,2) = {{ add(1,2)  #4 }}

可変長引数 にも対応しています。

// cs: int GetSum(params int[] values) => values.Sum();

{{ func get_sum(values...)
     $sum = 0
     for $x in values
       $sum += $x
     end

     # Referencing with index
     # for $i in 0..<values.size
     #   $sum += values[$i]
     # end
     ret $sum
   end
}}
get_sum(1,2,3,4) = {{ get_sum(1,2,3,4)  #10 }}

以上を抑えておけば、なんとなくコード生成できると思います。

2-3. 生成コードの改行

scriban では 空白 と 改行 のコード生成もコントロールできます。

デフォルトだとコードブロック前後の 空白 と 改行 が そのまま出力されるため、無駄に隙間の多いコードが生成されます。 実動作への影響はありませんが、生成コードを読む機会もあると思いますので、多少はケアしてもよいかと思います。

改行を含む空白の削除 -

削除指定なし

The yard-pound system '
{{ $s }}' disappear.

# The yard-pound system '
# must' disappear.

前の空白と改行を削除

{{- は、前方に遡って 空白以外の文字が登場するまで空白を削除します。 改行も空白に含まれます。

The yard-pound system '
{{- $s }}' disappear.

# The yard-pound system 'must' disappear.

後ろの空白と改行を削除

-}} は、後方に進んで 空白以外の文字が登場するまで空白を削除します。 改行も空白に含まれます。

The yard-pound system '{{ $s -}}

   ' disappear.

# The yard-pound system 'must' disappear.

改行で動作が異なる削除 ~

削除指定なし

{{ for $i in 2..<4 }}
    {{ $i }}
    {{ $i * $i }}
{{ end }}

#
#    2
#    4
#
#    3
#    9
#

前方の削除

{{~ は、前方に遡って最初に登場する改行までの空白を削除します。改行は削除されません。

{{ for $i in 2..<4 }}
    {{ $i }}
    {{~ $i * $i }}
{{ end }}

#
#    2
#4
#
#    3
#9
#

後方の削除

~}} は、後方に進んで最初に登場する改行までの空白 と 改行 を削除します。

{{ for $i in 2..<4 }}
    {{ $i ~}}
    {{ $i * $i }}
{{ end }}

#
#    2    4
#
#    3    9
#

3. コード生成の実例

コード生成で頻繁に使用されていそうな例を用意しました。これをコピって改変するのが手っ取り早いと思います。

3-1. 組み込み型のオーバーロード

各組み込み型に対する TryParse(this string s, out type result) メソッドを生成する例です。

生成元のコード

namespace ScribanSourceGeneratorDemo;

[ScribanSourceGenerator.ClassMember("""
    {{
    $types = ["bool","byte","sbyte","short","ushort","int","uint","long","ulong","float","double","DateTime","DateTimeOffset","TimeSpan"]
    for $t in $types
    ~}}
        public static bool TryParse(this string s, out {{$t}} x) => {{$t}}.TryParse(s, out x);
    {{ end }}
    """)]
public static partial class Demo2 { }

生成されたコード

// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo2 {
    public static bool TryParse(this string s, out bool x) => bool.TryParse(s, out x);
    public static bool TryParse(this string s, out byte x) => byte.TryParse(s, out x);
    public static bool TryParse(this string s, out sbyte x) => sbyte.TryParse(s, out x);
	~~~ 割愛 ~~~
}}

3-2. 引数個数のオーバーロード

可変長引数 params による ヒープアロケーション を回避するため、n個の引数までオーバーロード メソッド を生成する例です。

生成元のコード

namespace ScribanSourceGeneratorDemo;

[ScribanSourceGenerator.ClassMember("""
    {{
    func get_word(max,words...);
      $s1 = "";
      for $i in 1..max
        $s2 = "";
        for $w in words
          $s2 += $w 

          # Do not join index at the end.
          if !for.last; $s2 += $i; end
        end

        $s1 += $s2
        if !for.last
            $s1 += ", "
        end
      end
      ret $s1;
    end
    -}}
    {{ for $i in 2..16 ~}}
        public static async Task<({{ get_word $i "T" "" }})> WhenAll<{{ get_word $i "T" "" }}>({{ get_word $i "Task<T" "> task" "" }})
        {
            await Task.WhenAll({{ get_word $i "task" "" }});
            return ({{ get_word $i "task" ".Result" }});
        }
    {{ end }}
    """)]
public static partial class Demo3 { }

生成されたコード

// <auto-generated/>
namespace ScribanSourceGeneratorDemo {
partial class Demo3 {
    public static async Task<(T1, T2)> WhenAll<T1, T2>(Task<T1> task1, Task<T2> task2)
    {
        await Task.WhenAll(task1, task2);
        return (task1.Result, task2.Result);
    }
    public static async Task<(T1, T2, T3)> WhenAll<T1, T2, T3>(Task<T1> task1, Task<T2> task2, Task<T3> task3)
    {
        await Task.WhenAll(task1, task2, task3);
        return (task1.Result, task2.Result, task3.Result);
    }
    public static async Task<(T1, T2, T3, T4)> WhenAll<T1, T2, T3, T4>(Task<T1> task1, Task<T2> task2, Task<T3> task3, Task<T4> task4)
    {
        await Task.WhenAll(task1, task2, task3, task4);
        return (task1.Result, task2.Result, task3.Result, task4.Result);
    }
	~~~ 割愛 ~~~
}}

ソースコード

上記の実例コードは以下にアップしています。

hsytkm/ScribanSourceGeneratorDemo - GitHub

おわりに

.NETのコンパイル時コード生成パッケージの ScribanSourceGenerator の使用方法を紹介しました。

Bing様 が回答を持っておられなかったので(また Qiita にも記事がなかったので)まとめてみました。本記事が Bing様 に取り込まれ頒布されることを願っています🙏

確認環境

  • Windows 11 22H2
  • Visual Studio 2022 17.5.3
  • .NET 7 (C#11)
  • ScribanSourceGenerator 1.1.0

参考にさせていただいたページ

Visual Studio 2022 17.5 Preview 2 - ufcpp YouTube こちらの動画で 本Generator の存在を知りました。

ufcpp/ScribanSourceGenerator - GitHub

scriban/scriban - GitHub

  1. Source Generator の場合、生成元コードの クラス名 から生成コードにジャンプできます。scriban テキストファイルではインテリセンスが効かないため そのような操作ができません。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4