Rust のマクロで TypeScript の型を生成する
はじめに
少し空きましたが、ちょこちょこ Rust + WebAssembly + SolidJS で遊んでいます。前回まで[1][2] で、Rust <-> TypeScript 間のデータのやり取りはそこそこ出来る様になったのですが、まだちょっと気になる部分があります。
関数の引数や戻り値として JSON 文字列やり取りし、Rust 側からも TypeScript 側からも特定の型を介してそれにアクセスする[3] ことで、なんとなく型を頼れる雰囲気にはなったのですが、その型を定義している例がコチラです。
Rust 側と TypeScript 側でね、それぞれ別々にね、定義をしています。う〜ん・・・いまいち。なんとなく「手続マクロを使えばどうにかなるのでは?」と魔が差したので、勉強と思って取り組んでみます。言うまでもないですが、良い子のみなさんは wasm-bindgen[4] を使いましょう。
ちなみに Rust のバージョン新しくなっています。
$ cargo -V
cargo 1.66.0 (d65d197ad 2022-11-15)
$ rustc -V
rustc 1.66.0 (69f9c33d7 2022-12-12)
$ node -v
v18.12.1
実装方針
とりあえず自分が使う割とシンプルな型だけ変換できれば良いので、どちらからどちらに変換しても良い気はしますが、以下の理由から Rust -> TypeScript の変換で考えてみます。
- Rust の方が型の情報量(?)が多い(たぶん・・・)
- なんらかの方法で注釈したものだけを変換対象としたい(属性マクロが良さそう)
- ビルドフローが Rust -> TypeScript の順になっている
Rust 上で定義された型自体は触らなくて良いので、導出マクロ[5] でもイケる気はしますが、(慣例的に?)属性マクロ[6] の方がしっくり来そうなので属性マクロでやってみます。
完成イメージ
こんな感じの Rust のコードから、
use wasmple_bridge::wasmple_bridge;
#[wasmple_bridge]
pub type BufferPtr = usize;
#[wasmple_bridge]
#[derive(Serialize, Deserialize, JsonConvertee, Debug, PartialEq, Eq)]
struct FnConvertParameters {
a: String,
b: String,
}
#[wasmple_bridge]
#[derive(Serialize, Deserialize, JsonConvertee, Debug, PartialEq, Eq)]
struct FnConvertReturns {
interleaved: String,
reversed: String,
}
#[wasmple_bridge]
#[no_mangle]
pub extern "C" fn convert(input_ptr: BufferPtr) -> BufferPtr {
input_ptr
}
こういう感じの TypeScript のコードを生成してくれると良さそうです。とりあえずこのくらいを目指してやってみます。
export type BufferPtr = number;
export type FnConvertParameters = { a: string, b: string };
export type FnConvertReturns = { interleaved: string, reversed: string };
export type FnConvert = (ptr: BufferPtr) => BufferPtr;
クレートの構成
引き続きこちらのプロジェクトに実装していきます。
今回は、3 つのクレートに分けて実装をしてみます。それぞれのクレートの役割はこんな感じ。
-
wasmple_bridge_attribute
- 手続マクロの作成には独立したクレートが必要なのでコレ
- マクロの入口だけこのクレートに置いて、ココから
wasmple_bridge_impl
を呼び出す
-
wasmple_bridge_impl
- 手続マクロ用のクレートではテストが書けないので具体の処理はこちらで実装する
- マクロの出力を確認するのも面倒なのでテストで当てながら実装を進めていく
-
wasmple_bridge
- 外側からはこのクレートを参照する様にする
- 生成された TypeScript のコードを収集する部分もココに作ります
ディレクトリはこんな感じ。生成した TypeScript は target/bridge.ts
に置くことにします。
wasmple
├── target
│ └── bridge.ts
├── wasmple_bridge
│ ├── Cargo.toml
│ └── src
├── wasmple_bridge_attribute
│ ├── Cargo.toml
│ └── src
└── wasmple_bridge_impl
├── Cargo.toml
└── src
それでは少しずつやっていきましょう。
マクロクレートの作成
マクロクレート wasmple_bridge_attribute
は、こんな感じで手続マクロ用のクレートにします。
中身はコレだけで、具体の実装は wasmple_bridge_impl
クレートに委ねます。
実際には proc_macro::TokenStream
と proc_macro2::TokenStream
との変換が行われているのですが、Into
が活躍してくれているので良い感じに書けます。まぁコレだけなら、テストが書けなくても良しとしましょう。
マクロ実装クレートの作成
マクロ実装クレート wasmple_bridge_impl
は、こんな感じになります。手続マクロ実装の三種の神器 proc-macro2
[7] quote
[8] syn
[9] を使います。また、パラメータ化テストをまわすのに rstest
[10] を使います。あと、関数名をスネークケース some_function
からキャメルケース SomeFunction
に変換するのに、convert_case
[11] を使います。
マクロの実装となる関数
まずは、マクロクレートから呼び出される wasmple_bridge_impl::wasmple_bridge_impl()
関数を作成します。属性マクロは、2 つの TokenStream
を受け取り、TokenStream
を返します。
1 つめの引数 attr: TokenStream
には、属性マクロの呼び出し自体に付けたパラメタが渡ってきます。例えば、#[wasmple_bridge(someparam)]
の様にマクロを呼んだ場合、someparam
が渡ってきます。ひとつの属性マクロ内で、パラメタで動作を変えるような場合に利用可能です。今回は使わないので、_attr
で受けておきます。
2 つめの引数 item: TokenStream
には、属性マクロが付けられたアイテムが渡ってきます。アイテム[12] というのは、fn
struct
trait
など、クレートの構成要素のことです。属性マクロは、ここに渡ってきたアイテムを、戻り値で返した内容に置き換えます。とりあえず、元々の内容を書き換える気は無いので、渡ってきたアイテムをそのまま返すようにしておきましょう。このあと追加する部分を書き易いように、quote!
で包んでおきます。
pub fn wasmple_bridge_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
quote! { #item }
}
アイテムのパース
TokenStream
のままでは扱いにくいので、syn::parse2
により構文木にパースします。こんな感じ。パースに成功すれば、syn::Item
が手に入ります。
let Ok(parsed) = syn::parse2::<Item>(item.clone()) else { unsupported!(item) };
マクロ実行中のエラー処理
マクロの処理中に失敗した場合は、とりあえず panic!
しておけばコンパイルが止まります。構文木から TypeScript への変換中に、非対応の構文要素(とりあえず手元で使いそうなパターンのみ実装していくので非対応のパターンがたくさん出てくる想定)があった場合、直ちに panic!
で止めようと思うので、呼び出し易いように関数マクロを作っておきます。
変換中に非対応だった構文要素(ノード)を渡して、その内容を出力するようにしています。簡単ですが、テストでもソレを当てられるようになりますし、どこでコケたのかも判り易くなります。
最初のテスト
ここでは、引数に渡ってきたアイテムがそのまま戻り値に返される事だけテストしてみましょう。
#[cfg(test)]
mod tests {
use rstest::*;
use super::TokenStream;
use quote::quote;
#[rstest]
#[case::rust(quote! {
pub type TestType = usize;
})]
fn starts_with_input_item(#[case] item: TokenStream) {
let input = item.to_string();
let output = super::wasmple_bridge_impl(TokenStream::new(), item).to_string();
assert!(output.starts_with(&input));
}
}
残念ながら TokenStream
には Eq
トレイトが実装されていない様でしたので、String
に変換して比較しちゃいましょう。最終的には、元のアイテムの後ろに TypeScript を出力するためのコードを出力する予定なので、前方一致すれば良いことにしておきます。
panic!
するテストケースは、こんな感じで記述します。アイテムでも何でもない空っぽの TokenStream
を食わせると、Item
へのパースに失敗して unsupported!
するので、そうなることをテストしています。expected
に、パニックメッセージ(の一部)を渡すと、それも含めてチェックしてくれます。
#[should_panic(expected = "unsupported TokenStream")]
#[case::empty(quote! {})]
テストケースを増やしてこんな感じになりました。
Rust の型から TypeScript の型への変換
さて、だいたい御膳立てが整ってきたので、Rust の型から TypeScript の型への変換について考えていきましょう。
Rust の構文木
syn::parse2
によって Rust の構文木を得るところまで実装していました。syn::Item
型を指定してパースしているので、syn::Item
が手に入ります。コレは、Rust のアイテムを表現する enum でこんな感じになっています。この中から、必要なパターンを実装していけば良さそうです。
pub enum Item {
Const(ItemConst),
Enum(ItemEnum),
ExternCrate(ItemExternCrate),
Fn(ItemFn),
ForeignMod(ItemForeignMod),
Impl(ItemImpl),
Macro(ItemMacro),
Macro2(ItemMacro2),
Mod(ItemMod),
Static(ItemStatic),
Struct(ItemStruct),
Trait(ItemTrait),
TraitAlias(ItemTraitAlias),
Type(ItemType),
Union(ItemUnion),
Use(ItemUse),
Verbatim(TokenStream),
}
AST Explorer
syn
のリファレンスを見れば、「どんな構文木になっているか?」は分かるのですが、具体的ではないので掴みにくいこともあると思います。
そんな時に便利なのが、AST Explorer[13] です。実際に、今回の例を AST Explorer にかけてみたのがコチラです。syn
でパースした結果を、ツリー上に表示してくれます。いくつか構文木を眺めれば、大体何処に何が入ってくるか分かった気になるので、あとは必要なノードを拾って TypeScript に写していけば良さそうです。
TypeScript も TokenStream で表現
一方、生成したい TypeScript はこんな感じでした。
export type BufferPtr = number;
export type FnConvertParameters = { a: string, b: string };
export type FnConvertReturns = { interleaved: string, reversed: string };
export type FnConvert = (ptr: BufferPtr) => BufferPtr;
文字列としてチコチコ繋ぎ合わせていっても良いのですが・・・、コレって TokenStream
で表現できる気がしますね。変換中は TokenStream
として扱うことで、繋ぎ合わせに quote!
を使うことも出来て良い感じがします。それで行きましょう。
つまり、Rust の型から TypeScript の型への変換は、syn::Item
をルートとする構文木から TokenStream
への変換ということになりました。いぇい。
それってトレイト
それではココで唐突に、「TypeScript の型を表現する TokenStream への変換をするトレイト」を考えてみます。雑ですがこんな感じです。
仮に、このトレイト ToTsType
が syn::Item
に対して実装されていたとしたら、最終的な変換はこんな感じになりそうです。
let Ok(parsed) = syn::parse2::<Item>(item.clone()) else { unsupported!(item) };
let script = parsed.to_tstype();
変換の実装
実際には、syn::Item
の変換の過程で、その子ノードに対する変換も必要になってくるので、変換が必要になった構文木要素の型に対して地道に ToTsType
を実装していけば何とかなる気がします。ここでは、いくつかの構文木要素について見ていきたいと思います。
プリミティブ型の変換
まずは構文木の末端の方から、プリミティブ型の変換について考えてみます。例えば、Rust の i32
を TypeScript の number
に変換する処理です。
syn::Type
Rust の型を表現する syn::Type
は、次のような enum になっています。けっこう・・・というか、かなり様々なパターンがあります。何故かと言うと、[T; n]
とか impl Bound1 + Bound2 + Bound3
とか &'a mut T
とかも、syn::Type
の守備範囲だから。なるほど。
pub enum Type {
Array(TypeArray),
BareFn(TypeBareFn),
Group(TypeGroup),
ImplTrait(TypeImplTrait),
Infer(TypeInfer),
Macro(TypeMacro),
Never(TypeNever),
Paren(TypeParen),
Path(TypePath),
Ptr(TypePtr),
Reference(TypeReference),
Slice(TypeSlice),
TraitObject(TypeTraitObject),
Tuple(TypeTuple),
Verbatim(TokenStream),
}
対象にしたいプリミティブ型は、Type::Path(TypePath)
パターンになります。このパターンは、i32
や syn::Type
や std::slice::Iter
の様なパス的に参照される型を表現します。なお、マクロは構文解析や意味解析よりも前に実行されるため、この時点では「この『パス的に参照された型』が具体的に何か?」は判りません。たぶん。
また、型を括弧 ()
で包んだものも型となります。書いたことないけど、(i32)
とかでしょうか。このパターンは、Type::Paren(TypeParen)
で表現されるので、内側の Type
を再帰的に処理して、括弧は外してしまいましょう。
他のパターンは、サクッと unsupported!
にしてしまいます。テストケースもこんな感じ。
syn::TypePath
続いて、syn::TypePath
の変換です。中身を掘っていくと、.path.segments
という構造体から、各セグメント(a::b::c
で言うところの a
b
c
それぞれ)にアクセスできます。先ほども触れた通り、コンパイル処理上の意味解析は未実施のため、具体の型を特定して処理をすると言うよりは、「単にそういうトークンが並んでいる(そういう文字列である)」ことに対して処理をするイメージになります。端折ってやっちゃいましょう。セグメント列の最後の要素だけ見て処理してしまいます。
-
usize
i8
u8
i16
u16
i32
u32
i64
u64
f32
f64
は全てnumber
-
bool
はboolean
-
String
はstring
で良いでしょう。また、
-
Option<some>
はsome?
-
Vec<some>
はsome[]
に変換してみます。
それ以外の場合は、最後のセグメントの内容をそのまま返しておきます。unsupported!
にしないのは、このマクロを使って変換した他の型に対する参照をフォローするためです。
テストケースはこんな感じです。Vec<Vec<String>>
もちゃんと再帰的に紐解かれて string[][]
に変換されています。
構造体の変換
続けて構造体の変換も見てみましょう。
syn::ItemStruct
構造対のアイテム全体は syn::ItemStruct
に入ってきます。構造体の名前は .ident
に、フィールドへは .fields
からアクセスします。
syn::Fields
syn::Fields
は、こんな enum です。
pub enum Fields {
Named(FieldsNamed),
Unnamed(FieldsUnnamed),
Unit,
}
{ num: i32, str: String }
みたいなヤツは Fields::Named(FieldsNamed)
に渡ってきます。Fields::Unnamed(FieldsUnnamed)
はタプルを表現するものの様なので、今回は unsupported!
しちゃいます。
syn::FieldsNamed
.named
にフィールド一式入っているので、まずは各々のフィールドを TypeScript に変換します。Rust の num: i32
を TypeScript の num: number
に変換です。型部分(syn::Type
)の変換は、先ほど作ったものが適用されますね。
あとは、各フィールドを ,
でくっつけて {}
で包みます。quote!
には、こう言う時に便利な機能[14] が備わっています。こんな感じ。
これで、フィールドの変換もできました。
こんな感じで、syn::ItemStruct
に戻ると、Rust 構造体の型を TypeScript オブジェクトの型に変換できています。
まぁ、大体こんな感じです。現時点の全量はリポジトリを直接参照ください。この記事のサンプルに必要な変換は概ね実装された状態で、こんな感じです。
ファイルへの出力
ここまで、wasmple_bridge_impl
クレート内でテストを使いながら実装を進めてきました。最終的には TypeScript のコードをファイルに出力したいのですが、方法に皆目見当がつきません。
いろいろ探していると、同じような考えで FFI 用の C のヘッダファイルを出力する、safer_ffi[15] と言うクレートがありました。少し覗いてみると、inventory
[16] と言うクレートを利用している様です。
inventory
を使う
inventory
の使い方は簡単です。
- 構造体を作って
inventory::collect!
する - コード内の各地から
inventory::submit!
する - 実行時に
inventory::iter
でアクセス
コレだけ。超便利。順番にやっていきましょう。
inventory::collect!
する
構造体を作って TypeScript のコードを出力できれば良いので、文字列 1 つ扱えれば良さそうです。ついつい忘れそうになりますが、全てマクロの世界の話なので、出力される文字列の型は &'static str
になります。
この構造体 TsScript
を渡して inventory::collect!
します。何となく名前的に submit!
したものを collect!
する雰囲気がありますが、多分こっちが先ですかね。ここに、溜め込む場所を作っている気がします(読んでないで適当なこと言ってます)。構造体の定義と同じ .rs
ファイルで collect!
せよとのことなので、やっておきます。これで、TsScript
を参照する全ての箇所から collect!
の出力も見えているハズですね。
inventory
は、Linux, macOS, iOS, FreeBSD, Android and Windows
でしか動かない様なので、wasm32
ビルドの際は含まれない様にします。一瞬「wasm32
で動かないから使えない?」とか錯覚しましたが、これを動かしたいのはビルド環境上なので大丈夫でした。
inventory::submit!
する
コード内の各地から さて、submit!
の方です。ToTsType
で syn::Item
を変換して出来た TypeScript の TokenStream
を to_string()
して、TypeScript コードの文字列を手に入れます。コレを quote!
内で展開するとリテラルになるので、TsScript
にして submit!
します。
これで、#[wasmple_bridge]
属性を付けられた各地のコードから、submit!
されることになります。良さそうです。
inventory::iter
でアクセス
実行時に さて、collect!
と submit!
はマクロなのでコンパイル時に展開される話でしたが、集めた情報へのアクセスは実行時に行うみたいです。wasmple_bridge
クレートに、inventory::iter
を回して TypeScript の文字列を生成する関数を、作っておきましょう。
wasmple_bridge
の利用
なんとか動きそうな感じになってきました。そろそろ wasmple
から wasmple_bridge
を利用してみましょう。
#[wasmple_bridge]
属性注釈
まずは、変換対象としたいアイテムに #[wasmple_bridge]
属性注釈を付けます。
cargo run --example
で実行
変換対象と同じファイルに、wasmple_bridge::generate()
を呼び出すコードを用意しておきます。変換対象も複数ファイルに渡る事があるので、「同じファイル」である必要はありませんが、同じコンパイル単位に入っている必要があります。変換したいアイテムを擁するクレートの lib.rs
でやっておくのが良さそうです。
いよいよ実行したいのですが、wasmple
はライブラリクレートなのでそのままでは実行が出来ません。テストコードの中に仕込むようなやり方も見かけましたが、今回は cargo run --example
[17] を使ってみようと思います。
こんな感じで、main()
関数を含むファイルを用意しておいて、
--example
オプションをつけて cargo run
します。コレは便利。
$ cargo run --package wasmple --example generate-typescript
Finished dev [unoptimized + debuginfo] target(s) in 0.09s
Running `target/debug/examples/generate-typescript`
export type FnConvert = (input_ptr : BufferPtr) => BufferPtr ;
export type FnConvertReturns = { interleaved : string, reversed : string } ;
export type FnConvertParameters = { a : string, b : string } ;
コレで、Rust のアイテムから変換された TypeScript が、STDOUT に出力される様になりました。ふぅ。
ビルドプロセスへの組み込み
ビルド時には、こんな感じで実行して ./target/
ディレクトリに出力すれば良さそうです。
$ cargo run --package wasmple --example generate-typescript > ./target/bridge.ts
このプロジェクトでは、npm
でビルドフローを組んでいるので、こんな感じでスクリプトも追加しています。この辺りはお好みで。
TypeScript からの利用
あとは、出力したファイルを TypeScript から import
するだけです。他のファイルからも使いたいので、再 export
もするとこんな感じでしょうか。
個別に定義していた時と同じように動いています。コードは、こんな ↓ 感じ。クレート増えたね。
おわりに
なんとかカタチに(?)なりました。色々な要素が関わってくるのでかなり勉強になった気がします。お付き合いいただきありがとうございました。何かの参考になれば幸いです。
-
WebAssembly と JavaScript との間で自在にデータをやりとりする - JSON の受け渡し(typed) ↩︎
-
wasm-bindgen - Facilitating high-level interactions between Wasm modules and JavaScript. ↩︎
-
proc-macro2 - A wrapper around the procedural macro API of the compiler's proc_macro crate. ↩︎
-
quote - the quote! macro for turning Rust syntax tree data structures into tokens of source code. ↩︎
-
syn - a parsing library for parsing a stream of Rust tokens into a syntax tree of Rust source code. ↩︎
-
convert_case - Converts to and from various cases. ↩︎
-
AST Explorer - A web tool to explore the ASTs generated by various parsers. ↩︎
-
Interpolation - quote - the quote! macro for turning Rust syntax tree data structures into tokens of source code. ↩︎
-
::safer_ffi - a framework that helps you write foreign function interfaces (FFI) without polluting your Rust code with
unsafe { ... }
code blocks while making functions far easier to read and maintain. ↩︎
Discussion