Skip to main content

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}