Skip to main content

lattice_plugin_host/
grammar_host.rs

1//! The grammar-extension guest world (PH7.7b).
2//!
3//! A grammar plugin implements the `grammar-plugin` world: it **imports** the
4//! `grammar` register API (host-provided) and **exports** `register-grammar`
5//! (the host calls it once to drive registration) + the `grammar-callbacks`
6//! behaviors (`apply-*` / `parse-ex-args`, dispatched by callback-id). This
7//! module holds the **fourth `bindgen!`** (after `plugin`, `picker-source-plugin`,
8//! `completion-source-plugin`) for that world — the two-bindgen-with-shared-types
9//! trick (`with:` points `types` at the `plugin` world's generated module so a
10//! crossed value is the SAME Rust type `WitBoundary` round-trips, the PH7.3d
11//! precedent).
12//!
13//! **Fully synchronous (the PH7.7 fork).** Unlike picker/completion, a grammar
14//! `apply` resolves on the keystroke path — a motion must return inline to
15//! compose with its operator (async would break operator∘motion atomicity +
16//! dot-repeat/macros). So the `bindgen!` sets **no** `exports: { default: async }`:
17//! the `register-grammar` + `grammar-callbacks` exports are sync-callable from the
18//! dispatch thread, bounded by fuel + epoch (a Reflex-class budget, PH7.7c). No
19//! actor task — the sync trampoline calls the guest directly (PH7.7c).
20//!
21//! Registration flow: the host calls the guest's `register-grammar` export; the
22//! guest calls the imported `register-*` host functions; those record into the
23//! Store's [`GrammarContributions`] (via the `grammar::Host` impl on
24//! `PluginState`, `lib.rs`); after the export returns, the host drains the
25//! contributions and builds native `*Spec`s with trampoline `apply`s (PH7.7c).
26
27use crate::lattice::plugin_host::types::{
28    ActionSpec as WitActionSpec, ExCommandSpec as WitExCommandSpec, MotionSpec as WitMotionSpec,
29    OperatorSpec as WitOperatorSpec, TextObjectSpec as WitTextObjectSpec,
30};
31
32pub(crate) mod bindings {
33    wasmtime::component::bindgen!({
34        world: "grammar-plugin",
35        path: "../lattice-wit/wit",
36        // No `exports: { default: async }` — the grammar seam is SYNCHRONOUS
37        // (the PH7.7 fork): `register-grammar` + the `grammar-callbacks` `apply-*`
38        // exports are sync-callable from the dispatch thread, bounded by
39        // fuel/epoch. The `grammar` import's `register-*` host funcs are sync too
40        // (they only record into `PluginState`; they cannot trap).
41        with: {
42            // Reuse the `plugin` world's generated mirrors so a value crossing
43            // here is the same Rust type `WitBoundary` round-trips.
44            "lattice:plugin-host/types": crate::lattice::plugin_host::types,
45            // AP.0.1: `apply-action` takes a `borrow<document>`. Map the
46            // host-owned resource to `DocumentResource` (the backing built +
47            // unit-tested at PH7.3c) so bindgen emits the `HostDocument` trait
48            // the host implements + the sync-linker `add_to_linker`.
49            "lattice:plugin-host/buffer.document": crate::buffer::DocumentResource,
50            // TS.1: `apply-action` also takes `option<borrow<tree-snapshot>>`.
51            // Map both tree-sitter resources to their backings (dot-separated
52            // `interface.resource` key) so bindgen emits `HostTreeSnapshot` /
53            // `HostNode` + the sync-linker `add_to_linker`.
54            "lattice:plugin-host/tree-sitter.tree-snapshot":
55                crate::tree_resource::TreeSnapshotResource,
56            "lattice:plugin-host/tree-sitter.node": crate::tree_resource::NodeResource,
57            // TS.2: the compiled `query` + the `tree-cursor` walk.
58            "lattice:plugin-host/tree-sitter.query": crate::tree_resource::QueryResource,
59            "lattice:plugin-host/tree-sitter.tree-cursor":
60                crate::tree_resource::CursorResource,
61        },
62    });
63}
64
65/// One grammar contribution a plugin declared through the `register-*` API,
66/// recorded verbatim (name + doc + WIT spec metadata + the guest's callback id).
67/// The host drains these after `register-grammar` returns and builds a native
68/// `*Spec` with a trampoline `apply`/`parse_args` stamped `SourceLayer::Plugin`
69/// (PH7.7c). The WIT spec is held as-is — its scalar fields convert at drain time
70/// via `boundary_grammar` (`LatencyClass`/`SurfaceForm`) + the existing `ArgSpec`
71/// mirror; the native `*Spec` cannot be built until the trampoline closure exists.
72pub enum RecordedContribution {
73    Motion {
74        name: String,
75        doc: String,
76        spec: WitMotionSpec,
77        /// Guest-chosen id the host passes to `apply-motion` on dispatch.
78        callback: u32,
79    },
80    Operator {
81        name: String,
82        doc: String,
83        spec: WitOperatorSpec,
84        callback: u32,
85    },
86    TextObject {
87        name: String,
88        doc: String,
89        spec: WitTextObjectSpec,
90        callback: u32,
91    },
92    Action {
93        name: String,
94        doc: String,
95        spec: WitActionSpec,
96        callback: u32,
97    },
98    ExCommand {
99        name: String,
100        doc: String,
101        spec: WitExCommandSpec,
102        /// `parse-ex-args` callback id.
103        parse_callback: u32,
104        /// `apply-ex-command` callback id.
105        apply_callback: u32,
106    },
107}
108
109impl RecordedContribution {
110    /// The contribution's registered name (`register_*`'s `name` arg).
111    pub fn name(&self) -> &str {
112        match self {
113            RecordedContribution::Motion { name, .. }
114            | RecordedContribution::Operator { name, .. }
115            | RecordedContribution::TextObject { name, .. }
116            | RecordedContribution::Action { name, .. }
117            | RecordedContribution::ExCommand { name, .. } => name,
118        }
119    }
120}
121
122/// The per-plugin accumulator the `grammar::Host` impl records into during
123/// `register-grammar` (`lib.rs`). Held in `PluginState`; drained by the host
124/// after the registration export returns (PH7.7c). The `record_*` methods are
125/// the sync host-func bodies (they only push — they cannot trap), factored here
126/// (the `host_services::walk_within_grant` precedent) so the recording logic is
127/// unit-testable without a `PluginState` / guest.
128#[derive(Default)]
129pub struct GrammarContributions {
130    recorded: Vec<RecordedContribution>,
131}
132
133impl GrammarContributions {
134    /// Record a motion contribution (the `grammar.register-motion` body).
135    pub fn record_motion(&mut self, name: String, doc: String, spec: WitMotionSpec, callback: u32) {
136        self.recorded.push(RecordedContribution::Motion {
137            name,
138            doc,
139            spec,
140            callback,
141        });
142    }
143
144    /// Record an operator contribution (`grammar.register-operator`).
145    pub fn record_operator(
146        &mut self,
147        name: String,
148        doc: String,
149        spec: WitOperatorSpec,
150        callback: u32,
151    ) {
152        self.recorded.push(RecordedContribution::Operator {
153            name,
154            doc,
155            spec,
156            callback,
157        });
158    }
159
160    /// Record a text-object contribution (`grammar.register-text-object`).
161    pub fn record_text_object(
162        &mut self,
163        name: String,
164        doc: String,
165        spec: WitTextObjectSpec,
166        callback: u32,
167    ) {
168        self.recorded.push(RecordedContribution::TextObject {
169            name,
170            doc,
171            spec,
172            callback,
173        });
174    }
175
176    /// Record an action contribution (`grammar.register-action`).
177    pub fn record_action(&mut self, name: String, doc: String, spec: WitActionSpec, callback: u32) {
178        self.recorded.push(RecordedContribution::Action {
179            name,
180            doc,
181            spec,
182            callback,
183        });
184    }
185
186    /// Record an ex-command contribution (`grammar.register-ex-command`). Carries
187    /// two callbacks — `parse-ex-args` + `apply-ex-command`.
188    pub fn record_ex_command(
189        &mut self,
190        name: String,
191        doc: String,
192        spec: WitExCommandSpec,
193        parse_callback: u32,
194        apply_callback: u32,
195    ) {
196        self.recorded.push(RecordedContribution::ExCommand {
197            name,
198            doc,
199            spec,
200            parse_callback,
201            apply_callback,
202        });
203    }
204
205    /// How many contributions were recorded.
206    pub fn len(&self) -> usize {
207        self.recorded.len()
208    }
209
210    /// True when the plugin registered no grammar (the degenerate case).
211    pub fn is_empty(&self) -> bool {
212        self.recorded.is_empty()
213    }
214
215    /// Drain the recorded contributions, leaving the accumulator empty. Called by
216    /// the host after `register-grammar` returns (PH7.7c) to build native specs.
217    pub fn take(&mut self) -> Vec<RecordedContribution> {
218        std::mem::take(&mut self.recorded)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    #![allow(clippy::unwrap_used, clippy::panic)]
225
226    use super::*;
227
228    fn arg_schema() -> Vec<crate::lattice::plugin_host::types::ArgSpec> {
229        Vec::new()
230    }
231
232    #[test]
233    fn records_each_kind_and_preserves_callback_ids() {
234        let mut g = GrammarContributions::default();
235        assert!(g.is_empty());
236
237        g.record_motion(
238            "ArrowDown".into(),
239            "jump down".into(),
240            WitMotionSpec {
241                jump: true,
242                exclusive: false,
243                args_schema: arg_schema(),
244            },
245            10,
246        );
247        g.record_operator(
248            "surround".into(),
249            "wrap".into(),
250            WitOperatorSpec {
251                repeatable: true,
252                args_schema: arg_schema(),
253                blockwise_per_row: false,
254                post_motion_char: false,
255                // This unit test records a contribution; it binds no keys.
256                chord: None,
257                doubled: None,
258            },
259            20,
260        );
261        g.record_text_object(
262            "entire".into(),
263            "whole buffer".into(),
264            WitTextObjectSpec {
265                args_schema: arg_schema(),
266            },
267            30,
268        );
269        g.record_action(
270            "greet".into(),
271            "say hi".into(),
272            WitActionSpec {
273                args_schema: arg_schema(),
274            },
275            40,
276        );
277        g.record_ex_command(
278            "Hello".into(),
279            "greet cmd".into(),
280            WitExCommandSpec {
281                latency_class: crate::lattice::plugin_host::types::LatencyClass::Reflex,
282                accepts_bang: true,
283                accepts_range: false,
284                args_schema: arg_schema(),
285                surface_form: crate::lattice::plugin_host::types::SurfaceForm::Keyword,
286            },
287            50,
288            51,
289        );
290
291        assert_eq!(g.len(), 5);
292        let drained = g.take();
293        assert!(g.is_empty(), "take() leaves the accumulator empty");
294        assert_eq!(drained.len(), 5);
295
296        // Provenance the trampoline (PH7.7c) reads: name + callback id per kind.
297        match &drained[0] {
298            RecordedContribution::Motion { name, callback, .. } => {
299                assert_eq!(name, "ArrowDown");
300                assert_eq!(*callback, 10);
301            }
302            _ => panic!("expected Motion first"),
303        }
304        match &drained[4] {
305            RecordedContribution::ExCommand {
306                name,
307                parse_callback,
308                apply_callback,
309                ..
310            } => {
311                assert_eq!(name, "Hello");
312                assert_eq!(*parse_callback, 50);
313                assert_eq!(*apply_callback, 51);
314            }
315            _ => panic!("expected ExCommand last"),
316        }
317        assert_eq!(drained[1].name(), "surround");
318        assert_eq!(drained[2].name(), "entire");
319        assert_eq!(drained[3].name(), "greet");
320    }
321}