2026-05-04
どうもー。
最近このブログで使っている Rust 製の謎 UI ライブラリには、render! や css! という自作macroがあります。今回はこいつら用の LSP を作っていきます。
このライブラリは、このブログ用に作っている小さい Rust workspace です。Rust の proc-macro で HTML っぽい記法を書けるようにして、SSG では HTML 文字列を吐き、ブラウザでは WASM から DOM を組み立てる、みたいなことをやっています。
実装はこの repository にあります。 https://github.com/takurinton/crustal
中心にあるのは render! という macro。
let title = "hello";
render! {
<main>
<h1>{title}</h1>
</main>
}
native target ではこれを SSR 用の String にして、wasm32 target では web_sys で DOM element を作るコードに展開します。あとは css! で component と同じ場所に scoped style を書けるようにしていて、global_css! で reset style や CSS variables みたいな global style を注入できるようにしています。
React とか Leptos みたいなちゃんとした framework というより、このブログを Rust-first にするために作っている個人用の小さい rendering toolkit という感じです。過去の記事で render! や css! の話を書いてきたので、今回はその続きで editor support の話をします。
で、render! とか css! とかを書いていると、editor 体験がちょっと厳しくなってきました。
Rust の proc-macro はコンパイルすればエラーになるので、まあ最終的にはわかります。ただ、render! の閉じタグを間違えたとか、css! の &:hover の & を忘れたとか、そういうしょうもないミスで毎回 cargo check まで行くのはだるい。
ということで、自作macro用の LSP server を作りました。
今回作ったのはかなり小さい v1 です。VS Code extension とか Neovim plugin は作っていません。editor 側から stdio server として起動できればよくて、機能も diagnostics と hover だけです。
workspace に LSP server 用の binary crate を追加しました。
lsp/
├── Cargo.toml
└── src
├── lib.rs # source scan / diagnostics / hover
└── main.rs # stdio LSP server
対応している macro はこの 3 つ。
render!css!global_css!できることはこんな感じ。
render! の tag mismatch を diagnostics として出す= 忘れや value 忘れを diagnostics として出す{expr} の delimiter imbalance を diagnostics として出すcss! / global_css! の brace imbalance や string literal の閉じ忘れを diagnostics として出すcss! で nested selector が & から始まっていない場合に diagnostics を出す{expr}、css!、global_css! に hover を出す例えばこういうのを書くと、
render! {
<div class="post">
<h1>{title}</span>
</div>
}
</span> のところに「</h1> が欲しいよ」という diagnostics が出ます。
まあ普通の HTML validator みたいなものですね。Rust の中に書かれた自作macroだけを見ている、という感じです。
面白そうだから。
render! は Rust の token stream としては成立しているけど、中身は HTML っぽい別言語です。css! も同じで、Rust の文字列または token tree の中に CSS っぽい別言語が入っている。
こういうものは Rust Analyzer が深く面倒を見てくれるわけではありません。proc-macro を展開すれば最終的な Rust code にはなるけど、「<div> の対応する </div> がない」みたいな情報は展開後にはほぼ消えてしまう。
なので、macro の入力そのものを editor 上で見て、そこに対して diagnostics を出したい。こういうのは LSP でやるのがよい。
Rust の LSP 実装だと、tower-lsp と lsp-server あたりが候補になります。
今回は lsp-server を使いました。
理由は単純で、v1 では async runtime を持ち込みたくなかったからです。てかずっと不要なのでは。それはさておき、やることは document を受け取って text を保持して、hover request が来たら source を scan して返すだけ。
lsp-server は rust-analyzer でも使われている低レベルな crate で、stdio の message loop を自分で書く感じ。
server 側はだいたいこういう形になります。
fn main() {
let (connection, io_threads) = Connection::stdio();
let capabilities = ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)),
..ServerCapabilities::default()
};
let initialize_params = connection
.initialize(serde_json::to_value(capabilities).unwrap())
.unwrap();
let _: InitializeParams = serde_json::from_value(initialize_params).unwrap_or_default();
let mut server = Server::default();
while let Ok(message) = connection.receiver.recv() {
match message {
Message::Request(request) => {
if connection.handle_shutdown(&request).unwrap() {
break;
}
server.handle_request(&connection, request);
}
Message::Notification(notification) => {
server.handle_notification(&connection, notification)
}
Message::Response(_) => {}
}
}
drop(connection);
io_threads.join().unwrap();
}
このくらいなら同期処理で十分そう。
ただ、低レベルなぶん LSP の protocol の勘所を間違えると普通にハマります。これは後で書く。
大きく分けると 3 層にしました。
1. LSP server
2. document store
3. source analyzer
LSP server は stdio で message を受けて、didOpen、didChange、didClose、hover を dispatch するだけ。
document store は Url -> String の map です。v1 は full sync だけ対応なので、差分適用はしません。didChange が来たら document 全体を置き換える。
source analyzer は LSP から切り離しました。analyze(text) で diagnostics を返し、hover_at(text, position) で hover を返す。
pub fn analyze(text: &str) -> Analysis {
let line_index = LineIndex::new(text);
let invocations = scan_macros(text);
let mut diagnostics = Vec::new();
for invocation in &invocations {
match invocation.kind {
MacroKind::Render => {
diagnose_render(text, &line_index, invocation, &mut diagnostics, None)
}
MacroKind::Css | MacroKind::GlobalCss => {
diagnose_css(text, &line_index, invocation, &mut diagnostics, None)
}
}
}
Analysis { diagnostics }
}
こうしておくと、LSP の protocol を通さずに parser と diagnostics の unit test が書けます。LSP は JSON-RPC なので、全部を e2e test しようとすると結構面倒。なので core logic はただの関数に寄せました。
今回一番考えたのはここです。
この自作macroにはすでに render! の proc-macro parser があります。syn::parse::Parse を実装して、RenderToken にして、それを SSR codegen と client codegen に渡しています。
じゃあ LSP でもそれを使えばいいじゃん、という話になるんだけど、v1 ではやりませんでした。理由は、LSP が欲しい情報と proc-macro が欲しい情報が違うからです。
proc-macro が欲しいのは「正しい入力を Rust code に変換するための AST」です。基本的には parse に失敗したら compile error にすればいい。壊れた入力をなるべく読んで、壊れた場所に range を付けて、複数 diagnostics を返す、みたいなことはあんまり向いていない。
LSP が欲しいのは「壊れている途中の source に対する情報」です。editor で書いている最中は、閉じタグも閉じ brace も普通に存在しません。その状態で server が panic したり、最初の error で全部諦めたりすると体験が悪い。
あと LSP では Range が必要です。どの byte がどの line/character に対応するかを自分で追う必要がある。proc-macro の Span は便利だけど、LSP の range としてそのまま使えるものではありません。
なので、LSP 側では source-oriented な scanner を別で持つことにしました。
最初に Rust source 全体から render!、css!、global_css! を探します。
対応している呼び出し方はこのへん。
render! { <div></div> }
render!(<div></div>)
render![<div></div>]
css!("color: red;")
css! { color: red; }
global_css!("body { margin: 0; }")
global_css! { body { margin: 0; } }
実装はかなり素朴です。identifier を読んで、次が ! で、その次が { / ( / [ だったら macro invocation とみなす。
fn scan_macros(text: &str) -> Vec<MacroInvocation> {
let bytes = text.as_bytes();
let mut invocations = Vec::new();
let mut i = 0;
while i < bytes.len() {
if let Some(next) = skip_rust_trivia(text, i) {
i = next;
continue;
}
if !is_ident_start(bytes[i]) {
i += 1;
continue;
}
let ident_start = i;
i += 1;
while i < bytes.len() && is_ident_continue(bytes[i]) {
i += 1;
}
let ident = &text[ident_start..i];
let kind = match ident {
"render" => MacroKind::Render,
"css" => MacroKind::Css,
"global_css" => MacroKind::GlobalCss,
_ => continue,
};
let mut j = skip_ws(text, i);
if bytes.get(j) != Some(&b'!') {
continue;
}
j = skip_ws(text, j + 1);
let Some(&open) = bytes.get(j) else {
continue;
};
if !matches!(open, b'{' | b'(' | b'[') {
continue;
}
let close = matching_close(open);
let (end, closed) = scan_balanced(text, j, open, close);
invocations.push(MacroInvocation {
kind,
name_range: ByteRange::new(ident_start, i),
body: ByteRange::new(j + 1, end.saturating_sub(usize::from(closed))),
whole: ByteRange::new(ident_start, end),
closed,
});
i = end.max(j + 1);
}
invocations
}
ここで地味に重要なのが skip_rust_trivia です。
文字列やコメントの中に render! と書いてあっても、それは macro invocation ではありません。scan 中は string literal、line comment、block comment を飛ばします。
let _ = "render! { <div></div> }"; // これは無視したい
こういうのを拾ってしまうと diagnostics がめちゃくちゃになる。
macro body を見つけるには delimiter の対応を見る必要があります。
render! { ... } の中には Rust expression の { ... } も出てくるし、文字列の中に } が入ることもある。
render! {
<p>{ format!("hello }") }</p>
}
単純に最初の } まで読む、みたいな実装だと普通に壊れます。
ここでは depth を持って scan しています。
fn scan_balanced(text: &str, start: usize, open: u8, close: u8) -> (usize, bool) {
let mut depth = 0usize;
let mut i = start;
while i < text.len() {
if let Some(next) = skip_rust_trivia(text, i) {
i = next;
continue;
}
let byte = text.as_bytes()[i];
if byte == open {
depth += 1;
} else if byte == close {
depth = depth.saturating_sub(1);
if depth == 0 {
return (i + 1, true);
}
}
i += 1;
}
(text.len(), false)
}
これもちゃんとした Rust parser ではないんだけど、v1 の LSP diagnostics には十分です。少なくとも、このブログで使っている render! / css! の入力を editor 上で見るぶんにはこれで困らなそう。
render! は HTML-like な token を stack で見ています。
open tag が来たら stack に積む。close tag が来たら stack の top と比較する。違っていたら mismatched closing tag。最後に stack に残っていたら missing closing tag。かなり素直なやつです。
let mut stack: Vec<(String, ByteRange, bool)> = Vec::new();
やっていることは本当にこれだけです。
render! {
<div>
<span>hello</div>
}
この場合、</div> を読んだ時点で stack top は span なので、expected </span> という diagnostics を出します。その後 span は閉じられていないので、missing closing tag も出る。
少しうるさい気もするけど、v1 では情報量を優先しています。diagnostics を賢く merge するのは後でやればいいかなという感じ。
attribute はこのへんを見ています。
<div class="post" id={post_id}>
= がない= があるのに value がないself-closing は今の render! parser が対応していないので、LSP でも unsupported として出しています。
css! は css!("...") と css! { ... } の両方を見ます。
文字列 literal の場合は文字列の中身を取り出します。token-style の場合は macro body を trim して CSS text として扱う。
見ているのはこのへん。
color: red みたいに : があるのに ; がない declarationcss! で nested selector block が & から始まっていないケース最後のやつは css! の設計に由来しています。
css! {
color: red;
&:hover {
color: blue;
}
}
css! は generated class を & に展開するので、nested selector は & から始まってほしい。
逆にこういうのはダメです。
css! {
h1 {
font-size: 2rem;
}
}
これは .css-xxxxxx h1 にしたいのか、global な h1 にしたいのか曖昧です。v1 では nested selector blocks in css! must start with & と出すようにしました。
global に書きたいなら global_css! を使えばいい。
hover は気持ち程度ですが、あると便利。
render! の tag に hover すると、SSR では HTML tag を出して、client では DOM element を作るよ、という説明を出します。
attribute に hover すると、SSR では ToString して key="value" にし、client では set_attribute するよ、という説明を出します。
{expr} に hover すると、SSR と client で挙動が違うことを出します。
SSR appends ToString::to_string(&expr);
the client renderer binds through Bindable::bind.
これは結構この自作macroっぽいポイントだと思っています。render! の {expr} は単なる text interpolation ではなく、wasm32 target では Bindable::bind に落ちる可能性がある。
css! の hover では generated class name と selector preview を出します。class name は proc-macro 側と同じ FNV-1a の lower 24 bit です。
fn fnv1a_hash(s: &str) -> u32 {
let mut hash: u32 = 2166136261;
for byte in s.bytes() {
hash ^= byte as u32;
hash = hash.wrapping_mul(16777619);
}
hash
}
同じ CSS text なら LSP と proc-macro で同じ css-xxxxxx が出るので、editor 上で「この class name になるのね」が見える。
実装で一番しょうもなくハマったのは、LSP の中身というより stdio server の終了処理です。
最初は message loop を抜けたあとにそのまま io_threads.join() していました。すると shutdown を送っても process が終わらなくなった。原因は Connection を持ったまま join していたことで、sender/receiver が生きているので、IO thread 側が終われない。先に drop(connection) してから join する必要がありました。
drop(connection);
io_threads.join().unwrap();
あともう一つ、initialize response も間違えました。
Connection::initialize に InitializeResult を渡してしまって、response がこうなっていました。
{
"result": {
"capabilities": {
"capabilities": {
"hoverProvider": true,
"textDocumentSync": 1
}
}
}
}
ServerCapabilities を渡すべきところが capabilities.capabilities になっていた。
connection.initialize(serde_json::to_value(capabilities).unwrap())?;
こういうのは cargo check では当然わかりません。実際に Content-Length frame を組んで initialize、didOpen、hover、shutdown を流して確認しました。
テストは、LSP server 全体を JSON-RPC 経由で全部叩くよりも、壊れやすい層ごとに分ける方針にしました。
今回の実装で壊れやすいところはだいたい 3 つ。
1. Rust source から macro invocation を見つけるところ
2. macro body を読んで diagnostics / hover の材料を作るところ
3. LSP の document lifecycle と request handler に接続するところ
なので、テストもこの 3 つに分けています。
まず scanner は、Rust source の中から render!、css!、global_css! を見つける層です。
ここは見た目より壊れやすいです。例えば、文字列の中に render! が入っている場合は macro invocation として扱ってはいけない。
let s = "render! { <div></div> }";
逆に、macro body の中に Rust expression が入っていて、その expression の中に brace や quote が出てくる場合は正しく飛ばす必要があります。
render! {
<p>{ format!("hello }") }</p>
}
なので scanner のテストでは、単に macro を見つけられるかではなく、
あたりを見ています。
ここが壊れると diagnostics 以前に対象範囲がずれて全部おかしくなるので、LSP の request test よりも優先度が高い。
diagnostics は render! と CSS 系で分けています。
render! は stack の挙動が中心なので、
= 忘れ、value 忘れを拾える{expr} の delimiter imbalance を拾えるを見ています。
これは HTML parser の正しさをテストしたいわけではありません。自作macroの render! grammar と LSP の recovery 方針が合っているかを見ています。
例えば mismatched closing tag の場合、閉じタグ側に diagnostics を出すのか、開きタグ側に出すのか、両方出すのかは実装の方針です。v1 では「今見ている close tag が期待と違う」ことを close tag 側に出し、最後に stack に残った open tag も missing close として出しています。少し冗長だけど、editor 上では原因を追いやすい。
CSS 系は、proc-macro 側の CSS parser と完全一致させるためのテストではありません。LSP として早めに気づきたいミスに絞っています。
&:hover のような nested selector@media: があるのに ; がない declarationcss! で & なしの nested block特に css! の & なし nested block は、この macro の設計そのものに関わるので明示的にテストしています。css! は scoped class を生成する macro なので、nested selector は & を起点にしてほしい。global に書くなら global_css! を使う、という境界を diagnostics と test の両方で固定している。
hover は壊れても build は壊れないけど、editor support としては地味に重要。
ここでは文章そのものを厳密に固定しすぎないようにしつつ、意味のある token が含まれるかを見ています。
render! tag hover に自作macroの render element の説明が出るset_attribute の説明が出る{expr} hover に Bindable::bind の説明が出るcss! hover に proc-macro と同じ FNV-1a hash の class name が出るglobal_css! hover に style id が出るNone になるcss! の hash は特に大事です。LSP 側だけ違う hash を出してしまうと hover が嘘になります。なので color: red; が css-044b6e になることを固定しています。
LSP の protocol そのものは lsp-server と lsp-types に寄せています。ここを自前で頑張ってテストしすぎても、JSON-RPC の fixture が増えてしんどくなるだけです。
なので v1 では、document store を直接叩く形で lifecycle を見ています。
didOpen 相当で diagnostics が出るdidChange 相当で diagnostics が置き換わるhover が document store 経由で返るdidClose 相当で document が消えるこれで「LSP handler が document text をどう保持して analyzer に渡すか」は確認できます。
一方で、stdio の framing や initialize/shutdown の挙動は unit test ではなく、実際に Content-Length frame を流して手元で確認しました。ここは mock より実プロセスを起動した方が早いし、実際 shutdown 後に process が終わらない問題や initialize response の二重ネストはそれで見つかりました。
自作macro用の小さい LSP server を作りました。
まだ completion も formatting も code action もありません。diagnostics と hover だけです。ただ、自作 macro を日常的に書くなら、このくらいでもかなり体験が変わります。
今回の設計でよかったと思っているのは、proc-macro の parser を無理に共有しなかったことです。compiler が欲しい parser と editor が欲しい parser は似ているけど違うんでね。前者は正しい入力を codegen できればよくて、後者は壊れた入力をできるだけ読んで、場所つきで説明しないといけない。
今後やるなら、まずは vscode の extension か Neovim 向けの設定例を用意したいです。あとは completion。render! の tag や attribute の補完よりも、css! の nested selector まわりの補助があると便利そう。
自分のプロンプト力が弱いのか、codexに文章書かせたらcodexらしさ全開になってしまったw
自分はサンプルコード + 大まかな枠組み、それから400文字くらいの文章を書いたわけなんだけど、拙い文章の詳細を埋めてくれるのは助かるけどあまりに自分のアイデンティティみたいなのが損なわれてしまって難しい。(自分で書きなさいよ、的な話ではある)