lattice_plugin_host/language_host.rs
1//! The `language` guest→host registration seam (LG.3c).
2//!
3//! Design:
4//! [`plugin-languages.md`](../../../docs/dev/architecture/plugin-languages.md) §2.2.
5//!
6//! A language-contributing plugin implements the `language-plugin` world: it
7//! **imports** the `language` API (`register-language`) and **exports**
8//! `register-languages`, which the host calls once to drive declaration. The
9//! `help-plugin` precedent, shape for shape.
10//!
11//! ## What this module does NOT do
12//!
13//! It does not compile the grammar and it does not touch `lattice-syntax`.
14//! The seam collects plain [`LanguageSpec`] values — bytes and query strings —
15//! and hands them back; `lattice-plugin-loader` turns them into a real
16//! language. That keeps the dependency pointing the way it already does: the
17//! loader is the crate that knows about the editor's native registries, the
18//! host is the crate that knows about wasm.
19//!
20//! It matters more here than it did for `help`, because compiling a grammar
21//! costs ~100 ms. Doing it inside the guest call would hold the guest's
22//! `Store` alive across a Cranelift compile for no reason; doing it in the
23//! drain, after the store is dropped, is both cheaper and the shape the rest
24//! of the loader already has.
25//!
26//! ## The name is NOT namespaced, unlike `help`
27//!
28//! A help topic is auto-prefixed with the plugin id so a guest cannot squat
29//! `:help buffers`. A language deliberately is not, and the difference is
30//! principled rather than an oversight:
31//!
32//! - A language's name has to match its grammar's `tree_sitter_<name>` export
33//! and the `#+BEGIN_SRC <name>` / fenced-code-block identifier users
34//! already type. `org-plugin.org` would match neither.
35//! - The collision it would defend against is instead refused outright:
36//! `lattice_syntax` rejects any name a bundled language already uses, and
37//! rejects a name a *different* plugin has already claimed. So the property
38//! namespacing buys for `help` is bought here by refusal, which is the
39//! right trade when the name is load-bearing rather than decorative.
40
41use crate::{
42 Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier, arm_store,
43 classify_trap,
44};
45
46/// One language a guest declared, ready for the loader to compile and
47/// register.
48///
49/// Plain data. The grammar is still bytes here — deliberately, see the module
50/// docs.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct LanguageSpec {
53 pub name: String,
54 /// The grammar's `tree_sitter_<x>` export name. Defaults to `name`; they
55 /// differ when a grammar's upstream name is not the language's (lattice's
56 /// own `sql` rides the `sequel` grammar).
57 pub grammar_name: String,
58 /// Lower-cased, leading dots stripped, blanks dropped.
59 pub extensions: Vec<String>,
60 /// The grammar, compiled to wasm, exactly as the guest supplied it.
61 pub grammar: Vec<u8>,
62 pub highlights: Option<String>,
63 pub folds: Option<String>,
64 pub injections: Option<String>,
65 pub indents: Option<String>,
66 pub textobjects: Option<String>,
67 /// H.2: `(pattern, hide-groups)` as declared, uncompiled.
68 ///
69 /// Not compiled here for the same reason the queries are not: this
70 /// runs with the guest's store alive, and compilation belongs in the
71 /// loader's drain beside the grammar. The shape checks that DO run
72 /// here are the ones that need no engine — see [`validate_language`].
73 pub conceal_rules: Vec<(String, Vec<u32>, Option<String>)>,
74}
75
76/// The wire record, as bindgen generates it. Taken whole by
77/// [`validate_language`] rather than splatted into nine positional
78/// arguments — the fields are all `String`/`Option<String>`, so a
79/// positional signature is exactly the shape a caller silently gets wrong.
80pub use bindings::lattice::plugin_host::language::LanguageSpec as WitLanguageSpec;
81
82pub(crate) mod bindings {
83 wasmtime::component::bindgen!({
84 world: "language-plugin",
85 path: "../lattice-wit/wit",
86 // Same async linker as WASI + the host func, like `help-plugin`.
87 // Registration is off every hot path, so async costs nothing.
88 exports: { default: async },
89 with: {
90 "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
91 "lattice:plugin-host/project": crate::lattice::plugin_host::project,
92 },
93 });
94}
95
96/// Validate a guest's language declaration, or reject it.
97///
98/// Guest output is untrusted, and the rejections here are the ones a buggy
99/// guest actually produces. Each is an `Err` back to the guest rather than a
100/// trap, so one bad language costs itself and the plugin's others still
101/// register.
102///
103/// Note what is NOT validated here: whether the grammar bytes are a loadable
104/// wasm module, and whether the queries compile. Both need
105/// `lattice-syntax` and both happen in the loader's drain — checking them
106/// here would mean either duplicating that logic or holding the guest's store
107/// alive across a ~100 ms Cranelift compile.
108pub fn validate_language(raw: WitLanguageSpec) -> Result<LanguageSpec, String> {
109 let WitLanguageSpec {
110 name,
111 grammar_name,
112 grammar,
113 extensions,
114 highlights,
115 folds,
116 injections,
117 indents,
118 textobjects,
119 conceal_rules,
120 } = raw;
121 let name = name.trim();
122 if name.is_empty() {
123 return Err("register-language: name is empty".to_string());
124 }
125 // The name keys the query cache, so anything that is not a plain
126 // identifier is a mistake worth catching at the boundary.
127 let plain = |s: &str| {
128 s.chars()
129 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
130 };
131 if !plain(name) {
132 return Err(format!(
133 "register-language({name}): name must be alphanumeric, '_' or '-'"
134 ));
135 }
136 // The grammar name must additionally be able to appear in
137 // `tree_sitter_<x>`, so a confusing "module has no entry point" after a
138 // ~100 ms compile becomes a legible rejection now.
139 let grammar_name = grammar_name
140 .map(|g| g.trim().to_string())
141 .filter(|g| !g.is_empty())
142 .unwrap_or_else(|| name.to_string());
143 if !plain(&grammar_name) {
144 return Err(format!(
145 "register-language({name}): grammar-name '{grammar_name}' must be \
146 alphanumeric, '_' or '-' — it has to match the grammar's \
147 tree_sitter_<grammar-name> export"
148 ));
149 }
150
151 let extensions: Vec<String> = extensions
152 .iter()
153 .map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase())
154 .filter(|e| !e.is_empty())
155 .collect();
156 if extensions.is_empty() {
157 return Err(format!(
158 "register-language({name}): no file extensions, so the language \
159 could never be selected"
160 ));
161 }
162
163 if grammar.is_empty() {
164 return Err(format!("register-language({name}): grammar is empty"));
165 }
166
167 // Blank query sources are normalised away rather than passed on as
168 // `Some("")`. The design says an absent query means the feature is
169 // unavailable, and `Some(" ")` should mean the same thing rather than
170 // compiling to an empty query that silently matches nothing.
171 let blank_to_none = |s: Option<String>| s.filter(|q| !q.trim().is_empty());
172
173 Ok(LanguageSpec {
174 name: name.to_string(),
175 grammar_name,
176 extensions,
177 grammar,
178 highlights: blank_to_none(highlights),
179 folds: blank_to_none(folds),
180 injections: blank_to_none(injections),
181 indents: blank_to_none(indents),
182 textobjects: blank_to_none(textobjects),
183 // Shape only. A blank pattern or an empty `hide` cannot become a
184 // working rule under any engine, so refusing them here spares the
185 // loader a compile and gives the guest the reason while it is
186 // still alive to log it. Everything that needs the regex engine —
187 // does it compile, does group N exist — happens at compile time
188 // in `lattice-syntax`, where a refusal drops one rule rather than
189 // failing the language.
190 conceal_rules: conceal_rules
191 .into_iter()
192 .filter_map(|r| {
193 if r.pattern.trim().is_empty() {
194 tracing::warn!(language = name, "conceal rule dropped: empty pattern");
195 return None;
196 }
197 if r.hide.is_empty() {
198 tracing::warn!(
199 language = name,
200 pattern = %r.pattern,
201 "conceal rule dropped: hide is empty, so it would hide nothing"
202 );
203 return None;
204 }
205 Some((r.pattern, r.hide, r.slot))
206 })
207 .collect(),
208 })
209}
210
211impl PluginHost {
212 /// Instantiate a `language-plugin` component under its capability grant,
213 /// drive its `register-languages` export once, and return the host-issued
214 /// id plus the languages it declared.
215 ///
216 /// Mirror of [`spawn_help_plugin`](Self::spawn_help_plugin). Nothing about
217 /// the guest outlives this call: the bytes and query sources are already
218 /// across, so the `Store` is dropped when the function returns and parsing
219 /// never touches the guest again.
220 pub async fn spawn_language_plugin(
221 &self,
222 component: &Component,
223 manifest: &PluginManifest,
224 tier: TrustTier,
225 budget: PluginBudget,
226 ) -> Result<(crate::PluginId, Vec<LanguageSpec>), PluginHostError> {
227 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
228 for denied in &outcome.denied {
229 tracing::warn!(
230 plugin = %manifest.id,
231 capability = ?denied,
232 "language plugin loaded with a withheld capability (reduced function)"
233 );
234 }
235 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
236 let bindings =
237 bindings::LanguagePlugin::instantiate_async(&mut store, component, &self.linker)
238 .await
239 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
240
241 let id = self.alloc_id();
242 store.data_mut().log_ctx = self.log_ctx_for(id);
243
244 arm_store(&mut store, budget)?;
245 bindings
246 .call_register_languages(&mut store)
247 .await
248 .map_err(|source| PluginHostError::Trap {
249 func: "register-languages",
250 kind: classify_trap(&source),
251 source: source.into(),
252 })?;
253
254 let languages = std::mem::take(&mut store.data_mut().language_contributions);
255 Ok((id, languages))
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn wire(name: &str, exts: &[&str]) -> WitLanguageSpec {
264 WitLanguageSpec {
265 name: name.to_string(),
266 grammar_name: None,
267 grammar: vec![0, 1, 2],
268 extensions: exts.iter().map(|s| (*s).to_string()).collect(),
269 highlights: None,
270 folds: None,
271 injections: None,
272 indents: None,
273 textobjects: None,
274 conceal_rules: vec![],
275 }
276 }
277
278 fn spec(name: &str, exts: &[&str]) -> Result<LanguageSpec, String> {
279 validate_language(wire(name, exts))
280 }
281
282 /// H.2: the boundary keeps rules whose shape could work under any
283 /// engine and drops the two that could not, without failing the
284 /// language. Compilation — does the pattern parse, does group N
285 /// exist — happens in `lattice-syntax`, deliberately not here: this
286 /// runs with the guest's store alive.
287 #[test]
288 fn h2_shape_invalid_conceal_rules_drop_without_failing_the_language() {
289 use crate::language_host::bindings::lattice::plugin_host::language::ConcealRule;
290 let mut w = wire("org", &["org"]);
291 w.conceal_rules = vec![
292 ConcealRule {
293 pattern: r"(\[\[)([^]]+)(\]\])".to_string(),
294 hide: vec![1, 3],
295 slot: None,
296 },
297 ConcealRule {
298 pattern: " ".to_string(),
299 hide: vec![1],
300 slot: None,
301 },
302 ConcealRule {
303 pattern: "(x)".to_string(),
304 hide: vec![],
305 slot: None,
306 },
307 // Refused later, in lattice-syntax — the boundary has no
308 // engine and must not pretend to.
309 ConcealRule {
310 pattern: "(unclosed".to_string(),
311 hide: vec![1],
312 slot: None,
313 },
314 ];
315 let s = validate_language(w).expect("a bad rule must not fail the language");
316 assert_eq!(s.conceal_rules.len(), 2);
317 assert_eq!(s.conceal_rules[0].0, r"(\[\[)([^]]+)(\]\])");
318 assert_eq!(s.conceal_rules[1].0, "(unclosed");
319 }
320
321 #[test]
322 fn h2_a_language_declaring_no_conceal_rules_is_unchanged() {
323 assert!(spec("org", &["org"]).unwrap().conceal_rules.is_empty());
324 }
325
326 #[test]
327 fn grammar_name_defaults_to_the_language_name_but_may_differ() {
328 assert_eq!(spec("org", &["org"]).unwrap().grammar_name, "org");
329 // The bundled `sql`/`sequel` shape: the grammar's export name is not
330 // what users call the language.
331 let mut w = wire("sql", &["sql"]);
332 w.grammar_name = Some("sequel".to_string());
333 let s = validate_language(w).expect("accepted");
334 assert_eq!(s.name, "sql");
335 assert_eq!(s.grammar_name, "sequel");
336 // Blank means absent, not an empty export name.
337 let mut w = wire("sql", &["sql"]);
338 w.grammar_name = Some(" ".to_string());
339 assert_eq!(validate_language(w).unwrap().grammar_name, "sql");
340 }
341
342 #[test]
343 fn a_well_formed_language_converts() {
344 let s = spec("org", &[".ORG", "org_archive"]).expect("accepted");
345 assert_eq!(s.name, "org");
346 // Dots stripped, lower-cased.
347 assert_eq!(
348 s.extensions,
349 vec!["org".to_string(), "org_archive".to_string()]
350 );
351 }
352
353 #[test]
354 fn an_empty_name_is_rejected() {
355 assert!(spec("", &["org"]).is_err());
356 assert!(spec(" ", &["org"]).is_err());
357 }
358
359 /// The name has to match `tree_sitter_<name>`, so a name that cannot be
360 /// one is caught at the boundary rather than as an obscure "no entry
361 /// point" failure after a ~100 ms compile.
362 #[test]
363 fn a_name_that_cannot_be_a_c_symbol_is_rejected() {
364 for bad in ["my lang", "org!", "org.mode", "org/mode"] {
365 assert!(spec(bad, &["org"]).is_err(), "{bad} should be rejected");
366 }
367 for good in ["org", "org_mode", "tree-sitter-org", "c99"] {
368 assert!(spec(good, &["x"]).is_ok(), "{good} should be accepted");
369 }
370 }
371
372 #[test]
373 fn a_language_with_no_usable_extensions_is_rejected() {
374 assert!(spec("org", &[]).is_err());
375 // A lone dot and blanks normalise away to nothing, which is the same
376 // mistake spelled differently.
377 assert!(spec("org", &[".", " "]).is_err());
378 }
379
380 #[test]
381 fn an_empty_grammar_is_rejected() {
382 let mut w = wire("org", &["org"]);
383 w.grammar = Vec::new();
384 assert!(validate_language(w).is_err());
385 }
386
387 /// A blank query means the same thing as an absent one — the feature is
388 /// unavailable — rather than compiling to an empty query that matches
389 /// nothing and looks like a broken highlighter.
390 #[test]
391 fn blank_query_sources_normalise_to_absent() {
392 let mut w = wire("org", &["org"]);
393 w.highlights = Some(" \n\t ".to_string());
394 w.folds = Some(String::new());
395 w.textobjects = Some("(x) @y".to_string());
396 let s = validate_language(w).expect("accepted");
397 assert_eq!(s.highlights, None);
398 assert_eq!(s.folds, None);
399 assert_eq!(s.textobjects.as_deref(), Some("(x) @y"));
400 }
401}