lattice_plugin_host/grammar_trampoline.rs
1//! The sync grammar trampoline + registry wiring (plugin-host.md §4.1, PH7.7c).
2//!
3//! This is the production counterpart to the picker/completion actor adapters —
4//! but **synchronous**, per the PH7.7 fork: a plugin motion/operator/text-object
5//! must resolve inline on the dispatch thread to compose with its operator (async
6//! would break operator∘motion atomicity + dot-repeat/macros). So there is no
7//! actor task; the native spec's `apply` closure calls the guest export *directly*
8//! under a lock, bounded by a Reflex-class fuel/epoch budget
9//! ([`PluginBudget::grammar`]).
10//!
11//! Flow ([`PluginHost::instantiate_grammar_plugin`]):
12//! 1. instantiate the `grammar-plugin` component against the **sync** grammar
13//! linker (`lib.rs`: sync WASI + the `grammar` register import),
14//! 2. call the guest's `register-grammar` export (sync) — the guest calls the
15//! imported `register-*` host funcs, which record into
16//! [`GrammarContributions`](crate::grammar_host::GrammarContributions),
17//! 3. drain the recorded contributions and build a native `*Spec` for each,
18//! whose `apply` / `parse_args` is a **trampoline** closure over the shared
19//! `Arc<Mutex<GrammarGuest>>` + the guest-chosen callback id,
20//! 4. return a [`GrammarContributionSet`] the *caller* registers into its
21//! `CommandRegistry` via `register_plugin_*` (mode-ownership — the host
22//! builds specs, the caller owns the registry; ZERO `Editor::` methods).
23//!
24//! Graceful degradation (§8): a guest `err`, a fuel/epoch **trap** (the Reflex
25//! runaway guard), a boundary-conversion failure, or a poisoned lock all map to
26//! [`CommandError::Plugin`] — the dispatcher commits no effect, the contribution
27//! is a no-op, the reason is logged. Never a panic, never a keystroke hang.
28
29use std::sync::{Arc, Mutex};
30
31use wasmtime::Store;
32use wasmtime::component::Resource;
33
34use lattice_runtime::snapshot::DocumentSnapshot;
35use lattice_syntax::SyntaxSnapshot;
36
37use crate::buffer::DocumentResource;
38use crate::tree_resource::TreeSnapshotResource;
39
40use lattice_grammar::args::{ArgSpec as NativeArgSpec, Args as NativeArgs};
41use lattice_grammar::command::LatencyClass;
42use lattice_grammar::effect::Effect as NativeEffect;
43use lattice_grammar::error::{CommandError, GrammarResult};
44use lattice_grammar::registry::{
45 ActionContext, ActionSpec, CommandRegistry, ExCommandContext, ExCommandSpec, MotionContext,
46 MotionResult, MotionSpec, OperatorContext, OperatorSpec, SurfaceForm, TextObjectContext,
47 TextObjectSpec,
48};
49use lattice_protocol::position::Range as NativeRange;
50
51use crate::boundary_grammar::{
52 project_action_context, project_ex_command_context, project_motion_context,
53 project_operator_context, project_text_object_context,
54};
55use crate::grammar_host::RecordedContribution;
56use crate::grammar_host::bindings::GrammarPlugin;
57use crate::lattice::plugin_host::types::{
58 ActionSpec as WitActionSpec, ArgSpec as WitArgSpec, ExCommandSpec as WitExCommandSpec,
59 MotionSpec as WitMotionSpec, OperatorSpec as WitOperatorSpec,
60 TextObjectSpec as WitTextObjectSpec,
61};
62use crate::trace::{
63 Direction, HotGate, PluginTraceRecord, PluginTracerHandle, TraceLevel, TraceOutcome,
64};
65use crate::{
66 Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, PluginState,
67 Quarantine, TrustTier, WitBoundary, arm_store, classify_trap,
68};
69use lattice_runtime::EventBus;
70
71/// The plugin's `Store` + grammar bindings, shared by every contribution's
72/// trampoline closure. Behind an `Arc<Mutex<>>` so the `Send + Sync` grammar
73/// `apply` closures can reach the `!Sync` `Store`; the `Mutex` serializes calls
74/// onto the single-threaded store (one plugin's motions never run concurrently,
75/// which the store requires anyway).
76struct GrammarGuest {
77 store: Store<PluginState>,
78 bindings: GrammarPlugin,
79 /// XF.4: the `fs:write` gate for effects this plugin returns.
80 ///
81 /// Built once at load from `store.data().grant` — a grant never changes
82 /// for a plugin's life, so the keystroke path pays only the prefix compare
83 /// a `WriteToFile` actually needs, and nothing at all for every other
84 /// effect. `Arc` so each trampoline closure captures a pointer rather than
85 /// a copy of the prefix list.
86 authorizer: Arc<crate::EffectAuthorizer>,
87 /// Crash-quarantine (PH7.12) shared by every trampoline closure (they all
88 /// lock this one guest). The first `apply-*` trap trips it — one
89 /// `PluginCrashed` on the bus — and every later keystroke short-circuits to a
90 /// no-op instead of re-trapping the dead `Store` at held-key frequency.
91 quarantine: Quarantine,
92 /// This plugin's host-issued id — the key on every emitted trace record.
93 plugin: u32,
94 /// PO.3 hot-path gate: the published per-plugin verbosity atomic. Read once
95 /// per guest call with a relaxed load (design §4); at the default `Info` gate
96 /// a successful call emits nothing (zero timing / alloc / format).
97 gate: HotGate,
98 /// The boundary tracer, when wired at load time. `None` in tests / benches
99 /// and when the loader has no tracer — the whole trampoline then costs one
100 /// `records_calls()` load + a not-taken branch per call.
101 tracer: Option<PluginTracerHandle>,
102}
103
104/// Emit one grammar boundary-trace record into the wired tracer. Off the hot
105/// path: only called after [`HotGate::records_calls`] admitted a success, or on
106/// the cold guest-err / trap branches. The tracer re-gates internally, so a
107/// racing verbosity drop still drops the record.
108#[inline]
109fn emit_trace(
110 tracer: &PluginTracerHandle,
111 plugin: u32,
112 func: &'static str,
113 level: TraceLevel,
114 outcome: TraceOutcome,
115) {
116 tracer.trace(PluginTraceRecord {
117 plugin,
118 seam: crate::PluginSeam::Grammar,
119 direction: Direction::GuestExport,
120 call: std::borrow::Cow::Borrowed(func),
121 level,
122 outcome,
123 detail: None,
124 });
125}
126
127/// Lock the guest, arm the Reflex-class grammar budget, and run one synchronous
128/// callback. Maps every failure mode to [`CommandError::Plugin`] (graceful §8): a
129/// wasmtime trap (fuel/epoch runaway guard, or any guest panic) → a logged
130/// no-op; a guest-returned WIT `err` → the guest's message; a poisoned lock → a
131/// no-op. `T` is the callback's `ok` payload (already the WIT type).
132fn run_callback<T>(
133 guest: &Arc<Mutex<GrammarGuest>>,
134 func: &'static str,
135 call: impl FnOnce(&GrammarPlugin, &mut Store<PluginState>) -> wasmtime::Result<Result<T, String>>,
136) -> GrammarResult<T> {
137 let mut guard = guest
138 .lock()
139 .map_err(|_| CommandError::Plugin(format!("{func}: plugin lock poisoned")))?;
140 // Quarantine short-circuit (PH7.12): a prior `apply-*` trap tainted this
141 // instance's `Store`, so every later motion/operator is a clean no-op — the
142 // `PluginCrashed` event already fired at trip time. This is what stops a
143 // trapping motion from re-failing at held-key (30 Hz) frequency.
144 if guard.quarantine.is_tripped() {
145 return Err(CommandError::Plugin(format!("{func}: plugin quarantined")));
146 }
147 let GrammarGuest {
148 store,
149 bindings,
150 quarantine,
151 plugin,
152 gate,
153 tracer,
154 // XF.4: read at build time by each trampoline closure, not here.
155 authorizer: _,
156 } = &mut *guard;
157 // Reflex-class budget (audit F1): a runaway plugin motion traps well inside a
158 // frame instead of stalling the keystroke.
159 arm_store(store, PluginBudget::grammar())
160 .map_err(|e| CommandError::Plugin(format!("{func}: arm store: {e}")))?;
161
162 // Design §4 hot-path contract — a single relaxed-atomic gate load + a
163 // predicted-not-taken branch. At the default `Info` gate `record_calls` is
164 // false, so a *successful* call below does ZERO timing / allocation /
165 // formatting: the trampoline's only added cost is this load and branch.
166 let record_calls = gate.records_calls();
167 let start = record_calls.then(std::time::Instant::now);
168
169 match call(bindings, store) {
170 Ok(Ok(value)) => {
171 if let (Some(start), Some(tracer)) = (start, tracer.as_ref()) {
172 emit_trace(
173 tracer,
174 *plugin,
175 func,
176 TraceLevel::Debug,
177 TraceOutcome::Ok {
178 micros: start.elapsed().as_micros() as u64,
179 fuel_delta: 0,
180 },
181 );
182 }
183 Ok(value)
184 }
185 Ok(Err(guest_err)) => {
186 // A guest-signalled `err` — rare and user-actionable. Recorded at
187 // `Warn` so it is KEPT at the default `Info` gate, off the common
188 // keystroke path. The sync trampoline sees the guest's inner
189 // `Result::Err` directly; the async seams cannot (they observe only
190 // the outer `wasmtime::Result`, so a guest err crosses as a nominal
191 // `Ok` there) — this seam is deliberately richer, not a mirror.
192 if let Some(tracer) = tracer.as_ref() {
193 emit_trace(
194 tracer,
195 *plugin,
196 func,
197 TraceLevel::Warn,
198 TraceOutcome::Ok {
199 micros: start.map_or(0, |s| s.elapsed().as_micros() as u64),
200 fuel_delta: 0,
201 },
202 );
203 }
204 Err(CommandError::Plugin(format!("{func}: {guest_err}")))
205 }
206 Err(trap) => {
207 // The trap taints the instance irrecoverably: trip quarantine so the
208 // crash fires once and later keystrokes short-circuit above.
209 let kind = classify_trap(&trap);
210 quarantine.trip(func, kind);
211 // Always recorded (Error) — the lifecycle/crash signal the default
212 // gate carries per §4. Cold path (a trap trips quarantine once).
213 if let Some(tracer) = tracer.as_ref() {
214 emit_trace(
215 tracer,
216 *plugin,
217 func,
218 TraceLevel::Error,
219 TraceOutcome::Trap {
220 kind: kind.label().to_string(),
221 func: func.to_string(),
222 },
223 );
224 }
225 Err(CommandError::Plugin(format!(
226 "{func} trapped ({kind}): {trap}"
227 )))
228 }
229 }
230}
231
232/// Convert a WIT `arg-spec` list into native `ArgSpec`s (reusing the PH7.4a
233/// `ArgSpec` mirror). A conversion failure fails the whole registration (the
234/// spec is malformed), surfaced to the caller of `instantiate_grammar_plugin`.
235fn convert_args_schema(schema: Vec<WitArgSpec>) -> Result<Vec<NativeArgSpec>, PluginHostError> {
236 schema
237 .into_iter()
238 .map(|a| NativeArgSpec::from_wit(a).map_err(PluginHostError::GrammarSpec))
239 .collect()
240}
241
242/// TS.1 / OT.1: resolve the `tree-snapshot` a grammar callback should see.
243///
244/// Three seams mint this identically — action (TS.1), motion and text object
245/// (OT.1) — so the gate lives in one place rather than being re-derived per
246/// seam, where one copy could silently drop a condition. All three are real:
247///
248/// * **the capability gate** — no `tree-sitter` grant means `none` even on a
249/// parsed buffer (design §5, the read-only structural seam is gated);
250/// * **the downcast** — `lattice-grammar` type-erases the snapshot as
251/// `Arc<dyn Any>` to keep its lean dep set, so the concrete type is recovered
252/// here and a foreign payload yields `none` rather than a panic;
253/// * **the parse check** — a `SyntaxSnapshot` can exist with no tree behind it
254/// (plain text / parse pending), and handing the guest a treeless snapshot
255/// would make `root()` answer nothing while `none` says so honestly.
256///
257/// Takes a borrow so the motion and text-object contexts (which hold
258/// `Option<&Arc<…>>` to stay free on the keystroke path) pay the `Arc` bump only
259/// on the branch that actually mints a resource.
260fn resolve_tree_snapshot(
261 tree_sitter_granted: bool,
262 syntax: Option<&Arc<dyn std::any::Any + Send + Sync>>,
263) -> Option<Arc<SyntaxSnapshot>> {
264 tree_sitter_granted
265 .then_some(syntax)
266 .flatten()
267 .and_then(|any| any.clone().downcast::<SyntaxSnapshot>().ok())
268 .filter(|snap| snap.tree().is_some())
269}
270
271fn build_motion_spec(
272 guest: &Arc<Mutex<GrammarGuest>>,
273 spec: WitMotionSpec,
274 callback: u32,
275 // OT.1: same gate the action seam applies, for the same reason.
276 tree_sitter_granted: bool,
277) -> Result<MotionSpec, PluginHostError> {
278 let args_schema = convert_args_schema(spec.args_schema)?;
279 let guest = guest.clone();
280 Ok(MotionSpec {
281 jump: spec.jump,
282 exclusive: spec.exclusive,
283 // VM.3g-1: a guest motion takes the default goal-column rule — its
284 // landing column becomes the goal, which is what every vim motion but
285 // `j` / `k` / `$` does. The WIT `motion-spec` carries no `curswant`
286 // case yet; when a plugin needs a vertical motion that keeps the goal,
287 // that is the field to add, and this is the one line that reads it.
288 curswant: lattice_grammar::CurswantEffect::default(),
289 args_schema,
290 apply: Arc::new(move |ctx: &MotionContext| -> GrammarResult<MotionResult> {
291 let wit_ctx = project_motion_context(ctx).map_err(CommandError::Plugin)?;
292 // OM.4: mint a point-in-time `document` from the motion's buffer,
293 // exactly as `build_action_spec` does (O(1) rope clone). A motion
294 // that reads text — org's headline motions are the first — needs
295 // the same handle an action gets.
296 let snapshot = Arc::new(DocumentSnapshot {
297 buffer: ctx.buffer.clone(),
298 // OM.6b: so `document.path()` answers on this seam too. A
299 // guest that gets `none` here because the mint dropped the
300 // field would read it as "unsaved buffer" and be wrong.
301 path: ctx.path.map(|p| Arc::new(p.to_path_buf())),
302 ..Default::default()
303 });
304 // OT.1: and the tree, on the action seam's terms. `ctx.syntax` is
305 // already a borrow, so an ungranted or unparsed buffer costs a
306 // branch and no `Arc` traffic — which matters here and not on the
307 // action seam, because motions fire on every `j`.
308 let tree_snapshot = resolve_tree_snapshot(tree_sitter_granted, ctx.syntax);
309 let wit = run_callback(&guest, "apply-motion", |b, s| {
310 // Lend as borrows and reclaim after the call — the host owns
311 // the entries throughout (the `apply-action` pattern).
312 let owned_doc = s
313 .data_mut()
314 .table
315 .push(DocumentResource::new(snapshot.clone()))?;
316 let doc_borrow = Resource::new_borrow(owned_doc.rep());
317 let owned_tree = match &tree_snapshot {
318 Some(snap) => Some(
319 s.data_mut()
320 .table
321 .push(TreeSnapshotResource::new(snap.clone()))?,
322 ),
323 None => None,
324 };
325 let tree_borrow = owned_tree.as_ref().map(|o| Resource::new_borrow(o.rep()));
326 let result = b.lattice_plugin_host_grammar_callbacks().call_apply_motion(
327 &mut *s,
328 callback,
329 &wit_ctx,
330 doc_borrow,
331 tree_borrow,
332 );
333 let _ = s.data_mut().table.delete(owned_doc);
334 if let Some(owned_tree) = owned_tree {
335 let _ = s.data_mut().table.delete(owned_tree);
336 }
337 result
338 })?;
339 MotionResult::from_wit(wit).map_err(CommandError::Plugin)
340 }),
341 })
342}
343
344/// XF.4: convert a guest-returned effect and AUTHORISE it.
345///
346/// The one place both halves happen, so a new effect-returning contribution
347/// cannot get the conversion and forget the gate — which would be an
348/// unchecked cross-file write reachable from any plugin.
349fn effect_from_guest(
350 authorizer: &Arc<crate::EffectAuthorizer>,
351 wit: Vec<crate::lattice::plugin_host::types::Effect>,
352) -> GrammarResult<NativeEffect> {
353 let native = NativeEffect::from_wit(wit).map_err(CommandError::Plugin)?;
354 Ok(authorizer.authorize(native))
355}
356
357fn build_operator_spec(
358 guest: &Arc<Mutex<GrammarGuest>>,
359 spec: WitOperatorSpec,
360 callback: u32,
361) -> Result<OperatorSpec, PluginHostError> {
362 let args_schema = convert_args_schema(spec.args_schema)?;
363 let authorizer = Arc::clone(
364 &guest
365 .lock()
366 .expect("grammar guest mutex poisoned")
367 .authorizer,
368 );
369 let guest = guest.clone();
370 Ok(OperatorSpec {
371 repeatable: spec.repeatable,
372 args_schema,
373 blockwise_per_row: spec.blockwise_per_row,
374 post_motion_char: spec.post_motion_char,
375 apply: Arc::new(
376 move |ctx: &mut OperatorContext| -> GrammarResult<NativeEffect> {
377 let wit_ctx = project_operator_context(ctx).map_err(CommandError::Plugin)?;
378 // CM.1: mint a point-in-time `document`, as the motion,
379 // text-object, action and ex-command paths already do. An
380 // operator was the last callback without one, because no
381 // plugin had contributed an operator — and a comment operator
382 // cannot decide comment-vs-uncomment without reading the
383 // lines it was handed.
384 //
385 // The path comes off the `Document` the context already holds —
386 // `OperatorContext` has no `path` FIELD, unlike
387 // `TextObjectContext`, but it does not need one. An earlier
388 // draft of this left `path: None` on the reasoning that the
389 // context did not carry it, and shipped an operator whose
390 // `document.path()` answered `none` for every real file. The
391 // comment plugin chooses its comment leader by extension, so
392 // the symptom was `gcc` in a .rs file reporting "this buffer
393 // has no file".
394 //
395 // Still no tree: that genuinely does need a native field
396 // (`syntax`), and no operator has asked for it.
397 let snapshot = Arc::new(DocumentSnapshot {
398 buffer: ctx.document.buffer().clone(),
399 path: ctx.document.path_shared(),
400 ..Default::default()
401 });
402 let wit = run_callback(&guest, "apply-operator", |b, s| {
403 let owned_doc = s
404 .data_mut()
405 .table
406 .push(DocumentResource::new(snapshot.clone()))?;
407 let doc_borrow = Resource::new_borrow(owned_doc.rep());
408 let result = b
409 .lattice_plugin_host_grammar_callbacks()
410 .call_apply_operator(&mut *s, callback, &wit_ctx, doc_borrow);
411 let _ = s.data_mut().table.delete(owned_doc);
412 result
413 })?;
414 effect_from_guest(&authorizer, wit)
415 },
416 ),
417 })
418}
419
420fn build_text_object_spec(
421 guest: &Arc<Mutex<GrammarGuest>>,
422 spec: WitTextObjectSpec,
423 callback: u32,
424 // OT.1: same gate the action and motion seams apply.
425 tree_sitter_granted: bool,
426) -> Result<TextObjectSpec, PluginHostError> {
427 let args_schema = convert_args_schema(spec.args_schema)?;
428 let guest = guest.clone();
429 Ok(TextObjectSpec {
430 args_schema,
431 apply: Arc::new(
432 move |ctx: &TextObjectContext| -> GrammarResult<NativeRange> {
433 let wit_ctx = project_text_object_context(ctx).map_err(CommandError::Plugin)?;
434 // OM.4b: mint a point-in-time `document`, as the motion and
435 // action paths do. Resolving a subtree's bounds means reading
436 // lines, and `text-object-context` always said buffer text
437 // rides this handle.
438 let snapshot = Arc::new(DocumentSnapshot {
439 buffer: ctx.buffer.clone(),
440 // OM.6b, as on the motion and action seams.
441 path: ctx.path.map(|p| Arc::new(p.to_path_buf())),
442 ..Default::default()
443 });
444 // OT.1: and the tree — org's `ir` / `ar` resolve a subtree,
445 // which is the `(section)` node rather than a star count.
446 let tree_snapshot = resolve_tree_snapshot(tree_sitter_granted, ctx.syntax);
447 let wit = run_callback(&guest, "apply-text-object", |b, s| {
448 let owned_doc = s
449 .data_mut()
450 .table
451 .push(DocumentResource::new(snapshot.clone()))?;
452 let doc_borrow = Resource::new_borrow(owned_doc.rep());
453 let owned_tree = match &tree_snapshot {
454 Some(snap) => Some(
455 s.data_mut()
456 .table
457 .push(TreeSnapshotResource::new(snap.clone()))?,
458 ),
459 None => None,
460 };
461 let tree_borrow = owned_tree.as_ref().map(|o| Resource::new_borrow(o.rep()));
462 let result = b
463 .lattice_plugin_host_grammar_callbacks()
464 .call_apply_text_object(
465 &mut *s,
466 callback,
467 &wit_ctx,
468 doc_borrow,
469 tree_borrow,
470 );
471 let _ = s.data_mut().table.delete(owned_doc);
472 if let Some(owned_tree) = owned_tree {
473 let _ = s.data_mut().table.delete(owned_tree);
474 }
475 result
476 })?;
477 NativeRange::from_wit(wit).map_err(CommandError::Plugin)
478 },
479 ),
480 })
481}
482
483fn build_action_spec(
484 guest: &Arc<Mutex<GrammarGuest>>,
485 spec: WitActionSpec,
486 callback: u32,
487 // TS.1: whether this plugin was granted the `tree-sitter` editor-capability.
488 // When false the guest gets `none` for the tree even on a parsed buffer —
489 // the read-only structural seam is capability-gated (design §5).
490 tree_sitter_granted: bool,
491) -> Result<ActionSpec, PluginHostError> {
492 let args_schema = convert_args_schema(spec.args_schema)?;
493 let authorizer = Arc::clone(
494 &guest
495 .lock()
496 .expect("grammar guest mutex poisoned")
497 .authorizer,
498 );
499 let guest = guest.clone();
500 Ok(ActionSpec {
501 args_schema,
502 apply: Arc::new(move |ctx: &ActionContext| -> GrammarResult<NativeEffect> {
503 let wit_ctx = project_action_context(ctx).map_err(CommandError::Plugin)?;
504 // AP.0.1: mint a point-in-time `document` from the action's buffer
505 // (O(1) rope clone) so the guest can read text around the cursor.
506 // OM.6b added `path` to the mint — `document.path()` is what
507 // `org-archive-subtree` asks to name `<file>_archive`. The rest of
508 // the snapshot's fields are still irrelevant here.
509 let snapshot = Arc::new(DocumentSnapshot {
510 buffer: ctx.buffer.clone(),
511 path: ctx.path.clone(),
512 ..Default::default()
513 });
514 // TS.1: mint a `tree-snapshot` handle only when the grant, the
515 // downcast and the parse all hold — see `resolve_tree_snapshot`,
516 // shared with the motion and text-object seams since OT.1. The
517 // snapshot was acquired the same instant as `buffer` above, so the
518 // tree + text handles agree on version (§7).
519 let tree_snapshot = resolve_tree_snapshot(tree_sitter_granted, ctx.syntax.as_ref());
520 let wit = run_callback(&guest, "apply-action", |b, s| {
521 // Lend the resources as borrows: push owned entries, pass
522 // non-owning borrow handles to the guest, then reclaim the owned
523 // entries after the call (the host owns them throughout). Any
524 // `node` the guest derives from the tree borrow is guest-owned and
525 // dropped by the guest before it returns.
526 let owned_doc = s
527 .data_mut()
528 .table
529 .push(DocumentResource::new(snapshot.clone()))?;
530 let doc_borrow = Resource::new_borrow(owned_doc.rep());
531 let owned_tree = match &tree_snapshot {
532 Some(snap) => Some(
533 s.data_mut()
534 .table
535 .push(TreeSnapshotResource::new(snap.clone()))?,
536 ),
537 None => None,
538 };
539 let tree_borrow = owned_tree.as_ref().map(|o| Resource::new_borrow(o.rep()));
540 // Reborrow `s` for the call so the owned handles can be reclaimed
541 // after (the call takes the store by value via `AsContextMut`).
542 let result = b.lattice_plugin_host_grammar_callbacks().call_apply_action(
543 &mut *s,
544 callback,
545 &wit_ctx,
546 doc_borrow,
547 tree_borrow,
548 );
549 let _ = s.data_mut().table.delete(owned_doc);
550 if let Some(owned_tree) = owned_tree {
551 let _ = s.data_mut().table.delete(owned_tree);
552 }
553 result
554 })?;
555 effect_from_guest(&authorizer, wit)
556 }),
557 })
558}
559
560fn build_ex_command_spec(
561 guest: &Arc<Mutex<GrammarGuest>>,
562 spec: WitExCommandSpec,
563 parse_callback: u32,
564 apply_callback: u32,
565 // OC.10: whether this plugin holds the `tree-sitter` grant, threaded in
566 // exactly as the action / motion / text-object builders take it.
567 tree_sitter_granted: bool,
568) -> Result<ExCommandSpec, PluginHostError> {
569 let args_schema = convert_args_schema(spec.args_schema)?;
570 let latency_class =
571 LatencyClass::from_wit(spec.latency_class).map_err(PluginHostError::GrammarSpec)?;
572 let surface_form =
573 SurfaceForm::from_wit(spec.surface_form).map_err(PluginHostError::GrammarSpec)?;
574 let authorizer = Arc::clone(
575 &guest
576 .lock()
577 .expect("grammar guest mutex poisoned")
578 .authorizer,
579 );
580 let parse_guest = guest.clone();
581 let apply_guest = guest.clone();
582 Ok(ExCommandSpec {
583 latency_class,
584 accepts_bang: spec.accepts_bang,
585 accepts_range: spec.accepts_range,
586 args_schema,
587 surface_form,
588 parse_args: Arc::new(move |rest: &str, bang: bool| -> GrammarResult<NativeArgs> {
589 let rest = rest.to_string();
590 let wit = run_callback(&parse_guest, "parse-ex-args", |b, s| {
591 b.lattice_plugin_host_grammar_callbacks()
592 .call_parse_ex_args(s, parse_callback, &rest, bang)
593 })?;
594 NativeArgs::from_wit(wit).map_err(CommandError::Plugin)
595 }),
596 apply: Arc::new(
597 move |ctx: &ExCommandContext| -> GrammarResult<NativeEffect> {
598 let wit_ctx = project_ex_command_context(ctx).map_err(CommandError::Plugin)?;
599 // OC.10: mint the same `document` + `tree-snapshot` pair
600 // `build_action_spec` mints, from the same fields, at the same
601 // instant — so a plugin ex-command reads the buffer it was
602 // invoked from, and its tree and text agree on version (§7).
603 //
604 // Before this the guest got neither, while `apply-ex-command`
605 // still returned `list<effect>` including `apply-edit` — an
606 // effect naming a `target` buffer id the guest had no way to
607 // obtain. The seam offered a vocabulary it could not use.
608 let snapshot = Arc::new(DocumentSnapshot {
609 buffer: ctx.buffer.clone(),
610 path: ctx.path.clone(),
611 ..Default::default()
612 });
613 let tree_snapshot = resolve_tree_snapshot(tree_sitter_granted, ctx.syntax.as_ref());
614 let wit = run_callback(&apply_guest, "apply-ex-command", |b, s| {
615 let owned_doc = s
616 .data_mut()
617 .table
618 .push(DocumentResource::new(snapshot.clone()))?;
619 let doc_borrow = Resource::new_borrow(owned_doc.rep());
620 let owned_tree = match &tree_snapshot {
621 Some(snap) => Some(
622 s.data_mut()
623 .table
624 .push(TreeSnapshotResource::new(snap.clone()))?,
625 ),
626 None => None,
627 };
628 let tree_borrow = owned_tree.as_ref().map(|o| Resource::new_borrow(o.rep()));
629 let result = b
630 .lattice_plugin_host_grammar_callbacks()
631 .call_apply_ex_command(
632 &mut *s,
633 apply_callback,
634 &wit_ctx,
635 doc_borrow,
636 tree_borrow,
637 );
638 let _ = s.data_mut().table.delete(owned_doc);
639 if let Some(owned_tree) = owned_tree {
640 let _ = s.data_mut().table.delete(owned_tree);
641 }
642 result
643 })?;
644 effect_from_guest(&authorizer, wit)
645 },
646 ),
647 })
648}
649
650/// The native grammar contributions a plugin declared, each with a trampoline
651/// `apply` into the guest, ready for the **caller** to register into its
652/// `CommandRegistry`. The host builds the specs (owning the trampoline over the
653/// guest store) but never owns the registry — [`register_all`](Self::register_all)
654/// takes the caller's `&mut CommandRegistry` and stamps every entry
655/// `SourceLayer::Plugin(plugin_id)` via the `register_plugin_*` seam
656/// (mode-ownership; ZERO `Editor::` methods).
657pub struct GrammarContributionSet {
658 plugin_id: PluginId,
659 // `(name, doc, spec)` per kind. The specs carry boxed trampoline closures
660 // (not `Clone`), so registration consumes the set.
661 motions: Vec<(String, String, MotionSpec)>,
662 /// CM.2: the fourth element is the chord the guest declared, if any —
663 /// registration data, kept beside the spec rather than inside it because
664 /// `OperatorSpec` is read on every dispatch and carries no keys.
665 operators: Vec<(String, String, OperatorSpec, Option<PluginOperatorChord>)>,
666 text_objects: Vec<(String, String, TextObjectSpec)>,
667 actions: Vec<(String, String, ActionSpec)>,
668 ex_commands: Vec<(String, String, ExCommandSpec)>,
669}
670
671impl GrammarContributionSet {
672 /// The host-issued id of the plugin behind these contributions (the `u32`
673 /// stamped into `SourceLayer::Plugin`).
674 pub fn plugin_id(&self) -> PluginId {
675 self.plugin_id
676 }
677
678 /// Total number of contributions across all kinds.
679 pub fn len(&self) -> usize {
680 self.motions.len()
681 + self.operators.len()
682 + self.text_objects.len()
683 + self.actions.len()
684 + self.ex_commands.len()
685 }
686
687 /// True when the plugin registered no grammar.
688 pub fn is_empty(&self) -> bool {
689 self.len() == 0
690 }
691
692 /// Register every contribution into the caller's `CommandRegistry`, stamped
693 /// `SourceLayer::Plugin(plugin_id)`. Consumes the set (the specs own boxed
694 /// trampoline closures). The registry, dispatcher, and `:describe-*` views
695 /// then treat each entry exactly like a builtin (paramount #3).
696 pub fn register_all(self, registry: &mut CommandRegistry) -> Vec<WiredOperatorChord> {
697 let id = self.plugin_id.0;
698 let mut chords = Vec::new();
699 for (name, doc, spec) in self.motions {
700 registry.register_plugin_motion(id, &name, &doc, spec);
701 }
702 for (name, doc, spec, chord) in self.operators {
703 let op = registry.register_plugin_operator(id, &name, &doc, spec);
704 // The id is only knowable here, after registration — which is why
705 // the chord request travels OUT rather than the wirer travelling
706 // in. `lattice-plugin-host` stays unaware of how a chord is bound.
707 if let Some(c) = chord {
708 chords.push(WiredOperatorChord {
709 operator: op,
710 chord: c.chord,
711 doubled: c.doubled,
712 });
713 }
714 }
715 for (name, doc, spec) in self.text_objects {
716 registry.register_plugin_text_object(id, &name, &doc, spec);
717 }
718 for (name, doc, spec) in self.actions {
719 registry.register_plugin_action(id, &name, &doc, spec);
720 }
721 for (name, doc, spec) in self.ex_commands {
722 registry.register_plugin_ex_command(id, &name, &doc, spec);
723 }
724 chords
725 }
726}
727
728/// CM.2: a chord a plugin declared on its operator, as recorded at drain time.
729#[derive(Debug, Clone)]
730pub struct PluginOperatorChord {
731 pub chord: String,
732 /// The trailing key of the doubled linewise form (`c` for `gcc`).
733 pub doubled: Option<char>,
734}
735
736/// CM.2: a chord request, paired with the `OperatorId` registration produced.
737///
738/// The caller binds it — `lattice-plugin-host` does not, because the
739/// operator-pending composition needs host-resolved builtins that live two
740/// crates away.
741#[derive(Debug, Clone)]
742pub struct WiredOperatorChord {
743 pub operator: lattice_grammar::registry::OperatorId,
744 pub chord: String,
745 pub doubled: Option<char>,
746}
747
748impl PluginHost {
749 /// Instantiate a `grammar-plugin` component, drive its `register-grammar`
750 /// export, and return the native [`GrammarContributionSet`] (each spec's
751 /// `apply` a sync trampoline into the guest). **Synchronous** end to end (the
752 /// PH7.7 fork): instantiated against the sync `grammar_linker` (sync WASI +
753 /// the `grammar` register import), so there is no async host import a sync
754 /// `apply` could reach. The caller registers the result via
755 /// [`GrammarContributionSet::register_all`].
756 ///
757 /// A malformed spec (an `arg-spec` / `latency-class` / `surface-form` that
758 /// won't convert) fails registration loudly with [`PluginHostError::GrammarSpec`];
759 /// a *runtime* `apply` failure is graceful (a no-op, §8), handled in the
760 /// trampoline. Instantiation + `register-grammar` run under the generous
761 /// lifecycle budget; per-`apply` calls arm the Reflex budget (audit F1).
762 ///
763 /// PO.3: pass `Some(tracer)` to instrument the sync grammar seam. Each guest
764 /// call then reads the plugin's published [`HotGate`] once (a relaxed atomic
765 /// load); at the default `Info` gate that is the trampoline's only added cost
766 /// (design §4). `None` (tests / benches / a tracer-less loader) skips even the
767 /// gate handoff. The tracer's `hot_gate(id)` is seeded to the plugin's current
768 /// effective level and updated live by `:set plugin.trace-level`.
769 pub fn instantiate_grammar_plugin(
770 &self,
771 component: &Component,
772 manifest: &PluginManifest,
773 tier: TrustTier,
774 bus: &Arc<EventBus>,
775 tracer: Option<&PluginTracerHandle>,
776 // AP.3: the editor's `ConfigRegistry`, so a grammar action can READ an
777 // option via `config::get-option` (auto-pair reads `auto-pair.style` to
778 // gate manual vs auto behavior). The SAME registry the async config seam
779 // writes — one editor registry shared across a plugin's seam instances.
780 // `None` leaves `get-option` returning `none` (the pre-AP.3 behavior).
781 config_registry: Option<&Arc<lattice_config::ConfigRegistry>>,
782 ) -> Result<GrammarContributionSet, PluginHostError> {
783 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
784 // TS.1: the tree-sitter seam is gated on the `tree-sitter` editor
785 // capability — a grammar action of a plugin without the grant gets `none`
786 // for its tree handle (design §5). Captured once here; every action spec's
787 // trampoline reads it.
788 let tree_sitter_granted = outcome
789 .grant
790 .editor
791 .contains(lattice_mode::CapabilitySet::TREE_SITTER);
792 for denied in &outcome.denied {
793 tracing::warn!(
794 plugin = %manifest.id,
795 capability = ?denied,
796 "grammar plugin loaded with a withheld capability (reduced function)"
797 );
798 }
799 let mut store = self.new_store(
800 wasi,
801 outcome.grant,
802 PluginBudget::default(),
803 Some(&manifest.id),
804 )?;
805 // AP.3: wire the shared config registry so a grammar action's
806 // `config::get-option` reads the live editor options.
807 store.data_mut().config_registry = config_registry.cloned();
808 // OC.3 / ML.6: and take the modeline handle AWAY. `new_store` stamps it
809 // on every store so no async path can forget it; this is the one store
810 // that must not have it. `ui` has to be on the sync grammar linker (a
811 // component's imports must resolve on every linker it instantiates
812 // against, or the whole plugin fails to load), so the "modeline is
813 // unreachable from the keystroke path" guarantee lives here rather than
814 // in the linker's import set — where it is one line, and testable.
815 store.data_mut().ui = None;
816 let id = self.alloc_id();
817 // OC.1: wire the emit context, for the same reason and at the same point
818 // in the sequence `spawn_event_plugin` does — BEFORE `register-grammar`
819 // runs, since a guest may `register-event` from there and will
820 // `emit-event` later from an `apply-*` callback.
821 //
822 // `host-services` (and therefore `emit-event`) has been linked into this
823 // sync store since OM.11, so the seam has *looked* available all along;
824 // what was missing is the context behind it, and a call took the `None`
825 // arm of `PluginState::emit_event` — a warn-and-drop. That is the
826 // `plugin-gates-hand-guests-throwaway-contexts` shape: wired end to end,
827 // answering nothing. It is a defect independent of clocking (any plugin
828 // bridging a chord to its own async side hits it); clocking is only what
829 // found it.
830 //
831 // Safe on the keystroke path: `EventBus::publish` snapshots subscribers
832 // under a short lock and dispatches into unbounded channels with the lock
833 // dropped — bounded, non-blocking work. The `Quarantine` below already
834 // publishes on this same bus from this same trampoline.
835 store.data_mut().event_emit = Some(crate::EventEmitCtx {
836 plugin_id: id,
837 bus: Arc::clone(bus),
838 });
839 // SYNC instantiate against the sync grammar linker — no async import to
840 // drive, so a plain `instantiate` is correct (the PH7.7 fork).
841 let bindings = GrammarPlugin::instantiate(&mut store, component, &self.grammar_linker)
842 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
843 // Drive registration: the guest calls the imported `register-*`, which
844 // record into the store's `GrammarContributions`.
845 arm_store(&mut store, PluginBudget::default())?;
846 bindings
847 .call_register_grammar(&mut store)
848 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
849 let recorded = store.data_mut().grammar_contributions.take();
850
851 // PO.3: fetch this plugin's published hot-path gate (seeded to its current
852 // effective level), or a permanently-off gate when no tracer is wired.
853 let gate = tracer.map_or_else(HotGate::disabled, |t| t.hot_gate(id.0));
854
855 // Move the store + bindings behind the shared lock every trampoline reads.
856 // The `Quarantine` rides inside so one `apply-*` trap quarantines every
857 // contribution from this plugin (they share the one guest).
858 let authorizer = Arc::new(crate::EffectAuthorizer::new(
859 &store.data().grant,
860 manifest.id.clone(),
861 ));
862 let guest = Arc::new(Mutex::new(GrammarGuest {
863 store,
864 bindings,
865 authorizer,
866 quarantine: Quarantine::new(id, Arc::clone(bus)),
867 plugin: id.0,
868 gate,
869 tracer: tracer.cloned(),
870 }));
871
872 let mut set = GrammarContributionSet {
873 plugin_id: id,
874 motions: Vec::new(),
875 operators: Vec::new(),
876 text_objects: Vec::new(),
877 actions: Vec::new(),
878 ex_commands: Vec::new(),
879 };
880 for contribution in recorded {
881 match contribution {
882 RecordedContribution::Motion {
883 name,
884 doc,
885 spec,
886 callback,
887 } => set.motions.push((
888 name,
889 doc,
890 build_motion_spec(&guest, spec, callback, tree_sitter_granted)?,
891 )),
892 RecordedContribution::Operator {
893 name,
894 doc,
895 spec,
896 callback,
897 } => {
898 // CM.2: the chord is registration data, not dispatch data,
899 // so it rides beside the native spec rather than into it —
900 // `OperatorSpec` is read on every dispatch and has no
901 // business carrying keys.
902 let chord = spec.chord.clone().map(|chord| PluginOperatorChord {
903 chord,
904 doubled: spec.doubled.as_ref().and_then(|d| d.chars().next()),
905 });
906 set.operators.push((
907 name,
908 doc,
909 build_operator_spec(&guest, spec, callback)?,
910 chord,
911 ))
912 }
913 RecordedContribution::TextObject {
914 name,
915 doc,
916 spec,
917 callback,
918 } => set.text_objects.push((
919 name,
920 doc,
921 build_text_object_spec(&guest, spec, callback, tree_sitter_granted)?,
922 )),
923 RecordedContribution::Action {
924 name,
925 doc,
926 spec,
927 callback,
928 } => set.actions.push((
929 name,
930 doc,
931 build_action_spec(&guest, spec, callback, tree_sitter_granted)?,
932 )),
933 RecordedContribution::ExCommand {
934 name,
935 doc,
936 spec,
937 parse_callback,
938 apply_callback,
939 } => set.ex_commands.push((
940 name,
941 doc,
942 build_ex_command_spec(
943 &guest,
944 spec,
945 parse_callback,
946 apply_callback,
947 tree_sitter_granted,
948 )?,
949 )),
950 }
951 }
952 Ok(set)
953 }
954}