Skip to main content

lattice_plugin_host/
boundary_picker.rs

1//! The picker-source boundary mirrors (plugin-host.md §4.2 / §5 `picker-source`).
2//!
3//! Slice PH7.4a. The `picker-source` WIT interface is the plugin-facing API a
4//! WASM picker source authors against (the user's "expose the api, not sources":
5//! native sources stay native Rust; a plugin implements a source against these
6//! types and registers through the *same* `PickerRegistry::register_generator`
7//! seam a native source uses). This module round-trips that API's data types:
8//!
9//!   - [`Annotation`](NativeAnnotation) (+ `KeyChord`) — marginalia. The
10//!     **whole** closed enum crosses (PH7.4a decision) so a plugin candidate can
11//!     define + populate themed columns. `slot`/`category` are theme element
12//!     KEYS resolved at paint, never baked colors, so `:colorscheme` recolors
13//!     plugin marginalia live. The host lays out `AnnotationColumns` from the
14//!     visible set (a render-consumed projection), so only the per-candidate
15//!     annotations cross, never the column layout.
16//!   - `ArgSpec` / `PickerSourceSpec` — the source's declared metadata.
17//!   - `RoutingPayload` / `OpenTarget` — the per-candidate token a source emits
18//!     and consumes in `accept`.
19//!   - the owned `PickerContext` projection (§4.2) the host hands `init` —
20//!     host→guest only, so it mirrors one-way (a `project_*` fn, the
21//!     `project_buffer_snapshot` precedent), no `from_wit`.
22//!
23//! **The `&'static str` seam.** `PickerSourceSpec`/`ArgSpec` use `&'static str`
24//! for ids/docs/prompts — native sources supply compile-time literals. A WASM
25//! plugin supplies owned runtime strings, so `from_wit` **interns** them
26//! ([`intern`], `Box::leak`). This is bounded by the loaded-source count (each
27//! source's spec is leaked once at registration); unbounded re-registration
28//! (hot reload) is a PH7.12 concern — see the slice plan.
29//!
30//! The active buffer's bulk rope text + syntax-highlight overlay do NOT ride the
31//! context projection; they cross via the `buffer` `document` resource handle,
32//! wired with the `init(ctx)` guest export at PH7.4c. A fuzzy-finder needs
33//! neither.
34
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use crate::WitBoundary;
39use crate::boundary::path_to_wit;
40
41use crate::lattice::plugin_host::types::{
42    ActiveBufferSnapshot as WitActiveBufferSnapshot, AiSessionPayload as WitAiSessionPayload,
43    Annotation as WitAnnotation, AnnotationCustom as WitAnnotationCustom,
44    AnnotationSegment as WitAnnotationSegment, AnnotationStyled as WitAnnotationStyled,
45    ArgDefault as WitArgDefault, ArgKind as WitArgKind, ArgSpec as WitArgSpec,
46    BufferEntry as WitBufferEntry, CommandRef as WitCommandRef, JumpTarget as WitJumpTarget,
47    KeyChord as WitKeyChord, KeyKind as WitKeyKind, Location as WitLocation,
48    LspInstancePayload as WitLspInstancePayload, OpenTarget as WitOpenTarget,
49    PickerContext as WitPickerContext, PickerSourceSpec as WitPickerSourceSpec,
50    PositionEntry as WitPositionEntry, PositionSource as WitPositionSource,
51    ResolveDiffPayload as WitResolveDiffPayload, RoutingPayload as WitRoutingPayload,
52    ShowMessageActionPayload as WitShowMessageActionPayload, SpecialKey as WitSpecialKey,
53    SymbolLocation as WitSymbolLocation,
54};
55
56use lattice_completion::candidate::{
57    Annotation as NativeAnnotation, AnnotationSegment as NativeAnnotationSegment,
58};
59use lattice_grammar::args::{
60    ArgDefault as NativeArgDefault, ArgKind as NativeArgKind, ArgSpec as NativeArgSpec,
61    ArgValue as NativeArgValue,
62};
63use lattice_picker::RoutingPayload as NativeRoutingPayload;
64use lattice_picker::context::{
65    ActiveBufferSnapshot as NativeActiveBufferSnapshot, BufferEntry as NativeBufferEntry,
66    PickerContext as NativePickerContext, PositionEntry as NativePositionEntry,
67    PositionSource as NativePositionSource,
68};
69use lattice_picker::outcome::OpenTarget as NativeOpenTarget;
70use lattice_picker::source::PickerSourceSpec as NativePickerSourceSpec;
71use lattice_protocol::chord::{
72    KeyChord as NativeKeyChord, KeyKind as NativeKeyKind, KeyMods as NativeKeyMods,
73    SpecialKey as NativeSpecialKey,
74};
75
76// PL8.F: the `intern`/`Box::leak` helper is gone. The native picker/grammar
77// spec types (`PickerSourceSpec`, `ArgSpec`) now hold `Cow<'static, str>`, so a
78// plugin's WIT-supplied runtime strings cross as `Cow::Owned` and free with the
79// registry entry on `unregister` — no leak, still no `unsafe`.
80
81// ---- Marginalia: KeyChord + Annotation ----
82
83impl WitBoundary for NativeSpecialKey {
84    type Wit = WitSpecialKey;
85
86    fn to_wit(&self) -> Result<WitSpecialKey, String> {
87        Ok(match self {
88            NativeSpecialKey::Esc => WitSpecialKey::Esc,
89            NativeSpecialKey::Enter => WitSpecialKey::Enter,
90            NativeSpecialKey::Tab => WitSpecialKey::Tab,
91            NativeSpecialKey::Backspace => WitSpecialKey::Backspace,
92            NativeSpecialKey::Space => WitSpecialKey::Space,
93            NativeSpecialKey::Up => WitSpecialKey::Up,
94            NativeSpecialKey::Down => WitSpecialKey::Down,
95            NativeSpecialKey::Left => WitSpecialKey::Left,
96            NativeSpecialKey::Right => WitSpecialKey::Right,
97            NativeSpecialKey::Home => WitSpecialKey::Home,
98            NativeSpecialKey::End => WitSpecialKey::End,
99            NativeSpecialKey::PageUp => WitSpecialKey::PageUp,
100            NativeSpecialKey::PageDown => WitSpecialKey::PageDown,
101            NativeSpecialKey::Insert => WitSpecialKey::Insert,
102            NativeSpecialKey::Delete => WitSpecialKey::Delete,
103            NativeSpecialKey::F(n) => WitSpecialKey::F(*n),
104        })
105    }
106
107    fn from_wit(wit: WitSpecialKey) -> Result<Self, String> {
108        Ok(match wit {
109            WitSpecialKey::Esc => NativeSpecialKey::Esc,
110            WitSpecialKey::Enter => NativeSpecialKey::Enter,
111            WitSpecialKey::Tab => NativeSpecialKey::Tab,
112            WitSpecialKey::Backspace => NativeSpecialKey::Backspace,
113            WitSpecialKey::Space => NativeSpecialKey::Space,
114            WitSpecialKey::Up => NativeSpecialKey::Up,
115            WitSpecialKey::Down => NativeSpecialKey::Down,
116            WitSpecialKey::Left => NativeSpecialKey::Left,
117            WitSpecialKey::Right => NativeSpecialKey::Right,
118            WitSpecialKey::Home => NativeSpecialKey::Home,
119            WitSpecialKey::End => NativeSpecialKey::End,
120            WitSpecialKey::PageUp => NativeSpecialKey::PageUp,
121            WitSpecialKey::PageDown => NativeSpecialKey::PageDown,
122            WitSpecialKey::Insert => NativeSpecialKey::Insert,
123            WitSpecialKey::Delete => NativeSpecialKey::Delete,
124            // `F(0)` is reserved/invalid (`SpecialKey::F` docs `1..=24`); reject
125            // it at the boundary rather than construct an invalid chord.
126            WitSpecialKey::F(0) => {
127                return Err("special-key f(0) is invalid (function keys are 1..=24)".to_string());
128            }
129            WitSpecialKey::F(n) => NativeSpecialKey::F(n),
130        })
131    }
132}
133
134impl WitBoundary for NativeKeyChord {
135    type Wit = WitKeyChord;
136
137    fn to_wit(&self) -> Result<WitKeyChord, String> {
138        let key = match self.key {
139            NativeKeyKind::Char(c) => WitKeyKind::Char(c),
140            NativeKeyKind::Special(s) => WitKeyKind::Special(s.to_wit()?),
141        };
142        Ok(WitKeyChord {
143            key,
144            mods: self.mods.0,
145        })
146    }
147
148    fn from_wit(wit: WitKeyChord) -> Result<Self, String> {
149        let key = match wit.key {
150            WitKeyKind::Char(c) => NativeKeyKind::Char(c),
151            WitKeyKind::Special(s) => NativeKeyKind::Special(NativeSpecialKey::from_wit(s)?),
152        };
153        Ok(NativeKeyChord {
154            key,
155            mods: NativeKeyMods(wit.mods),
156        })
157    }
158}
159
160impl WitBoundary for NativeAnnotation {
161    type Wit = WitAnnotation;
162
163    fn to_wit(&self) -> Result<WitAnnotation, String> {
164        Ok(match self {
165            NativeAnnotation::Kind(s) => WitAnnotation::Kind(s.to_string()),
166            NativeAnnotation::DocSnippet(s) => WitAnnotation::DocSnippet(s.to_string()),
167            NativeAnnotation::Keybinding(chords) => {
168                WitAnnotation::Keybinding(chords.iter().map(WitBoundary::to_wit).collect::<Result<
169                    Vec<_>,
170                    String,
171                >>(
172                )?)
173            }
174            NativeAnnotation::Source(s) => WitAnnotation::Source(s.to_string()),
175            NativeAnnotation::Custom { text, slot } => WitAnnotation::Custom(WitAnnotationCustom {
176                text: text.to_string(),
177                slot: slot.to_string(),
178            }),
179            NativeAnnotation::Styled { category, segments } => {
180                WitAnnotation::Styled(WitAnnotationStyled {
181                    category: category.to_string(),
182                    segments: segments
183                        .iter()
184                        .map(|seg| WitAnnotationSegment {
185                            text: seg.text.to_string(),
186                            slot: seg.slot.to_string(),
187                        })
188                        .collect(),
189                })
190            }
191        })
192    }
193
194    fn from_wit(wit: WitAnnotation) -> Result<Self, String> {
195        Ok(match wit {
196            WitAnnotation::Kind(s) => NativeAnnotation::Kind(Arc::from(s)),
197            WitAnnotation::DocSnippet(s) => NativeAnnotation::DocSnippet(Arc::from(s)),
198            WitAnnotation::Keybinding(chords) => NativeAnnotation::Keybinding(
199                chords
200                    .into_iter()
201                    .map(NativeKeyChord::from_wit)
202                    .collect::<Result<Vec<_>, String>>()?,
203            ),
204            WitAnnotation::Source(s) => NativeAnnotation::Source(Arc::from(s)),
205            WitAnnotation::Custom(c) => NativeAnnotation::Custom {
206                text: Arc::from(c.text),
207                slot: Arc::from(c.slot),
208            },
209            WitAnnotation::Styled(st) => NativeAnnotation::Styled {
210                category: Arc::from(st.category),
211                segments: st
212                    .segments
213                    .into_iter()
214                    .map(|seg| NativeAnnotationSegment {
215                        text: Arc::from(seg.text),
216                        slot: Arc::from(seg.slot),
217                    })
218                    .collect(),
219            },
220        })
221    }
222}
223
224// ---- Source spec: ArgKind / ArgDefault / ArgSpec / PickerSourceSpec ----
225
226impl WitBoundary for NativeArgKind {
227    type Wit = WitArgKind;
228
229    fn to_wit(&self) -> Result<WitArgKind, String> {
230        Ok(match self {
231            NativeArgKind::String => WitArgKind::String,
232            NativeArgKind::Char => WitArgKind::Char,
233            NativeArgKind::Bool => WitArgKind::Bool,
234            NativeArgKind::Int => WitArgKind::Int,
235            NativeArgKind::Pattern => WitArgKind::Pattern,
236            NativeArgKind::Chord => WitArgKind::Chord,
237            NativeArgKind::Body => WitArgKind::Body,
238            NativeArgKind::Raw => WitArgKind::Raw,
239        })
240    }
241
242    fn from_wit(wit: WitArgKind) -> Result<Self, String> {
243        Ok(match wit {
244            WitArgKind::String => NativeArgKind::String,
245            WitArgKind::Char => NativeArgKind::Char,
246            WitArgKind::Bool => NativeArgKind::Bool,
247            WitArgKind::Int => NativeArgKind::Int,
248            WitArgKind::Pattern => NativeArgKind::Pattern,
249            WitArgKind::Chord => NativeArgKind::Chord,
250            WitArgKind::Body => NativeArgKind::Body,
251            WitArgKind::Raw => NativeArgKind::Raw,
252        })
253    }
254}
255
256impl WitBoundary for NativeArgDefault {
257    type Wit = WitArgDefault;
258
259    fn to_wit(&self) -> Result<WitArgDefault, String> {
260        Ok(match self {
261            NativeArgDefault::Required => WitArgDefault::Required,
262            NativeArgDefault::None => WitArgDefault::None,
263            NativeArgDefault::Literal(v) => WitArgDefault::Literal(v.to_wit()?),
264            NativeArgDefault::UseSelection => WitArgDefault::UseSelection,
265            NativeArgDefault::UseCursorWord => WitArgDefault::UseCursorWord,
266            NativeArgDefault::UseLastResponse => WitArgDefault::UseLastResponse,
267        })
268    }
269
270    fn from_wit(wit: WitArgDefault) -> Result<Self, String> {
271        Ok(match wit {
272            WitArgDefault::Required => NativeArgDefault::Required,
273            WitArgDefault::None => NativeArgDefault::None,
274            WitArgDefault::Literal(v) => NativeArgDefault::Literal(NativeArgValue::from_wit(v)?),
275            WitArgDefault::UseSelection => NativeArgDefault::UseSelection,
276            WitArgDefault::UseCursorWord => NativeArgDefault::UseCursorWord,
277            WitArgDefault::UseLastResponse => NativeArgDefault::UseLastResponse,
278        })
279    }
280}
281
282impl WitBoundary for NativeArgSpec {
283    type Wit = WitArgSpec;
284
285    fn to_wit(&self) -> Result<WitArgSpec, String> {
286        Ok(WitArgSpec {
287            name: self.name.to_string(),
288            kind: self.kind.to_wit()?,
289            doc: self.doc.to_string(),
290            prompt: self.prompt.to_string(),
291            default: self.default.to_wit()?,
292            completion: self.completion.as_deref().map(str::to_string),
293            picker: self.picker.as_deref().map(str::to_string),
294        })
295    }
296
297    fn from_wit(wit: WitArgSpec) -> Result<Self, String> {
298        // PL8.F: the plugin's runtime strings become `Cow::Owned` on the native
299        // `ArgSpec` — no `Box::leak`. They free with the command entry on
300        // `unregister_plugin`.
301        Ok(NativeArgSpec {
302            name: wit.name.into(),
303            kind: NativeArgKind::from_wit(wit.kind)?,
304            doc: wit.doc.into(),
305            prompt: wit.prompt.into(),
306            default: NativeArgDefault::from_wit(wit.default)?,
307            completion: wit.completion.map(Into::into),
308            picker: wit.picker.map(Into::into),
309        })
310    }
311}
312
313impl WitBoundary for NativePickerSourceSpec {
314    type Wit = WitPickerSourceSpec;
315
316    fn to_wit(&self) -> Result<WitPickerSourceSpec, String> {
317        Ok(WitPickerSourceSpec {
318            id: self.id.to_string(),
319            doc: self.doc.to_string(),
320            args_schema: self
321                .args_schema
322                .iter()
323                .map(WitBoundary::to_wit)
324                .collect::<Result<Vec<_>, String>>()?,
325            args_hint: self.args_hint.to_string(),
326            live: self.live,
327            create_label: self.create_label.as_ref().map(|l| l.to_string()),
328            rooted: self.rooted,
329            delete_command: self.delete_command.as_ref().map(|c| c.to_string()),
330        })
331    }
332
333    fn from_wit(wit: WitPickerSourceSpec) -> Result<Self, String> {
334        // PL8.F: `Cow::Owned` — the plugin's id/doc/args_hint free on
335        // `PickerRegistry::unregister`, no `Box::leak`.
336        Ok(NativePickerSourceSpec {
337            id: wit.id.into(),
338            doc: wit.doc.into(),
339            args_schema: wit
340                .args_schema
341                .into_iter()
342                .map(NativeArgSpec::from_wit)
343                .collect::<Result<Vec<_>, String>>()?,
344            args_hint: wit.args_hint.into(),
345            live: wit.live,
346            // OR.5: `Cow::Owned`, like the id/doc/args_hint above — a plugin's
347            // label frees on `PickerRegistry::unregister`.
348            create_label: wit.create_label.map(Into::into),
349            // PP.2. This is the direction where the field IS the feature: a
350            // guest declaring `rooted` and arriving here as `false` is a
351            // picker that silently stops naming its root, which reads as the
352            // feature not existing. PC.11's `fill-action` shipped exactly that
353            // way — a mechanical field-add wrote the default on this arm — so
354            // the round-trip test carries a populated value.
355            rooted: wit.rooted,
356            // PD.1, and the same direction the note above is about: a guest
357            // declaring a delete verb that arrived `None` is a `<C-d>` that
358            // silently does nothing.
359            delete_command: wit.delete_command.map(Into::into),
360            // PH.1: no WIT slot, deliberately. A guest documents its picker by
361            // registering a topic named `picker-<id>` through the help seam;
362            // the host namespaces it to `<plugin>.picker-<id>`, and
363            // `Editor::do_picker_help` finds it by convention through the
364            // source's `owner_plugin` — so there is one name (the one
365            // `:help` shows) and no declaration to keep in sync with it.
366            help_topic: None,
367        })
368    }
369}
370
371// ---- OpenTarget + RoutingPayload ----
372
373impl WitBoundary for NativeOpenTarget {
374    type Wit = WitOpenTarget;
375
376    fn to_wit(&self) -> Result<WitOpenTarget, String> {
377        Ok(match self {
378            NativeOpenTarget::Default => WitOpenTarget::Default,
379            NativeOpenTarget::Split => WitOpenTarget::Split,
380            NativeOpenTarget::VSplit => WitOpenTarget::Vsplit,
381            NativeOpenTarget::Tab => WitOpenTarget::Tab,
382        })
383    }
384
385    fn from_wit(wit: WitOpenTarget) -> Result<Self, String> {
386        Ok(match wit {
387            WitOpenTarget::Default => NativeOpenTarget::Default,
388            WitOpenTarget::Split => NativeOpenTarget::Split,
389            WitOpenTarget::Vsplit => NativeOpenTarget::VSplit,
390            WitOpenTarget::Tab => NativeOpenTarget::Tab,
391        })
392    }
393}
394
395impl WitBoundary for NativeRoutingPayload {
396    type Wit = WitRoutingPayload;
397
398    fn to_wit(&self) -> Result<WitRoutingPayload, String> {
399        Ok(match self {
400            NativeRoutingPayload::Buffer { id } => WitRoutingPayload::Buffer(*id),
401            NativeRoutingPayload::PaneHistoryEntry { index } => {
402                WitRoutingPayload::PaneHistoryEntry(*index)
403            }
404            NativeRoutingPayload::ResolveDiff { primary, accept } => {
405                WitRoutingPayload::ResolveDiff(WitResolveDiffPayload {
406                    primary: *primary,
407                    accept: *accept,
408                })
409            }
410            NativeRoutingPayload::LspInstance {
411                server_id,
412                workspace,
413            } => WitRoutingPayload::LspInstance(WitLspInstancePayload {
414                server_id: server_id.clone(),
415                workspace: path_to_wit(workspace)?,
416            }),
417            NativeRoutingPayload::LspLocation { path, line, col } => {
418                WitRoutingPayload::LspLocation(WitLocation {
419                    path: path_to_wit(path)?,
420                    line: *line,
421                    col: *col,
422                })
423            }
424            NativeRoutingPayload::LspCompletion { index } => {
425                WitRoutingPayload::LspCompletion(*index)
426            }
427            NativeRoutingPayload::LspCodeAction { index } => {
428                WitRoutingPayload::LspCodeAction(*index)
429            }
430            NativeRoutingPayload::OpenFile { path } => {
431                WitRoutingPayload::OpenFile(path_to_wit(path)?)
432            }
433            NativeRoutingPayload::JumpInBuffer {
434                buffer_id,
435                line,
436                col,
437            } => WitRoutingPayload::JumpInBuffer(WitJumpTarget {
438                buffer_id: *buffer_id,
439                line: *line,
440                col: *col,
441            }),
442            NativeRoutingPayload::InvokeCommand { id, args } => {
443                WitRoutingPayload::InvokeCommand(WitCommandRef {
444                    id: id.clone(),
445                    args: args.to_wit()?,
446                })
447            }
448            NativeRoutingPayload::PasteRegister { name } => WitRoutingPayload::PasteRegister(*name),
449            NativeRoutingPayload::JumpToMark { name } => WitRoutingPayload::JumpToMark(*name),
450            NativeRoutingPayload::ExpandSnippet { id } => {
451                WitRoutingPayload::ExpandSnippet(id.clone())
452            }
453            NativeRoutingPayload::AcceptShowMessageAction {
454                request_id,
455                action_index,
456            } => WitRoutingPayload::AcceptShowMessageAction(WitShowMessageActionPayload {
457                request_id: *request_id,
458                action_index: *action_index,
459            }),
460            NativeRoutingPayload::LspCodeLens { index } => WitRoutingPayload::LspCodeLens(*index),
461            NativeRoutingPayload::ColorPresentation { index } => {
462                WitRoutingPayload::ColorPresentation(*index)
463            }
464            NativeRoutingPayload::Colorscheme { name } => {
465                WitRoutingPayload::Colorscheme(name.clone())
466            }
467            NativeRoutingPayload::AiSession { provider, index } => {
468                WitRoutingPayload::AiSession(WitAiSessionPayload {
469                    provider: provider.clone(),
470                    index: *index,
471                })
472            }
473            // MB.3: `LoadCommandLine` is the native `history` picker's
474            // per-candidate token — host-internal, not part of the plugin
475            // WIT surface (a plugin picker source cannot seed the `:` line).
476            // It never crosses the boundary.
477            NativeRoutingPayload::LoadCommandLine { .. } => {
478                return Err(
479                    "load-command-line is a host-internal picker routing payload, not representable over WIT"
480                        .into(),
481                );
482            }
483            // MB.5: `LoadSearchLine` — same as LoadCommandLine above,
484            // host-internal search-history picker routing.
485            NativeRoutingPayload::LoadSearchLine { .. } => {
486                return Err(
487                    "load-search-line is a host-internal picker routing payload, not representable over WIT"
488                        .into(),
489                );
490            }
491            // magit's branch-create wizard — host-internal, same shape
492            // as LoadCommandLine/LoadSearchLine above.
493            NativeRoutingPayload::BranchBase { .. } => {
494                return Err(
495                    "branch-base is a host-internal picker routing payload, not representable over WIT"
496                        .into(),
497                );
498            }
499            // MG.53.e: pairs with `supply-value` in `boundary.rs` — the
500            // thing waiting for the value is host state a plugin cannot
501            // address.
502            NativeRoutingPayload::SuppliedValue { .. } => {
503                return Err(
504                    "supplied-value is a host-internal picker routing payload, not representable over WIT"
505                        .into(),
506                );
507            }
508            // OR.6: a place in a file. Crosses like `lsp-location` does.
509            NativeRoutingPayload::FileLocation { path, line, col } => {
510                WitRoutingPayload::FileLocation(crate::lattice::plugin_host::types::Location {
511                    path: crate::boundary::path_to_wit(path)?,
512                    line: *line,
513                    col: *col,
514                })
515            }
516            // OR.5: the create row's query, crossing VERBATIM. This one DOES
517            // reach a plugin — it is the whole point of the slice, since the
518            // source that declared `create_label` is the one that has to decide
519            // what creation means.
520            NativeRoutingPayload::Create { query } => WitRoutingPayload::Create(query.clone()),
521        })
522    }
523
524    fn from_wit(wit: WitRoutingPayload) -> Result<Self, String> {
525        Ok(match wit {
526            WitRoutingPayload::Buffer(id) => NativeRoutingPayload::Buffer { id },
527            // OR.6: a place in a file, back from the guest.
528            WitRoutingPayload::FileLocation(loc) => NativeRoutingPayload::FileLocation {
529                path: std::path::PathBuf::from(loc.path),
530                line: loc.line,
531                col: loc.col,
532            },
533            // OR.5. A guest never *emits* one of these — the picker synthesises
534            // the row — but the mirror is symmetric so a guest that echoes a
535            // routing token back round-trips rather than erroring.
536            WitRoutingPayload::Create(query) => NativeRoutingPayload::Create { query },
537            WitRoutingPayload::PaneHistoryEntry(index) => {
538                NativeRoutingPayload::PaneHistoryEntry { index }
539            }
540            WitRoutingPayload::ResolveDiff(p) => NativeRoutingPayload::ResolveDiff {
541                primary: p.primary,
542                accept: p.accept,
543            },
544            WitRoutingPayload::LspInstance(p) => NativeRoutingPayload::LspInstance {
545                server_id: p.server_id,
546                workspace: PathBuf::from(p.workspace),
547            },
548            WitRoutingPayload::LspLocation(l) => NativeRoutingPayload::LspLocation {
549                path: PathBuf::from(l.path),
550                line: l.line,
551                col: l.col,
552            },
553            WitRoutingPayload::LspCompletion(index) => {
554                NativeRoutingPayload::LspCompletion { index }
555            }
556            WitRoutingPayload::LspCodeAction(index) => {
557                NativeRoutingPayload::LspCodeAction { index }
558            }
559            WitRoutingPayload::OpenFile(path) => NativeRoutingPayload::OpenFile {
560                path: PathBuf::from(path),
561            },
562            WitRoutingPayload::JumpInBuffer(t) => NativeRoutingPayload::JumpInBuffer {
563                buffer_id: t.buffer_id,
564                line: t.line,
565                col: t.col,
566            },
567            WitRoutingPayload::InvokeCommand(c) => NativeRoutingPayload::InvokeCommand {
568                id: c.id,
569                args: lattice_grammar::args::Args::from_wit(c.args)?,
570            },
571            WitRoutingPayload::PasteRegister(name) => NativeRoutingPayload::PasteRegister { name },
572            WitRoutingPayload::JumpToMark(name) => NativeRoutingPayload::JumpToMark { name },
573            WitRoutingPayload::ExpandSnippet(id) => NativeRoutingPayload::ExpandSnippet { id },
574            WitRoutingPayload::AcceptShowMessageAction(p) => {
575                NativeRoutingPayload::AcceptShowMessageAction {
576                    request_id: p.request_id,
577                    action_index: p.action_index,
578                }
579            }
580            WitRoutingPayload::LspCodeLens(index) => NativeRoutingPayload::LspCodeLens { index },
581            WitRoutingPayload::ColorPresentation(index) => {
582                NativeRoutingPayload::ColorPresentation { index }
583            }
584            WitRoutingPayload::Colorscheme(name) => NativeRoutingPayload::Colorscheme { name },
585            WitRoutingPayload::AiSession(p) => NativeRoutingPayload::AiSession {
586                provider: p.provider,
587                index: p.index,
588            },
589        })
590    }
591}
592
593// ---- The owned `PickerContext` projection (§4.2, host→guest only) ----
594
595fn project_position_source(src: &NativePositionSource) -> WitPositionSource {
596    match src {
597        NativePositionSource::AutoJump => WitPositionSource::AutoJump,
598        NativePositionSource::ExplicitMark => WitPositionSource::ExplicitMark,
599        NativePositionSource::PluginPush => WitPositionSource::PluginPush,
600        NativePositionSource::NamedMark(c) => WitPositionSource::NamedMark(*c),
601    }
602}
603
604fn project_position_entry(e: &NativePositionEntry) -> WitPositionEntry {
605    WitPositionEntry {
606        buffer_id: e.buffer_id,
607        line: e.line,
608        col: e.col,
609        source: project_position_source(&e.source),
610    }
611}
612
613fn project_buffer_entry(e: &NativeBufferEntry) -> Result<WitBufferEntry, String> {
614    Ok(WitBufferEntry {
615        id: e.id,
616        kind_label: e.kind_label.clone(),
617        path: e.path.as_deref().map(path_to_wit).transpose()?,
618        title: e.title.clone(),
619        dirty: e.dirty,
620    })
621}
622
623fn project_active_buffer_snapshot(
624    ab: &NativeActiveBufferSnapshot<'_>,
625) -> Result<WitActiveBufferSnapshot, String> {
626    let selection = match ab.selection {
627        Some((a, b)) => Some((a.to_wit()?, b.to_wit()?)),
628        None => None,
629    };
630    Ok(WitActiveBufferSnapshot {
631        buffer_id: ab.buffer_id,
632        path: ab.path.map(path_to_wit).transpose()?,
633        language: ab.language.map(str::to_string),
634        cursor: ab.cursor.to_wit()?,
635        selection,
636        syntax_symbols: ab
637            .syntax_symbols
638            .iter()
639            .map(|(name, line, col)| WitSymbolLocation {
640                name: name.clone(),
641                line: *line,
642                col: *col,
643            })
644            .collect(),
645    })
646}
647
648/// Project a live [`PickerContext`](NativePickerContext) into its owned WIT
649/// mirror (§4.2). Host→guest only: the host builds this at `init` time; the
650/// guest never sends a context back. A non-UTF-8 path anywhere in the context
651/// is a typed error (§4.4), never a lossy encoding.
652pub fn project_picker_context(ctx: &NativePickerContext<'_>) -> Result<WitPickerContext, String> {
653    Ok(WitPickerContext {
654        active_buffer: project_active_buffer_snapshot(&ctx.active_buffer)?,
655        workspace_root: path_to_wit(&ctx.workspace_root)?,
656        recent_files: ctx
657            .recent_files
658            .iter()
659            .map(|p| path_to_wit(p))
660            .collect::<Result<Vec<_>, String>>()?,
661        position_history: ctx
662            .position_history
663            .iter()
664            .map(project_position_entry)
665            .collect(),
666        buffers: ctx
667            .buffers
668            .iter()
669            .map(project_buffer_entry)
670            .collect::<Result<Vec<_>, String>>()?,
671        marks: ctx
672            .marks
673            .iter()
674            .map(|(c, pos)| Ok((*c, pos.to_wit()?)))
675            .collect::<Result<Vec<_>, String>>()?,
676        registers: ctx.registers.clone(),
677    })
678}
679
680#[cfg(test)]
681mod tests {
682    #![allow(clippy::unwrap_used, clippy::panic)]
683
684    use super::*;
685    use lattice_completion::candidate::AnnotationSegment;
686
687    // ---- KeyChord + Annotation ----
688
689    fn assert_chord_round_trips(native: NativeKeyChord) {
690        let back = NativeKeyChord::from_wit(native.to_wit().unwrap()).unwrap();
691        assert_eq!(native, back);
692    }
693
694    #[test]
695    fn key_chord_round_trips_char_special_and_mods() {
696        assert_chord_round_trips(NativeKeyChord {
697            key: NativeKeyKind::Char('a'),
698            mods: NativeKeyMods::NONE,
699        });
700        assert_chord_round_trips(NativeKeyChord {
701            key: NativeKeyKind::Char('x'),
702            mods: NativeKeyMods::CTRL,
703        });
704        assert_chord_round_trips(NativeKeyChord {
705            key: NativeKeyKind::Special(NativeSpecialKey::Tab),
706            mods: NativeKeyMods(NativeKeyMods::CTRL.0 | NativeKeyMods::SHIFT.0),
707        });
708        assert_chord_round_trips(NativeKeyChord {
709            key: NativeKeyKind::Special(NativeSpecialKey::F(12)),
710            mods: NativeKeyMods::ALT,
711        });
712    }
713
714    #[test]
715    fn special_key_f0_is_a_typed_error() {
716        let err = NativeSpecialKey::from_wit(WitSpecialKey::F(0)).expect_err("f(0) must reject");
717        assert!(err.contains("f(0)"), "error names the culprit: {err}");
718    }
719
720    fn assert_annotation_round_trips(native: NativeAnnotation) {
721        let back = NativeAnnotation::from_wit(native.to_wit().unwrap()).unwrap();
722        assert_eq!(native, back);
723    }
724
725    #[test]
726    fn annotation_round_trips_every_variant() {
727        assert_annotation_round_trips(NativeAnnotation::Kind(Arc::from("(file)")));
728        assert_annotation_round_trips(NativeAnnotation::DocSnippet(Arc::from("opens a file")));
729        assert_annotation_round_trips(NativeAnnotation::Keybinding(vec![
730            NativeKeyChord {
731                key: NativeKeyKind::Char('g'),
732                mods: NativeKeyMods::NONE,
733            },
734            NativeKeyChord {
735                key: NativeKeyKind::Special(NativeSpecialKey::Esc),
736                mods: NativeKeyMods::CTRL,
737            },
738        ]));
739        assert_annotation_round_trips(NativeAnnotation::Source(Arc::from("lsp")));
740        assert_annotation_round_trips(NativeAnnotation::Custom {
741            text: Arc::from("42K"),
742            slot: Arc::from("annotation_size"),
743        });
744        // The marginalia column a plugin file source would emit: a permission
745        // string split into per-bit-class segments, each its own theme slot.
746        assert_annotation_round_trips(NativeAnnotation::Styled {
747            category: Arc::from("perms"),
748            segments: vec![
749                AnnotationSegment {
750                    text: Arc::from("d"),
751                    slot: Arc::from("perm_dir"),
752                },
753                AnnotationSegment {
754                    text: Arc::from("rwx"),
755                    slot: Arc::from("perm_owner"),
756                },
757            ],
758        });
759    }
760
761    // ---- ArgSpec / PickerSourceSpec (leak-tolerant field compare) ----
762
763    #[test]
764    fn arg_spec_round_trips_through_intern() {
765        let native = NativeArgSpec {
766            name: "root".into(),
767            kind: NativeArgKind::String,
768            doc: "directory to walk".into(),
769            prompt: "root:".into(),
770            default: NativeArgDefault::Literal(NativeArgValue::String("/tmp".into())),
771            completion: Some("gen:files".into()),
772            picker: None,
773        };
774        let back = NativeArgSpec::from_wit(native.to_wit().unwrap()).unwrap();
775        assert_eq!(back.name, "root");
776        assert_eq!(back.doc, "directory to walk");
777        assert_eq!(back.prompt, "root:");
778        assert_eq!(back.completion.as_deref(), Some("gen:files"));
779        assert!(matches!(back.kind, NativeArgKind::String));
780        assert!(matches!(
781            back.default,
782            NativeArgDefault::Literal(NativeArgValue::String(ref s)) if s == "/tmp"
783        ));
784    }
785
786    #[test]
787    fn picker_source_spec_round_trips() {
788        let native = NativePickerSourceSpec {
789            id: "files".into(),
790            doc: "file picker".into(),
791            args_schema: vec![NativeArgSpec {
792                name: "root".into(),
793                kind: NativeArgKind::String,
794                doc: "dir".into(),
795                prompt: "root:".into(),
796                default: NativeArgDefault::None,
797                completion: Some("gen:files".into()),
798                picker: None,
799            }],
800            args_hint: "[root]".into(),
801            live: false,
802            // OR.5: a real label, so the round trip proves the field crosses
803            // rather than proving `None == None`.
804            create_label: Some("Create note: %s".into()),
805            // PP.2: `true`, for the same reason and against the same failure.
806            // `rooted: false` would round-trip through an arm that dropped the
807            // field entirely — the exact shape PC.11's `fill-action` shipped,
808            // where a mechanical field-add wrote the default on the one arm
809            // where the value IS the feature.
810            rooted: true,
811            // PD.1, populated for that same reason: a `None` here would
812            // round-trip clean through an arm that dropped the field, and the
813            // value is the whole feature — a guest whose delete verb arrived
814            // `None` gets a `<C-d>` that silently does nothing.
815            delete_command: Some("project-forget".into()),
816            // PH.1: host-only — it has no WIT slot, so it cannot round-trip.
817            help_topic: None,
818        };
819        let back = NativePickerSourceSpec::from_wit(native.to_wit().unwrap()).unwrap();
820        assert_eq!(back.id, "files");
821        assert_eq!(back.doc, "file picker");
822        assert_eq!(back.args_hint, "[root]");
823        assert!(!back.live);
824        assert_eq!(back.create_label.as_deref(), Some("Create note: %s"));
825        assert!(
826            back.rooted,
827            "a guest declaring `rooted` must arrive rooted — a picker that \
828             silently stops naming its root reads as the feature not existing"
829        );
830        assert_eq!(
831            back.delete_command.as_deref(),
832            Some("project-forget"),
833            "a guest declaring a delete verb must arrive with it — `<C-d>` is \
834             silent by design when none is declared, so a dropped field is \
835             indistinguishable from a source that never wanted the key"
836        );
837        assert_eq!(back.args_schema.len(), 1);
838        assert_eq!(back.args_schema[0].name, "root");
839    }
840
841    // ---- OpenTarget + RoutingPayload ----
842
843    #[test]
844    fn open_target_round_trips_every_variant() {
845        for t in [
846            NativeOpenTarget::Default,
847            NativeOpenTarget::Split,
848            NativeOpenTarget::VSplit,
849            NativeOpenTarget::Tab,
850        ] {
851            let back = NativeOpenTarget::from_wit(t.to_wit().unwrap()).unwrap();
852            assert_eq!(t, back);
853        }
854    }
855
856    /// `RoutingPayload` carries `Args` (no `PartialEq`), so assert via the round-
857    /// tripped Debug shape — an equality proxy that still catches field drift.
858    fn assert_routing_round_trips(native: NativeRoutingPayload) {
859        let dbg = format!("{native:?}");
860        let back = NativeRoutingPayload::from_wit(native.to_wit().unwrap()).unwrap();
861        assert_eq!(dbg, format!("{back:?}"));
862    }
863
864    #[test]
865    fn routing_payload_round_trips_representative_variants() {
866        assert_routing_round_trips(NativeRoutingPayload::Buffer { id: 7 });
867        assert_routing_round_trips(NativeRoutingPayload::ResolveDiff {
868            primary: 3,
869            accept: true,
870        });
871        assert_routing_round_trips(NativeRoutingPayload::LspInstance {
872            server_id: "rust-analyzer".into(),
873            workspace: PathBuf::from("/home/x/proj"),
874        });
875        assert_routing_round_trips(NativeRoutingPayload::LspLocation {
876            path: PathBuf::from("/a/b.rs"),
877            line: 12,
878            col: 4,
879        });
880        assert_routing_round_trips(NativeRoutingPayload::OpenFile {
881            path: PathBuf::from("/tmp/foo.rs"),
882        });
883        assert_routing_round_trips(NativeRoutingPayload::JumpInBuffer {
884            buffer_id: 2,
885            line: 40,
886            col: 1,
887        });
888        assert_routing_round_trips(NativeRoutingPayload::InvokeCommand {
889            id: "write".into(),
890            args: lattice_grammar::args::Args::None,
891        });
892        assert_routing_round_trips(NativeRoutingPayload::PasteRegister { name: 'a' });
893        assert_routing_round_trips(NativeRoutingPayload::JumpToMark { name: 'z' });
894        assert_routing_round_trips(NativeRoutingPayload::ExpandSnippet { id: "fn".into() });
895        assert_routing_round_trips(NativeRoutingPayload::AcceptShowMessageAction {
896            request_id: 5,
897            action_index: 1,
898        });
899        assert_routing_round_trips(NativeRoutingPayload::LspCodeLens { index: 9 });
900        assert_routing_round_trips(NativeRoutingPayload::ColorPresentation { index: 0 });
901        assert_routing_round_trips(NativeRoutingPayload::Colorscheme {
902            name: "nord".into(),
903        });
904    }
905
906    #[test]
907    #[cfg(unix)]
908    fn routing_payload_non_utf8_path_is_a_typed_error() {
909        use std::os::unix::ffi::OsStrExt;
910        let bad = std::ffi::OsStr::from_bytes(&[0xff, 0xfe]);
911        let native = NativeRoutingPayload::OpenFile {
912            path: PathBuf::from(bad),
913        };
914        let err = native.to_wit().expect_err("non-utf8 path must reject");
915        assert!(err.contains("UTF-8"), "error explains the failure: {err}");
916    }
917}