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}