Skip to main content

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}