Skip to main content

lattice_syntax/
wasm_grammar.rs

1//! Loading grammars from WebAssembly, and the stores parsers need to run
2//! them.
3//!
4//! Design:
5//! [`plugin-languages.md`](../../../docs/dev/architecture/plugin-languages.md) §2.5.
6//! Slice plan: LG.3b.
7//!
8//! [`load`] turns a wasm side module into an ordinary
9//! [`tree_sitter::Language`], which is the fact the whole design rests on:
10//! downstream, `HighlightConfiguration`, folds, injections, indents and
11//! the incremental reparse path cannot tell where a grammar came from.
12//!
13//! ## The one thing that is not transparent
14//!
15//! A wasm-backed `Language` can only be used by a `Parser` that owns a
16//! [`WasmStore`]. A parser without one fails `set_language` outright
17//! ("Failed to load the Wasm store"), so this is not something that can be
18//! left to chance. The costs, measured (see `benchmarks.md`):
19//!
20//! | operation | cost |
21//! |---|---|
22//! | `WasmStore::new` | **5.1 ms** — it compiles tree-sitter's wasm libc |
23//! | `load_language` | **102 ms** — Cranelift compiling the grammar |
24//! | binding an already-loaded `Language` into another store | **68 µs** |
25//!
26//! Three properties fall out of that table, and each is load-bearing:
27//!
28//! 1. **`load_language` is not cached by the `Engine`.** Loading the same
29//!    bytes into a second store pays the full 102 ms again. So a grammar
30//!    is loaded exactly once, at registration, and the `Language` is kept.
31//! 2. **A `Language` outlives the store it was loaded from**, and is
32//!    portable into any other store for 68 µs — three orders of magnitude
33//!    cheaper than recompiling it. So [`load`] drops its loading store
34//!    immediately; nothing has to keep it alive.
35//! 3. **A `Tree` survives its parser's store being taken back**, which is
36//!    what makes the pooled store below safe.
37//!
38//! And one constraint that is easy to violate and fails opaquely: **every
39//! store must share the engine the grammar was compiled with.** A `Language`
40//! from engine A cannot be instantiated in a store from engine B —
41//! `WasmStore::new` returns a bare `Wasm` error with no explanation. That is
42//! why [`engine`] is a process-wide `OnceLock` rather than something callers
43//! pass in: there is exactly one engine, so the mistake is unavailable.
44//!
45//! ## Two strategies, because two call shapes
46//!
47//! [`set_language`] gives a parser its **own** store and leaves it there.
48//! That is right for [`crate::Syntax`]'s long-lived parser: 5 ms once per
49//! buffer whose language is wasm-backed, off the keystroke path, and the
50//! store must stay because the parser needs it for every later reparse.
51//!
52//! [`with_pooled_store`] **lends** a thread-local store for the duration of
53//! one parse and takes it back. That is right for injection highlighting,
54//! which builds a fresh `Parser` **per injection, per highlight call** — a
55//! markdown file with twenty fenced blocks would otherwise pay 20 × 5 ms
56//! on every highlight. Property 3 above is why taking the store back after
57//! the parse is sound.
58//!
59//! Native grammars touch none of this: both entry points check
60//! `Language::is_wasm` first and do nothing when it is false.
61
62use std::cell::RefCell;
63use std::sync::OnceLock;
64
65use tree_sitter::{Language, Parser, WasmStore, wasmtime::Engine};
66
67/// The engine every grammar store shares.
68///
69/// tree-sitter's own wasmtime (36), not the plugin host's (46) — they are
70/// different crates and this must be the former's type. LG.0 proved the
71/// two runtimes coexist, including under a forced guest trap in both
72/// initialisation orders.
73///
74/// Created on first use, which means a session that loads no language
75/// plugin never creates an engine and therefore never installs the second
76/// runtime's signal handlers.
77fn engine() -> &'static Engine {
78    static ENGINE: OnceLock<Engine> = OnceLock::new();
79    ENGINE.get_or_init(Engine::default)
80}
81
82/// Compile a grammar from a wasm side module.
83///
84/// **~102 ms.** Call once, at registration, on an off-thread task — never
85/// on the keystroke or frame path. The returned `Language` is what gets
86/// kept; the store used to load it is dropped here, which is sound
87/// because the `Language` owns the compiled module.
88///
89/// `name` must be the grammar's tree-sitter name — the suffix of its
90/// `tree_sitter_<name>` export — or the module will load but expose no
91/// entry point.
92pub fn load(name: &str, bytes: &[u8]) -> Result<Language, String> {
93    let mut store =
94        WasmStore::new(engine()).map_err(|e| format!("wasm store for '{name}': {e}"))?;
95    store
96        .load_language(name, bytes)
97        .map_err(|e| format!("load wasm grammar '{name}': {e}"))
98}
99
100/// Bind `lang` into `parser`, giving the parser its own store first when
101/// the grammar is wasm-backed.
102///
103/// The store stays in the parser for its lifetime, because the parser
104/// needs it for every subsequent parse — this is not a lending API.
105pub(crate) fn set_language(parser: &mut Parser, lang: &Language) -> Result<(), String> {
106    if lang.is_wasm() {
107        let store = WasmStore::new(engine()).map_err(|e| format!("wasm store: {e}"))?;
108        parser
109            .set_wasm_store(store)
110            .map_err(|e| format!("attach wasm store: {e}"))?;
111    }
112    parser.set_language(lang).map_err(|e| e.to_string())
113}
114
115thread_local! {
116    /// One store per thread, created on first use by a wasm grammar and
117    /// reused thereafter. `Syntax` runs on `spawn_blocking` workers, so
118    /// this is a handful of stores per process rather than one per buffer.
119    static POOLED: RefCell<Option<WasmStore>> = const { RefCell::new(None) };
120}
121
122/// Run `f` with `parser` bound to `lang`, lending a pooled store when the
123/// grammar needs one and returning it afterwards.
124///
125/// For short-lived parsers only — the store goes back to the pool when
126/// this returns, so anything `f` produces must not need it. A `Tree` does
127/// not (proven, and the reason this is safe); a parser kept for later
128/// reparses does, which is what [`set_language`] is for.
129///
130/// Returns `None` if the language could not be bound at all.
131pub(crate) fn with_pooled_store<R>(
132    parser: &mut Parser,
133    lang: &Language,
134    f: impl FnOnce(&mut Parser) -> R,
135) -> Option<R> {
136    if !lang.is_wasm() {
137        parser.set_language(lang).ok()?;
138        return Some(f(parser));
139    }
140
141    let store = POOLED
142        .with(|p| p.borrow_mut().take())
143        .or_else(|| WasmStore::new(engine()).ok())?;
144    parser.set_wasm_store(store).ok()?;
145    // From here the store is inside `parser` and must come back out on
146    // every path, or the pool silently empties and each later injection
147    // pays 5 ms to build a fresh one.
148    let bound = parser.set_language(lang).is_ok();
149    let out = bound.then(|| f(parser));
150    if let Some(store) = parser.take_wasm_store() {
151        POOLED.with(|p| *p.borrow_mut() = Some(store));
152    }
153    out
154}
155
156#[cfg(test)]
157mod tests {
158    #![allow(clippy::unwrap_used, clippy::panic)]
159    use super::*;
160
161    /// The artefact the LG.1 tests already build. Absent means the
162    /// prerequisites are missing, which is a skip rather than a failure.
163    fn markdown_wasm() -> Option<Vec<u8>> {
164        let p = concat!(
165            env!("CARGO_MANIFEST_DIR"),
166            "/../../target/wasm-grammars/tree-sitter-markdown.wasm"
167        );
168        std::fs::read(p).ok()
169    }
170
171    #[test]
172    fn a_wasm_grammar_loads_and_binds_to_a_fresh_parser() {
173        let Some(bytes) = markdown_wasm() else {
174            eprintln!("SKIPPED — run scripts/build-wasm-grammar.sh first");
175            return;
176        };
177        let lang = load("markdown", &bytes).expect("loads");
178        assert!(lang.is_wasm());
179
180        let mut parser = Parser::new();
181        set_language(&mut parser, &lang).expect("binds");
182        let tree = parser.parse("# hi\n\n- a\n", None).expect("parses");
183        assert!(!tree.root_node().has_error());
184    }
185
186    /// The property the pool depends on: the borrowed store goes back, so
187    /// a second call does not build another one. Checked by observing that
188    /// the pool is non-empty afterwards — the cheap proxy for "we did not
189    /// leak it into the parser".
190    #[test]
191    fn the_pooled_store_is_returned_after_use() {
192        let Some(bytes) = markdown_wasm() else {
193            eprintln!("SKIPPED — run scripts/build-wasm-grammar.sh first");
194            return;
195        };
196        let lang = load("markdown", &bytes).expect("loads");
197
198        let mut parser = Parser::new();
199        let sexp = with_pooled_store(&mut parser, &lang, |p| {
200            p.parse("# hi\n", None).map(|t| t.root_node().to_sexp())
201        })
202        .expect("bound")
203        .expect("parsed");
204        assert!(sexp.starts_with("(document"));
205
206        assert!(
207            POOLED.with(|p| p.borrow().is_some()),
208            "the store must return to the pool, or every injection pays ~6 ms"
209        );
210
211        // And a second use works off the returned store.
212        let mut parser2 = Parser::new();
213        let again = with_pooled_store(&mut parser2, &lang, |p| p.parse("## two\n", None).is_some());
214        assert_eq!(again, Some(true));
215    }
216
217    /// A native grammar must not touch any of this — no store created, no
218    /// pool entry, nothing.
219    #[test]
220    fn native_grammars_never_create_a_store() {
221        let lang: Language = tree_sitter_json::LANGUAGE.into();
222        assert!(!lang.is_wasm());
223
224        let mut parser = Parser::new();
225        set_language(&mut parser, &lang).expect("binds");
226        assert!(
227            parser.take_wasm_store().is_none(),
228            "a native language must not have been given a wasm store"
229        );
230
231        let mut parser2 = Parser::new();
232        let ok = with_pooled_store(&mut parser2, &lang, |p| p.parse("{}", None).is_some());
233        assert_eq!(ok, Some(true));
234    }
235}