Rustのmacroを使ってhtml rendererを作った
2025-11-03
こんにちは
どうもー。
社会人になってからというもの(大変社会性に溢れる自分は)ブログを書く機会がなくなり、個人開発もほぼしなくなってしまったので意図せず 3 年ぶりくらいの投稿だけど、11 月になって色々落ち着いてひと段落ってことでちょっと個人的な開発をしてみたので記事にしてみます。
html 風の記法を Rust の構文木に変換するため、syn クレートの Parse トレイトを実装してみたってやつです。
書き味的にはこんな感じ。
fn main() {
    let world = "Japan!!"
    render! {
        <div class="foo">
            <p>こんにちは! {world}</p>
        </div>
    }.to_string()
}
もちろんこのブログにも搭載しています。余談ですがこのブログは markdown parser も作っていて、ネストした syntax の tokenize に苦戦しました。markdown parser くらい自分にかかれば〜とか思ってたから少し反省してます。謙虚に生きる。
既存実装
別にこれ自体に新規性はなくて、すでにある似た実装だとこういうのがあります。
いずれも同様の手法を用いていて、完全上位互換(互換はないんだけど)です。
コード
全部で 300 行くらいで短い実装になってます。あんまり余計なもの積んでないから。
Render 構造体: 全体を表すノード
まず、最上位の構造体 Render はテンプレート全体を表します。
Parse トレイトを実装し、ParseStream から token を順に読み込んで 
Vec
 に格納しています。
#[derive(Debug)]
struct Render {
    tokens: Vec<Token>,
}
impl Parse for Render {
    fn parse(input: ParseStream) -> Result<Self> {
        let mut tokens = Vec::new();
        while !input.is_empty() {
            let token = input.parse::<Token>()?;
            tokens.push(token);
        }
        Ok(Render { tokens })
    }
}
この部分は単純に入力を最後まで読み取って Token 列に変換するだけのループですが、Token 自体が再帰的な構造(タグの入れ子や text node)を持つため、ここでは「字句解析」ではなく「構文解析」に近いことをやっています。
Token の parse: tag、text、brace の分岐
次に、各 token の実際の構文解析を行う部分です。Token が html ライクなタグなのか、{}で囲まれた式なのか、単なるテキストなのかを動的に判定しています。
impl Parse for Token {
    fn parse(input: ParseStream) -> Result<Self> {
        let mut input = input;
        let tokenizer = Tokenizer::new();
        if input.peek(Token![<]) {
            let open = input.parse::<Token![<]>()?;
            let span = open.span();
            if input.peek(Token![/]) {
                return tokenizer.close(&mut input, span);
            } else {
                return tokenizer.open(&mut input, span);
            }
        }
        if input.peek(Brace) {
            return tokenizer.braced(&mut input);
        }
        tokenizer.text(&mut input)
    }
}
この実装のポイントは、input.peek を使って次の token を覗き見し、処理を分岐している点です。
具体的には、以下のようなルールで判定を行っています。
上記のいずれにも該当しない場合は、プレーンテキストとして扱います。
また、詳細な処理は Tokenizer に委譲しており、各構文要素(tag, expr など)が独立して拡張しやすい構成になっています。
例: attribute の parse
具体的な parse 部分について少し紹介。
例えば attribute の parser はこんな感じになってます。Token を拾う形で実装していて、それ以外の部分は普通に他の形式言語の parser と同じような形で順番に前を拾いに行く形になってます。
fn parse_attributes(self, input: &mut ParseStream) -> Result<Vec<Attribute>> {
    let mut attributes = Vec::new();
    while !(input.peek(Token![>]) || input.peek(Token![/])) {
        let mut key_ts = TokenStream::new();
        while !input.peek(Token![=]) {
            if input.is_empty() || input.peek(Token![>]) || input.peek(Token![/]) {
                // キーがない=属性終端とみなして抜ける
                break;
            }
            let tt: TokenTree = input.parse()?;
            key_ts.extend(Some(tt));
        }
        // キーが空なら(例: 直で '>')属性読み取り終了
        if key_ts.is_empty() {
            break;
        }
        input.parse::<Token![=]>()?;
        // value: 次の属性開始 or タグ終端まで
        let mut value_ts = TokenStream::new();
        loop {
            if input.peek(Token![>]) || input.peek(Token![/]) {
                break;
            }
            // 次の属性キーの開始を "次tokenのさらに次が '=' になる" で検出
            if input.peek2(Token![=]) {
                break;
            }
            let tt: TokenTree = input.parse()?;
            value_ts.extend(Some(tt));
        }
        let value = syn::parse2::<Expr>(value_ts)?;
        // span は厳密に取らなくてもオッケー
        // 必要なら key_ts の最初の span を拾う
        attributes.push(Attribute {
            key: key_ts,
            value,
            span: Span::call_site(),
        });
    }
    Ok(attributes)
}
今後
やりたいことは結構あるんだけど(機能追加もその他も。あんまりロジック部分がよくできていないので)、パッと出てくるのだと SSG 部分はできてるので hydration(つまりクライアントで動くランタイム)できるようにしたいなと思ってます。
結構イメージは湧いていて、server で build するとき(SSG 部分)では handler をなんらかの識別子で置換しておいて、hydration するときはそれを拾って実態を付与するみたいなことをやるといいんじゃないかなあ。
ちなみに仮想 DOM はあんまり積む気はなくて、React っぽい思想が好きではあるけどあれを wasm でやると差分計算等で頭打ちになる気がするのでどちらかというと Svelte っぽいようなリアクティブを実装するといいんじゃないかなって思ってます。
要は Sveltekit の SSG mode みたいな、そんな構成にすると結構良さげなのかも?なんて妄想をしたり...。
久しぶりにこういう実装やって楽しかったし、暇な時間見つけてちまちま作りたいなーって思いました。