Skip to main content

lattice_plugin_host/
boundary_grammar.rs

1//! The grammar-extension boundary conversions (plugin-host.md §4.1, PH7.7a).
2//!
3//! Mirrors the data a plugin authors against when it EXTENDS the vim grammar
4//! via `register_{motion,operator,text_object,ex_command,action}`. The grammar
5//! *handling* (dispatcher, parser, composition) stays native + sync + untouched
6//! (PH7.7 fork, locked): a plugin only contributes entries; it never observes
7//! or reimplements dispatch.
8//!
9//! Two directions, matching the picker seam (`boundary_picker.rs`):
10//!   - **Contexts** are host→guest one-way projections of the dispatch
11//!     environment (`project_*` free fns — the contexts carry `&Buffer` /
12//!     `&CancellationToken` / `Option<&dyn ScopeResolver>` borrows, so they
13//!     cannot round-trip; the guest never sends a context back). Bulk buffer
14//!     text never rides a context — it crosses via the `buffer` `document`
15//!     resource handle (§4.2), so a projection reads only the owned scalars.
16//!   - **Results** come back guest→host: `MotionResult` here; a text object
17//!     returns `range` (`NativeRange::from_wit`), an operator/ex-command returns
18//!     `effect` (`NativeEffect::from_wit`), `parse_args` returns `args`
19//!     (`NativeArgs::from_wit`) — all reusing the PH7.3b conversions.
20//!
21//! The contribution *spec* records (`motion-spec`/…) mirror each native `*Spec`
22//! with the `apply` / `parse_args` closure dropped — the behavior is a sync
23//! guest export the host calls back by callback-id (PH7.7b/c), not a field that
24//! crosses. Their scalar fields reuse the conversions this module adds
25//! (`LatencyClass`, `SurfaceForm`) plus the existing `ArgSpec` mirror; the
26//! WIT-record → native-`*Spec` direction is PH7.7c's trampoline job (it needs
27//! the callback closure), so no spec `from_wit` lands here.
28
29use crate::WitBoundary;
30use crate::lattice::plugin_host::types::{
31    ActionContext as WitActionContext, ExCommandContext as WitExCommandContext,
32    LatencyClass as WitLatencyClass, MotionContext as WitMotionContext,
33    MotionResult as WitMotionResult, OperatorContext as WitOperatorContext,
34    SurfaceForm as WitSurfaceForm, TextObjectContext as WitTextObjectContext,
35};
36use lattice_grammar::command::LatencyClass as NativeLatencyClass;
37use lattice_grammar::registry::{
38    ActionContext as NativeActionContext, ExCommandContext as NativeExCommandContext,
39    MotionContext as NativeMotionContext, MotionResult as NativeMotionResult,
40    OperatorContext as NativeOperatorContext, SurfaceForm as NativeSurfaceForm,
41    TextObjectContext as NativeTextObjectContext,
42};
43
44/// Intern a plugin-supplied owned string as `&'static str`. `SurfaceForm`'s
45impl WitBoundary for NativeLatencyClass {
46    type Wit = WitLatencyClass;
47
48    fn to_wit(&self) -> Result<WitLatencyClass, String> {
49        Ok(match self {
50            NativeLatencyClass::Reflex => WitLatencyClass::Reflex,
51            NativeLatencyClass::Display => WitLatencyClass::Display,
52            NativeLatencyClass::Background => WitLatencyClass::Background,
53        })
54    }
55
56    fn from_wit(wit: WitLatencyClass) -> Result<Self, String> {
57        Ok(match wit {
58            WitLatencyClass::Reflex => NativeLatencyClass::Reflex,
59            WitLatencyClass::Display => NativeLatencyClass::Display,
60            WitLatencyClass::Background => NativeLatencyClass::Background,
61        })
62    }
63}
64
65impl WitBoundary for NativeSurfaceForm {
66    type Wit = WitSurfaceForm;
67
68    fn to_wit(&self) -> Result<WitSurfaceForm, String> {
69        Ok(match self {
70            NativeSurfaceForm::Keyword => WitSurfaceForm::Keyword,
71            NativeSurfaceForm::Delimiter { hint } => WitSurfaceForm::Delimiter(hint.to_string()),
72        })
73    }
74
75    fn from_wit(wit: WitSurfaceForm) -> Result<Self, String> {
76        Ok(match wit {
77            WitSurfaceForm::Keyword => NativeSurfaceForm::Keyword,
78            // PL8.F: `Cow::Owned` — the plugin's delimiter hint frees with the
79            // command entry on `unregister_plugin`, no `Box::leak`.
80            WitSurfaceForm::Delimiter(hint) => NativeSurfaceForm::Delimiter { hint: hint.into() },
81        })
82    }
83}
84
85impl WitBoundary for NativeMotionResult {
86    type Wit = WitMotionResult;
87
88    fn to_wit(&self) -> Result<WitMotionResult, String> {
89        Ok(WitMotionResult {
90            target: self.target.to_wit()?,
91            linewise: self.linewise,
92        })
93    }
94
95    fn from_wit(wit: WitMotionResult) -> Result<Self, String> {
96        Ok(NativeMotionResult {
97            target: lattice_protocol::position::Position::from_wit(wit.target)?,
98            linewise: wit.linewise,
99            // VM.3c: `None` on purpose, not for want of a field. A plugin
100            // declares exclusivity on its `MotionSpec`, which is the right
101            // place for any motion that knows its own answer; the per-result
102            // override exists for `;` / `,`, whose answer depends on what they
103            // are repeating. If a guest ever needs it, it is a WIT addition,
104            // and this line is where it lands.
105            exclusive: None,
106            notice: None,
107            // VM.3g-3: `None` on purpose, on the same reasoning as `exclusive`
108            // above. A plugin's vertical motion declares its `CurswantEffect`
109            // on its `MotionSpec`, which answers for every motion whose goal
110            // rule is fixed; the per-result override exists for `gj` / `gk`,
111            // whose aim is only knowable after the display row is clamped. A
112            // guest that needs it is a WIT addition, and this line is where it
113            // lands.
114            curswant: None,
115        })
116    }
117}
118
119/// Project a live [`MotionContext`](NativeMotionContext) into its owned WIT
120/// mirror (host→guest). Reads only the owned scalars — `&Buffer`,
121/// `&CancellationToken`, and the tree-sitter `scope_resolver` are host-owned and
122/// reached (if at all) through the `document` handle, never this record.
123pub fn project_motion_context(ctx: &NativeMotionContext) -> Result<WitMotionContext, String> {
124    Ok(WitMotionContext {
125        buffer_id: ctx.buffer_id.0,
126        from: ctx.from.to_wit()?,
127        count: ctx.count.get(),
128        has_explicit_count: ctx.has_explicit_count,
129        args: ctx.args.to_wit()?,
130    })
131}
132
133/// Project an [`OperatorContext`](NativeOperatorContext) (host→guest). The
134/// `&mut Document` is not projected — mutation is the returned `effect` (§4.5).
135pub fn project_operator_context(ctx: &NativeOperatorContext) -> Result<WitOperatorContext, String> {
136    Ok(WitOperatorContext {
137        buffer_id: ctx.buffer_id.0,
138        range: ctx.range.to_wit()?,
139        linewise: ctx.linewise,
140        register: ctx.register.to_wit()?,
141        count: ctx.count.get(),
142        args: ctx.args.to_wit()?,
143    })
144}
145
146/// Project a [`TextObjectContext`](NativeTextObjectContext) (host→guest). The
147/// scope/comment env rides the `document` handle, not this record.
148pub fn project_text_object_context(
149    ctx: &NativeTextObjectContext,
150) -> Result<WitTextObjectContext, String> {
151    Ok(WitTextObjectContext {
152        at: ctx.at.to_wit()?,
153        count: ctx.count.get(),
154        args: ctx.args.to_wit()?,
155    })
156}
157
158/// Project an [`ExCommandContext`](NativeExCommandContext) (host→guest). The
159/// native `range: Option<grammar::Range>` is absent — the recursive grammar
160/// `Range` cannot cross a WIT record (the `Global` / `NarrowTrigger` precedent),
161/// so a v1 ex-command plugin gets `bang` / `args` / `register` / `count`.
162pub fn project_ex_command_context(
163    ctx: &NativeExCommandContext,
164) -> Result<WitExCommandContext, String> {
165    Ok(WitExCommandContext {
166        bang: ctx.bang,
167        args: ctx.args.to_wit()?,
168        register: ctx.register.to_wit()?,
169        count: ctx.count.get(),
170        // OC.10: the same two `project_action_context` crosses, for the same
171        // reason — `buffer` is NOT projected here either; it rides the
172        // `borrow<document>` handle the trampoline mints, so bulk rope text
173        // stays off the boundary and only the scalars cross.
174        cursor: ctx.cursor.to_wit()?,
175        buffer_id: ctx.buffer_id.0,
176    })
177}
178
179/// Project an [`ActionContext`](NativeActionContext) (host→guest). The `buffer`
180/// field is NOT projected here — it rides the `borrow<document>` handle the
181/// trampoline mints (AP.0.1), keeping bulk rope text off the boundary; only the
182/// `cursor` scalar crosses in the record.
183pub fn project_action_context(ctx: &NativeActionContext) -> Result<WitActionContext, String> {
184    Ok(WitActionContext {
185        args: ctx.args.to_wit()?,
186        register: ctx.register.to_wit()?,
187        count: ctx.count.get(),
188        cursor: ctx.cursor.to_wit()?,
189        buffer_id: ctx.buffer_id.0,
190        // OS.2: `transpose` so a range that fails to project fails the whole
191        // context rather than silently arriving as `none` — a guest cannot tell
192        // "no region" from "a region we could not encode", and the second is a
193        // bug it would act on.
194        selection: ctx.selection.map(|r| r.to_wit()).transpose()?,
195    })
196}
197
198#[cfg(test)]
199mod tests {
200    #![allow(clippy::unwrap_used, clippy::panic)]
201
202    use super::*;
203    use crate::lattice::plugin_host::types::{Args as WitArgs, Register as WitRegister};
204    use lattice_core::Document;
205    use lattice_core::buffer::Buffer;
206    use lattice_core::buffers::BufferId;
207    use lattice_grammar::CancellationToken;
208    use lattice_grammar::args::{ArgValue, Args};
209    use lattice_grammar::command::Count;
210    use lattice_grammar::register::Register;
211    use lattice_protocol::position::{Position, Range};
212
213    fn pos(line: u32, byte: u32) -> Position {
214        Position { line, byte }
215    }
216
217    #[test]
218    fn latency_class_round_trips_every_arm() {
219        for native in [
220            NativeLatencyClass::Reflex,
221            NativeLatencyClass::Display,
222            NativeLatencyClass::Background,
223        ] {
224            let back = NativeLatencyClass::from_wit(native.to_wit().unwrap()).unwrap();
225            assert_eq!(native, back);
226        }
227    }
228
229    #[test]
230    fn surface_form_round_trips_both_arms() {
231        assert_eq!(
232            NativeSurfaceForm::from_wit(NativeSurfaceForm::Keyword.to_wit().unwrap()).unwrap(),
233            NativeSurfaceForm::Keyword
234        );
235        let delim = NativeSurfaceForm::Delimiter {
236            hint: ":s/pat/repl/".into(),
237        };
238        let back = NativeSurfaceForm::from_wit(delim.to_wit().unwrap()).unwrap();
239        assert_eq!(back, delim);
240    }
241
242    #[test]
243    fn motion_result_round_trips() {
244        let native = NativeMotionResult {
245            curswant: None,
246            target: pos(3, 7),
247            linewise: true,
248            // VM.3c: set to a NON-default so the round-trip below says
249            // something. The WIT mirror has no field for it, so it must come
250            // back `None` — that is the contract, not an oversight, and a
251            // `None` here would have asserted nothing either way.
252            exclusive: Some(true),
253            notice: None,
254        };
255        let back = NativeMotionResult::from_wit(native.to_wit().unwrap()).unwrap();
256        assert_eq!(back.target, native.target);
257        assert_eq!(back.linewise, native.linewise);
258        assert_eq!(
259            back.exclusive, None,
260            "`exclusive` is host-side only: a plugin declares exclusivity on \
261             its MotionSpec, so the WIT mirror carries no field and the decode \
262             must not invent one"
263        );
264    }
265
266    #[test]
267    fn motion_context_projects_owned_scalars() {
268        let buffer = Buffer::from_text("hello world\nsecond line\n");
269        let cancel = CancellationToken::never();
270        let ctx = NativeMotionContext {
271            buffer: &buffer,
272            buffer_id: BufferId(9),
273            from: pos(1, 2),
274            count: Count(4),
275            has_explicit_count: true,
276            args: Args::String("w".into()),
277            cancel: &cancel,
278            scope_resolver: None,
279            path: None,
280            syntax: None,
281            last_find: None,
282            fold_resolver: None,
283            last_search: None,
284            marks: None,
285            viewport: None,
286            nostartofline: false,
287            operator_pending: false,
288            scrolloff: 0,
289            curswant: None,
290            display: None,
291        };
292        let wit = project_motion_context(&ctx).unwrap();
293        assert_eq!(wit.buffer_id, 9);
294        assert_eq!(wit.from.line, 1);
295        assert_eq!(wit.from.byte, 2);
296        assert_eq!(wit.count, 4);
297        assert!(wit.has_explicit_count);
298        assert!(matches!(wit.args, WitArgs::String(ref s) if s == "w"));
299    }
300
301    #[test]
302    fn operator_context_projects_range_and_register() {
303        let mut document = Document::from_text("abc\n");
304        let ctx = NativeOperatorContext {
305            document: &mut document,
306            buffer_id: lattice_core::BufferId(7),
307            range: Range {
308                start: pos(0, 0),
309                end: pos(0, 3),
310            },
311            origin: pos(0, 0),
312            linewise: false,
313            register: Register::Named('a'),
314            count: Count(1),
315            args: Args::None,
316            cancel: &CancellationToken::never(),
317            indent: Default::default(),
318            indent_resolver: None,
319            textwidth: Default::default(),
320            comment_syntax: None,
321            native_format: Default::default(),
322        };
323        let wit = project_operator_context(&ctx).unwrap();
324        assert_eq!(wit.range.start.byte, 0);
325        assert_eq!(wit.range.end.byte, 3);
326        assert!(!wit.linewise);
327        assert!(matches!(wit.register, WitRegister::Named('a')));
328        assert_eq!(wit.count, 1);
329        assert_eq!(wit.buffer_id, 7, "CM.3: the id crosses");
330    }
331
332    #[test]
333    fn text_object_context_projects_cursor_and_count() {
334        let buffer = Buffer::from_text("word\n");
335        let cancel = CancellationToken::never();
336        let ctx = NativeTextObjectContext {
337            buffer: &buffer,
338            at: pos(0, 2),
339            count: Count(2),
340            args: Args::None,
341            cancel: &cancel,
342            scope_resolver: None,
343            comment_syntax: None,
344            path: None,
345            syntax: None,
346        };
347        let wit = project_text_object_context(&ctx).unwrap();
348        assert_eq!(wit.at.byte, 2);
349        assert_eq!(wit.count, 2);
350    }
351
352    #[test]
353    fn ex_command_context_projects_bang_and_args() {
354        let ctx = NativeExCommandContext {
355            bang: true,
356            args: Args::List(vec![ArgValue::String("x".into()), ArgValue::Int(1)]),
357            range: None,
358            register: Register::Unnamed,
359            count: Count(1),
360            buffer_id: lattice_core::BufferId(7),
361            // OC.10: the cursor and buffer id now cross too — `buffer` and
362            // `syntax` deliberately do NOT, they ride the resource handles the
363            // trampoline mints, which is what keeps rope text off the boundary.
364            cursor: lattice_protocol::position::Position { line: 3, byte: 5 },
365            buffer: Default::default(),
366            path: None,
367            syntax: None,
368            cancel: CancellationToken::never(),
369        };
370        let wit = project_ex_command_context(&ctx).unwrap();
371        assert!(wit.bang);
372        assert!(matches!(wit.register, WitRegister::Unnamed));
373        assert!(matches!(wit.args, WitArgs::List(ref v) if v.len() == 2));
374        assert_eq!(wit.cursor.line, 3);
375        assert_eq!(wit.buffer_id, 7);
376    }
377
378    #[test]
379    fn action_context_projects_register_count_and_cursor() {
380        let ctx = NativeActionContext {
381            args: Args::None,
382            register: Register::System,
383            count: Count(3),
384            cursor: pos(4, 2),
385            buffer_id: BufferId(7),
386            buffer: Buffer::from_text("hello\nworld\n"),
387            syntax: None,
388            selection: None,
389            cancel: CancellationToken::never(),
390            path: None,
391        };
392        let wit = project_action_context(&ctx).unwrap();
393        assert_eq!(wit.count, 3);
394        assert!(matches!(wit.register, WitRegister::System));
395        // The cursor scalar + buffer id cross in the record; bulk text does not.
396        assert_eq!(wit.cursor.line, 4);
397        assert_eq!(wit.cursor.byte, 2);
398        assert_eq!(wit.buffer_id, 7);
399    }
400
401    fn action_ctx() -> NativeActionContext {
402        NativeActionContext {
403            args: Args::None,
404            register: Register::Unnamed,
405            count: Count(1),
406            cursor: pos(0, 0),
407            buffer_id: BufferId(1),
408            buffer: Buffer::from_text("one\ntwo\nthree\nfour\nfive\n"),
409            syntax: None,
410            selection: None,
411            cancel: CancellationToken::never(),
412            path: None,
413        }
414    }
415
416    /// OS.2: a Visual action's region must reach the guest. Before it, the WIT
417    /// mirror had nothing to copy and a plugin action saw strictly less than
418    /// the same action reached natively — the position OC.10 fixed for
419    /// `ex-command-context`.
420    #[test]
421    fn a_visual_selection_is_mirrored_to_the_guest() {
422        let mut ctx = action_ctx();
423        ctx.selection = Some(Range::new(pos(2, 0), pos(4, 7)));
424        let wit = project_action_context(&ctx).unwrap();
425        let sel = wit.selection.expect("selection mirrored");
426        assert_eq!((sel.start.line, sel.start.byte), (2, 0));
427        assert_eq!((sel.end.line, sel.end.byte), (4, 7));
428    }
429
430    /// The other half of the contract, and the one a guest relies on to tell
431    /// "act on the region" from "act at the cursor".
432    #[test]
433    fn no_selection_projects_as_none() {
434        assert!(
435            project_action_context(&action_ctx())
436                .unwrap()
437                .selection
438                .is_none()
439        );
440    }
441}