Skip to main content

lattice_plugin_host/
boundary.rs

1//! The boundary adapter machinery (plugin-host.md §4).
2//!
3//! Design fragment §4. Slice: PH7.3a (conventions + `Args`); the `Effect`
4//! variant mirror is PH7.3b; the `document` resource handle + owned-snapshot
5//! projection is PH7.3c.
6//!
7//! Native host types (`lattice_grammar::args::Args`, `Effect`, the picker /
8//! completion records) cannot cross a WASM boundary as-is — they carry
9//! borrows, closures, `Future`/`Stream` carriers, or non-serde fields (§4.1–
10//! §4.5). The **generated WIT mirror** (from `wit/types.wit`, emitted by
11//! `bindgen!` at the crate root) is the owned, serializable shape the guest
12//! sees. [`WitBoundary`] is the single adapter contract between the two: one
13//! place that defines `to_wit` / `from_wit` and the `Result<_, String>` error
14//! convention, so every type round-trips uniformly and a malformed payload is
15//! rejected at the boundary rather than at apply-time.
16//!
17//! The `String` error arm is deliberate: it is the WIT `result<_, string>`
18//! convention (§4, "Result<_, string> error convention"), so a conversion that
19//! cannot be represented yet (e.g. a nested `CommandInvocation`, §4.1) surfaces
20//! as a typed error the host logs + skips, never a panic or a lossy encoding.
21
22// The generated WIT mirrors live at the crate root (the `bindgen!` site);
23// alias them so native/`Wit` pairs read unambiguously.
24use std::path::{Path, PathBuf};
25
26// All WIT types the `types` interface defines live under this generated
27// module; the world-level `use` also surfaces a few at the crate root, but the
28// transitively-referenced payload records only exist here, so import uniformly.
29use crate::lattice::plugin_host::types::{
30    ArgValue as WitArgValue, Args as WitArgs, CandidateChord as WitCandidateChord,
31    CandidateData as WitCandidateData, CandidateExtension as WitCandidateExtension,
32    CandidateFile as WitCandidateFile, CandidateKind as WitCandidateKind,
33    CandidateMark as WitCandidateMark, CandidateOption as WitCandidateOption,
34    CandidateOptionValue as WitCandidateOptionValue, CandidateRegister as WitCandidateRegister,
35    CommandRef as WitCommandRef, JumpTarget as WitJumpTarget, Location as WitLocation,
36    LspCodeActionRef as WitLspCodeActionRef, PickerAcceptOutcome as WitPickerAcceptOutcome,
37    RawCandidate as WitRawCandidate,
38};
39use lattice_completion::candidate::{
40    CandidateData as NativeCandidateData, CandidateKind as NativeCandidateKind,
41    RawCandidate as NativeRawCandidate,
42};
43use lattice_completion::insert::SourceId;
44use lattice_grammar::args::{ArgValue as NativeArgValue, Args as NativeArgs};
45use lattice_picker::outcome::PickerAcceptOutcome as NativePickerAcceptOutcome;
46
47/// A host path crosses the boundary as a WIT `string`, which must be UTF-8. A
48/// non-UTF-8 path cannot cross faithfully, so it is a typed error rather than a
49/// lossy `to_string_lossy`.
50pub(crate) fn path_to_wit(path: &Path) -> Result<String, String> {
51    path.to_str().map(str::to_string).ok_or_else(|| {
52        format!(
53            "path is not valid UTF-8 and cannot cross the boundary: {}",
54            path.display()
55        )
56    })
57}
58
59/// Converts a native host type to/from its generated WIT mirror.
60///
61/// `to_wit` borrows the native value (the host owns it); `from_wit` consumes
62/// the WIT value (it arrived by value across the boundary). Both return
63/// `Result<_, String>` — the WIT `result<_, string>` convention — so a value
64/// that cannot be represented in the current WIT surface is a typed error, not
65/// a panic or a silent drop.
66pub trait WitBoundary: Sized {
67    /// The generated WIT mirror type.
68    type Wit;
69
70    /// Project the native value into its owned WIT mirror.
71    fn to_wit(&self) -> Result<Self::Wit, String>;
72
73    /// Reconstruct the native value from its WIT mirror.
74    fn from_wit(wit: Self::Wit) -> Result<Self, String>;
75}
76
77impl WitBoundary for NativeArgValue {
78    type Wit = WitArgValue;
79
80    fn to_wit(&self) -> Result<WitArgValue, String> {
81        Ok(match self {
82            NativeArgValue::String(s) => WitArgValue::String(s.clone()),
83            NativeArgValue::Char(c) => WitArgValue::Char(*c),
84            NativeArgValue::Bool(b) => WitArgValue::Bool(*b),
85            NativeArgValue::Int(i) => WitArgValue::Int(*i),
86            NativeArgValue::Pattern(s) => WitArgValue::Pattern(s.clone()),
87            NativeArgValue::Chord(s) => WitArgValue::Chord(s.clone()),
88            NativeArgValue::Raw(s) => WitArgValue::Raw(s.clone()),
89            // A nested invocation needs the command mirror (§4.1); until then
90            // it crosses as a typed error rather than a lossy string.
91            NativeArgValue::Invocation(_) => {
92                return Err(
93                    "ArgValue::Invocation crosses the boundary with the command mirror \
94                     (PH7.3b, fragment §4.1)"
95                        .to_string(),
96                );
97            }
98        })
99    }
100
101    fn from_wit(wit: WitArgValue) -> Result<Self, String> {
102        Ok(match wit {
103            WitArgValue::String(s) => NativeArgValue::String(s),
104            WitArgValue::Char(c) => NativeArgValue::Char(c),
105            WitArgValue::Bool(b) => NativeArgValue::Bool(b),
106            WitArgValue::Int(i) => NativeArgValue::Int(i),
107            WitArgValue::Pattern(s) => NativeArgValue::Pattern(s),
108            WitArgValue::Chord(s) => NativeArgValue::Chord(s),
109            WitArgValue::Raw(s) => NativeArgValue::Raw(s),
110        })
111    }
112}
113
114impl WitBoundary for NativeArgs {
115    type Wit = WitArgs;
116
117    fn to_wit(&self) -> Result<WitArgs, String> {
118        Ok(match self {
119            NativeArgs::None => WitArgs::None,
120            NativeArgs::Char(c) => WitArgs::Char(*c),
121            NativeArgs::String(s) => WitArgs::String(s.clone()),
122            NativeArgs::Bytes(b) => WitArgs::Bytes(b.clone()),
123            NativeArgs::List(values) => WitArgs::List(
124                values
125                    .iter()
126                    .map(WitBoundary::to_wit)
127                    .collect::<Result<Vec<_>, _>>()?,
128            ),
129        })
130    }
131
132    fn from_wit(wit: WitArgs) -> Result<Self, String> {
133        Ok(match wit {
134            WitArgs::None => NativeArgs::None,
135            WitArgs::Char(c) => NativeArgs::Char(c),
136            WitArgs::String(s) => NativeArgs::String(s),
137            WitArgs::Bytes(b) => NativeArgs::Bytes(b),
138            WitArgs::List(values) => NativeArgs::List(
139                values
140                    .into_iter()
141                    .map(NativeArgValue::from_wit)
142                    .collect::<Result<Vec<_>, _>>()?,
143            ),
144        })
145    }
146}
147
148impl WitBoundary for NativeCandidateKind {
149    type Wit = WitCandidateKind;
150
151    fn to_wit(&self) -> Result<WitCandidateKind, String> {
152        Ok(match self {
153            NativeCandidateKind::Command => WitCandidateKind::Command,
154            NativeCandidateKind::Option => WitCandidateKind::Option,
155            NativeCandidateKind::File => WitCandidateKind::File,
156            NativeCandidateKind::Directory => WitCandidateKind::Directory,
157            NativeCandidateKind::Pattern => WitCandidateKind::Pattern,
158            NativeCandidateKind::Buffer => WitCandidateKind::Buffer,
159            NativeCandidateKind::Register => WitCandidateKind::Register,
160            NativeCandidateKind::Mark => WitCandidateKind::Mark,
161            NativeCandidateKind::Chord => WitCandidateKind::Chord,
162            NativeCandidateKind::Plain => WitCandidateKind::Plain,
163            NativeCandidateKind::Extension(tag) => WitCandidateKind::Extension(*tag),
164        })
165    }
166
167    fn from_wit(wit: WitCandidateKind) -> Result<Self, String> {
168        Ok(match wit {
169            WitCandidateKind::Command => NativeCandidateKind::Command,
170            WitCandidateKind::Option => NativeCandidateKind::Option,
171            WitCandidateKind::File => NativeCandidateKind::File,
172            WitCandidateKind::Directory => NativeCandidateKind::Directory,
173            WitCandidateKind::Pattern => NativeCandidateKind::Pattern,
174            WitCandidateKind::Buffer => NativeCandidateKind::Buffer,
175            WitCandidateKind::Register => NativeCandidateKind::Register,
176            WitCandidateKind::Mark => NativeCandidateKind::Mark,
177            WitCandidateKind::Chord => NativeCandidateKind::Chord,
178            WitCandidateKind::Plain => NativeCandidateKind::Plain,
179            WitCandidateKind::Extension(tag) => NativeCandidateKind::Extension(tag),
180        })
181    }
182}
183
184impl WitBoundary for NativeCandidateData {
185    type Wit = WitCandidateData;
186
187    fn to_wit(&self) -> Result<WitCandidateData, String> {
188        Ok(match self {
189            // `Command` carries a recursive `SourceLocation` (§4.4); it is a
190            // native-generator concern and crosses only once the provenance
191            // mirror lands — a typed error, never a lossy encoding.
192            NativeCandidateData::Command { .. } => {
193                return Err(
194                    "CandidateData::Command carries a recursive SourceLocation; it crosses \
195                     with the provenance mirror (fragment §4.4)"
196                        .to_string(),
197                );
198            }
199            NativeCandidateData::File { path, is_dir, size } => {
200                WitCandidateData::File(WitCandidateFile {
201                    path: path_to_wit(path)?,
202                    is_dir: *is_dir,
203                    size: *size,
204                })
205            }
206            NativeCandidateData::Option {
207                name,
208                current_value,
209                doc,
210            } => WitCandidateData::Option(WitCandidateOption {
211                name: name.clone(),
212                current_value: current_value.clone(),
213                doc: doc.clone(),
214            }),
215            NativeCandidateData::OptionValue {
216                option_name,
217                value,
218                doc,
219            } => WitCandidateData::OptionValue(WitCandidateOptionValue {
220                option_name: option_name.clone(),
221                value: value.clone(),
222                doc: doc.clone(),
223            }),
224            NativeCandidateData::Chord {
225                chord,
226                mode_label,
227                doc,
228            } => WitCandidateData::Chord(WitCandidateChord {
229                chord: chord.clone(),
230                mode_label: mode_label.clone(),
231                doc: doc.clone(),
232            }),
233            NativeCandidateData::Register { name, preview } => {
234                WitCandidateData::Register(WitCandidateRegister {
235                    name: *name,
236                    preview: preview.clone(),
237                })
238            }
239            NativeCandidateData::Mark { name, position } => {
240                WitCandidateData::Mark(WitCandidateMark {
241                    name: *name,
242                    position: position.clone(),
243                })
244            }
245            NativeCandidateData::Plain => WitCandidateData::Plain,
246            NativeCandidateData::Extension { kind_id, payload } => {
247                WitCandidateData::Extension(WitCandidateExtension {
248                    kind_id: *kind_id,
249                    payload: payload.clone(),
250                })
251            }
252        })
253    }
254
255    fn from_wit(wit: WitCandidateData) -> Result<Self, String> {
256        Ok(match wit {
257            WitCandidateData::File(f) => NativeCandidateData::File {
258                path: PathBuf::from(f.path),
259                is_dir: f.is_dir,
260                size: f.size,
261            },
262            WitCandidateData::Option(o) => NativeCandidateData::Option {
263                name: o.name,
264                current_value: o.current_value,
265                doc: o.doc,
266            },
267            WitCandidateData::OptionValue(o) => NativeCandidateData::OptionValue {
268                option_name: o.option_name,
269                value: o.value,
270                doc: o.doc,
271            },
272            WitCandidateData::Chord(c) => NativeCandidateData::Chord {
273                chord: c.chord,
274                mode_label: c.mode_label,
275                doc: c.doc,
276            },
277            WitCandidateData::Register(r) => NativeCandidateData::Register {
278                name: r.name,
279                preview: r.preview,
280            },
281            WitCandidateData::Mark(m) => NativeCandidateData::Mark {
282                name: m.name,
283                position: m.position,
284            },
285            WitCandidateData::Plain => NativeCandidateData::Plain,
286            WitCandidateData::Extension(e) => NativeCandidateData::Extension {
287                kind_id: e.kind_id,
288                payload: e.payload,
289            },
290        })
291    }
292}
293
294impl WitBoundary for NativeRawCandidate {
295    type Wit = WitRawCandidate;
296
297    fn to_wit(&self) -> Result<WitRawCandidate, String> {
298        Ok(WitRawCandidate {
299            text: self.text.clone(),
300            insert_text: self.insert_text.clone(),
301            display: self.display.clone(),
302            source: self.source.as_ref().map(|s| s.0.clone()),
303            kind: self.kind.to_wit()?,
304            data: self.data.to_wit()?,
305            // PH7.4a: marginalia crosses so plugin sources contribute themed
306            // columns (`Annotation` boundary in `boundary_picker`).
307            annotations: self
308                .annotations
309                .iter()
310                .map(WitBoundary::to_wit)
311                .collect::<Result<Vec<_>, String>>()?,
312            // PS.1: host→guest carries the RANGES but cannot carry the styles —
313            // a `Style` is a closed enum plus an interned element id, and the
314            // NAME it was resolved from is not recoverable from it. This
315            // direction is only exercised by the round-trip test and by a host
316            // handing a candidate back for re-ranking, so an empty slot is the
317            // honest answer rather than a fabricated name.
318            display_spans: Vec::new(),
319        })
320    }
321
322    fn from_wit(wit: WitRawCandidate) -> Result<Self, String> {
323        // PS.1: resolved before `display` is moved into the record below.
324        let spans = display_spans_from_wit(&wit.display, wit.display_spans);
325        Ok(NativeRawCandidate {
326            text: wit.text,
327            insert_text: wit.insert_text,
328            display: wit.display,
329            source: wit.source.map(SourceId),
330            kind: NativeCandidateKind::from_wit(wit.kind)?,
331            data: NativeCandidateData::from_wit(wit.data)?,
332            annotations: wit
333                .annotations
334                .into_iter()
335                .map(lattice_completion::candidate::Annotation::from_wit)
336                .collect::<Result<Vec<_>, String>>()?,
337            // `accept_action` stays host-only (§4.4) — reconstructed empty and
338            // re-derived host-side.
339            accept_action: None,
340            // PS.1: styled runs, resolved HERE because this is where the theme
341            // is in hand and where a malformed span can be dropped once rather
342            // than defended against at every paint site.
343            display_spans: spans,
344        })
345    }
346}
347
348/// PS.1: resolve a guest's styled runs against its `display` text.
349///
350/// Every span is validated and an invalid one is **dropped** — not clamped,
351/// not fatal. Dropping is the right severity for each failure mode:
352///
353/// - **Inverted or out of bounds** — the guest computed offsets against text it
354///   did not send. Clamping would paint a run it never asked for, which is a
355///   worse answer than painting none.
356/// - **Not on a UTF-8 boundary** — this is the one that makes validation
357///   non-optional rather than tidy. Slicing there panics, and a guest computing
358///   offsets in `chars` instead of bytes is an ordinary bug that must not be
359///   able to take the picker down.
360/// - **An unresolvable `slot`** — kept, and rendered unstyled. The run is where
361///   the guest said it was; only its colour is unknown, and a theme element
362///   that is not registered yet is a normal transient state, not an error.
363///
364/// The row survives all of them: a picker that shows nothing because one span
365/// was malformed is a worse failure than one that shows a plain row.
366fn display_spans_from_wit(
367    // Named `text` rather than `display`: inside `tracing::warn!` the bare name
368    // `display` resolves to `tracing::field::display`, so the parameter would
369    // shadow into a function item at every use site in the macro body.
370    text: &str,
371    spans: Vec<crate::lattice::plugin_host::types::DisplaySpan>,
372) -> Vec<lattice_completion::candidate::DisplaySpan> {
373    spans
374        .into_iter()
375        .filter_map(|s| {
376            let (start, end) = (s.start as usize, s.end as usize);
377            if start >= end || end > text.len() {
378                tracing::warn!(
379                    start,
380                    end,
381                    len = text.len(),
382                    slot = %s.slot,
383                    "picker candidate: display span out of range; dropped"
384                );
385                return None;
386            }
387            if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
388                tracing::warn!(
389                    start,
390                    end,
391                    slot = %s.slot,
392                    "picker candidate: display span is not on a UTF-8 boundary \
393                     (offsets are BYTES, not chars); dropped"
394                );
395                return None;
396            }
397            Some(lattice_completion::candidate::DisplaySpan {
398                range: start..end,
399                style: resolve_slot_style(&s.slot),
400            })
401        })
402        .collect()
403}
404
405/// PS.1: a `slot` name → `Style`, through exactly the path a `highlights.scm`
406/// capture takes.
407///
408/// The sameness IS the feature. A builtin category (`keyword`,
409/// `text.title.1`) resolves first, so a plugin cannot redefine what `keyword`
410/// means for the whole editor; any other name resolves against the live theme
411/// registry as a `Style::Element`, which is how a plugin's own registered
412/// element reaches a picker row. So a row's colour tracks the active
413/// colourscheme and matches the same construct rendered in a buffer, rather
414/// than being a second palette that drifts out of step with it.
415fn resolve_slot_style(slot: &str) -> lattice_cells::style::Style {
416    lattice_syntax::style::name_to_style_with_theme(slot, None)
417}
418
419impl WitBoundary for NativePickerAcceptOutcome {
420    type Wit = WitPickerAcceptOutcome;
421
422    fn to_wit(&self) -> Result<WitPickerAcceptOutcome, String> {
423        Ok(match self {
424            NativePickerAcceptOutcome::OpenFile { path } => {
425                WitPickerAcceptOutcome::OpenFile(path_to_wit(path)?)
426            }
427            NativePickerAcceptOutcome::SwitchBuffer { buffer_id } => {
428                WitPickerAcceptOutcome::SwitchBuffer(*buffer_id)
429            }
430            NativePickerAcceptOutcome::JumpInBuffer {
431                buffer_id,
432                line,
433                col,
434            } => WitPickerAcceptOutcome::JumpInBuffer(WitJumpTarget {
435                buffer_id: *buffer_id,
436                line: *line,
437                col: *col,
438            }),
439            NativePickerAcceptOutcome::JumpToMark { name } => {
440                WitPickerAcceptOutcome::JumpToMark(*name)
441            }
442            NativePickerAcceptOutcome::JumpToLocation { path, line, col } => {
443                WitPickerAcceptOutcome::JumpToLocation(WitLocation {
444                    path: path_to_wit(path)?,
445                    line: *line,
446                    col: *col,
447                })
448            }
449            NativePickerAcceptOutcome::InvokeCommand { id, args } => {
450                WitPickerAcceptOutcome::InvokeCommand(WitCommandRef {
451                    id: id.clone(),
452                    args: args.to_wit()?,
453                })
454            }
455            NativePickerAcceptOutcome::PasteRegister { name } => {
456                WitPickerAcceptOutcome::PasteRegister(*name)
457            }
458            NativePickerAcceptOutcome::ExpandSnippet { id } => {
459                WitPickerAcceptOutcome::ExpandSnippet(id.clone())
460            }
461            NativePickerAcceptOutcome::OpenLspLog { server_id } => {
462                WitPickerAcceptOutcome::OpenLspLog(server_id.clone())
463            }
464            NativePickerAcceptOutcome::OpenLspTraceLog { server_id } => {
465                WitPickerAcceptOutcome::OpenLspTraceLog(server_id.clone())
466            }
467            NativePickerAcceptOutcome::ApplyLspCodeAction { handle, index } => {
468                WitPickerAcceptOutcome::ApplyLspCodeAction(WitLspCodeActionRef {
469                    handle: *handle,
470                    index: *index,
471                })
472            }
473            NativePickerAcceptOutcome::ApplyLspCompletion { index } => {
474                WitPickerAcceptOutcome::ApplyLspCompletion(*index)
475            }
476            NativePickerAcceptOutcome::ApplyColorscheme { name } => {
477                WitPickerAcceptOutcome::ApplyColorscheme(name.clone())
478            }
479            // MB.3: `LoadCommandLine` seeds the `:` line — a host-internal
480            // outcome for the native `history` picker. It is not part of the
481            // plugin WIT surface (a plugin picker source has no business
482            // driving the command line), so it never crosses the boundary.
483            NativePickerAcceptOutcome::LoadCommandLine { .. }
484            | NativePickerAcceptOutcome::LoadSearchLine { .. } => {
485                return Err(
486                    "load-command/load-search are host-internal picker outcomes, not representable over WIT"
487                        .into(),
488                );
489            }
490            NativePickerAcceptOutcome::WalkPaneHistory { .. } => {
491                return Err(
492                    "walk-pane-history is a host-internal picker outcome, not representable over WIT"
493                        .into(),
494                );
495            }
496            NativePickerAcceptOutcome::OpenPrompt { .. } => {
497                return Err(
498                    "open-prompt is a host-internal picker outcome, not representable over WIT"
499                        .into(),
500                );
501            }
502            // YR.3: `fill-caller` puts text into a HOST surface — the
503            // document, the `:` line, a prompt, a parked transient
504            // argument. Which one is the `FillTarget` the host captured
505            // when the picker opened, so a plugin source emitting this
506            // would be filling something it cannot see or name.
507            NativePickerAcceptOutcome::FillCaller { .. } => {
508                return Err(
509                    "fill-caller is a host-internal picker outcome, not representable over WIT"
510                        .into(),
511                );
512            }
513            NativePickerAcceptOutcome::NoOp => WitPickerAcceptOutcome::NoOp,
514        })
515    }
516
517    fn from_wit(wit: WitPickerAcceptOutcome) -> Result<Self, String> {
518        Ok(match wit {
519            WitPickerAcceptOutcome::OpenFile(path) => NativePickerAcceptOutcome::OpenFile {
520                path: PathBuf::from(path),
521            },
522            WitPickerAcceptOutcome::SwitchBuffer(buffer_id) => {
523                NativePickerAcceptOutcome::SwitchBuffer { buffer_id }
524            }
525            WitPickerAcceptOutcome::JumpInBuffer(t) => NativePickerAcceptOutcome::JumpInBuffer {
526                buffer_id: t.buffer_id,
527                line: t.line,
528                col: t.col,
529            },
530            WitPickerAcceptOutcome::JumpToMark(name) => {
531                NativePickerAcceptOutcome::JumpToMark { name }
532            }
533            WitPickerAcceptOutcome::JumpToLocation(l) => {
534                NativePickerAcceptOutcome::JumpToLocation {
535                    path: PathBuf::from(l.path),
536                    line: l.line,
537                    col: l.col,
538                }
539            }
540            WitPickerAcceptOutcome::InvokeCommand(c) => NativePickerAcceptOutcome::InvokeCommand {
541                id: c.id,
542                args: NativeArgs::from_wit(c.args)?,
543            },
544            WitPickerAcceptOutcome::PasteRegister(name) => {
545                NativePickerAcceptOutcome::PasteRegister { name }
546            }
547            WitPickerAcceptOutcome::ExpandSnippet(id) => {
548                NativePickerAcceptOutcome::ExpandSnippet { id }
549            }
550            WitPickerAcceptOutcome::OpenLspLog(server_id) => {
551                NativePickerAcceptOutcome::OpenLspLog { server_id }
552            }
553            WitPickerAcceptOutcome::OpenLspTraceLog(server_id) => {
554                NativePickerAcceptOutcome::OpenLspTraceLog { server_id }
555            }
556            WitPickerAcceptOutcome::ApplyLspCodeAction(r) => {
557                NativePickerAcceptOutcome::ApplyLspCodeAction {
558                    handle: r.handle,
559                    index: r.index,
560                }
561            }
562            WitPickerAcceptOutcome::ApplyLspCompletion(index) => {
563                NativePickerAcceptOutcome::ApplyLspCompletion { index }
564            }
565            WitPickerAcceptOutcome::ApplyColorscheme(name) => {
566                NativePickerAcceptOutcome::ApplyColorscheme { name }
567            }
568            WitPickerAcceptOutcome::NoOp => NativePickerAcceptOutcome::NoOp,
569        })
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    #![allow(clippy::unwrap_used, clippy::panic)]
576
577    use super::*;
578
579    /// native → WIT → native is the identity for every representable value.
580    fn assert_args_round_trips(native: NativeArgs) {
581        let wit = native.to_wit().expect("to_wit");
582        let back = NativeArgs::from_wit(wit).expect("from_wit");
583        assert_eq!(native, back);
584    }
585
586    #[test]
587    fn args_round_trip_covers_every_representable_variant() {
588        assert_args_round_trips(NativeArgs::None);
589        assert_args_round_trips(NativeArgs::Char('x'));
590        assert_args_round_trips(NativeArgs::String("hello".into()));
591        assert_args_round_trips(NativeArgs::Bytes(vec![0, 1, 2, 255]));
592        assert_args_round_trips(NativeArgs::List(vec![
593            NativeArgValue::String("s".into()),
594            NativeArgValue::Char('c'),
595            NativeArgValue::Bool(true),
596            NativeArgValue::Int(-42),
597            NativeArgValue::Pattern("re".into()),
598            NativeArgValue::Chord("<C-c>".into()),
599            NativeArgValue::Raw("body".into()),
600        ]));
601    }
602
603    #[test]
604    fn arg_value_int_survives_full_i64_range() {
605        for i in [i64::MIN, -1, 0, 1, i64::MAX] {
606            let v = NativeArgValue::Int(i);
607            let back = NativeArgValue::from_wit(v.to_wit().unwrap()).unwrap();
608            assert_eq!(v, back);
609        }
610    }
611
612    #[test]
613    fn nested_invocation_is_a_typed_error_not_a_panic() {
614        use lattice_grammar::CommandId;
615        use lattice_grammar::command::CommandInvocation;
616        // A nested invocation cannot cross until the command mirror lands; it
617        // must surface as a typed Err, and a containing Args must propagate it.
618        let invocation = CommandInvocation::of(CommandId::new(0));
619        let native = NativeArgs::List(vec![NativeArgValue::Invocation(Box::new(invocation))]);
620        let err = native
621            .to_wit()
622            .expect_err("nested invocation must not cross yet");
623        assert!(err.contains("Invocation"), "error names the culprit: {err}");
624    }
625
626    // ---- RawCandidate / CandidateData / CandidateKind ----
627
628    fn raw(kind: NativeCandidateKind, data: NativeCandidateData) -> NativeRawCandidate {
629        NativeRawCandidate {
630            insert_text: None,
631            text: "text".into(),
632            display: "display".into(),
633            source: Some(SourceId("gen:files".into())),
634            kind,
635            data,
636            // Non-crossable render-time fields — left at their reconstructed
637            // defaults so the round-trip is an equality.
638            accept_action: None,
639            annotations: Vec::new(),
640            display_spans: Vec::new(),
641        }
642    }
643
644    fn assert_candidate_round_trips(native: NativeRawCandidate) {
645        let wit = native.to_wit().expect("to_wit");
646        let back = NativeRawCandidate::from_wit(wit).expect("from_wit");
647        assert_eq!(native, back);
648    }
649
650    #[test]
651    fn raw_candidate_round_trips_across_data_and_kind_variants() {
652        assert_candidate_round_trips(raw(NativeCandidateKind::Plain, NativeCandidateData::Plain));
653        assert_candidate_round_trips(raw(
654            NativeCandidateKind::File,
655            NativeCandidateData::File {
656                path: PathBuf::from("/home/alice/x.rs"),
657                is_dir: false,
658                size: Some(4096),
659            },
660        ));
661        assert_candidate_round_trips(raw(
662            NativeCandidateKind::Option,
663            NativeCandidateData::Option {
664                name: "wrap".into(),
665                current_value: "on".into(),
666                doc: "soft wrap".into(),
667            },
668        ));
669        assert_candidate_round_trips(raw(
670            NativeCandidateKind::Register,
671            NativeCandidateData::Register {
672                name: 'a',
673                preview: "yanked".into(),
674            },
675        ));
676        assert_candidate_round_trips(raw(
677            NativeCandidateKind::Extension(7),
678            NativeCandidateData::Extension {
679                kind_id: 7,
680                payload: vec![1, 2, 3],
681            },
682        ));
683    }
684
685    #[test]
686    fn candidate_data_command_is_a_typed_error() {
687        use lattice_grammar::source::{SourceKind, SourceLayer, SourceLocation};
688        let data = NativeCandidateData::Command {
689            name: "quit".into(),
690            doc: "quit".into(),
691            kind_label: "ex-command".into(),
692            source: SourceLocation {
693                layer: SourceLayer::Builtin,
694                kind: SourceKind::Synthetic("<builtin>".into()),
695            },
696        };
697        let err = data.to_wit().expect_err("Command must not cross yet");
698        assert!(err.contains("Command"), "error names the culprit: {err}");
699    }
700
701    // ---- PickerAcceptOutcome ----
702
703    fn assert_outcome_round_trips(native: NativePickerAcceptOutcome) {
704        let wit = native.to_wit().expect("to_wit");
705        let back = NativePickerAcceptOutcome::from_wit(wit).expect("from_wit");
706        // PickerAcceptOutcome is not `PartialEq`; compare structural Debug.
707        assert_eq!(format!("{native:?}"), format!("{back:?}"));
708    }
709
710    #[test]
711    fn picker_accept_outcome_round_trips_every_variant() {
712        for outcome in [
713            NativePickerAcceptOutcome::OpenFile {
714                path: PathBuf::from("/a/b.rs"),
715            },
716            NativePickerAcceptOutcome::SwitchBuffer { buffer_id: 3 },
717            NativePickerAcceptOutcome::JumpInBuffer {
718                buffer_id: 3,
719                line: 10,
720                col: 4,
721            },
722            NativePickerAcceptOutcome::JumpToMark { name: 'a' },
723            NativePickerAcceptOutcome::JumpToLocation {
724                path: PathBuf::from("/a/b.rs"),
725                line: 1,
726                col: 0,
727            },
728            NativePickerAcceptOutcome::InvokeCommand {
729                id: "write".into(),
730                args: NativeArgs::List(vec![NativeArgValue::Bool(true)]),
731            },
732            NativePickerAcceptOutcome::PasteRegister { name: '+' },
733            NativePickerAcceptOutcome::ExpandSnippet { id: "fn".into() },
734            NativePickerAcceptOutcome::OpenLspLog {
735                server_id: "rust-analyzer".into(),
736            },
737            NativePickerAcceptOutcome::OpenLspTraceLog {
738                server_id: "rust-analyzer".into(),
739            },
740            NativePickerAcceptOutcome::ApplyLspCodeAction {
741                handle: 42,
742                index: 1,
743            },
744            NativePickerAcceptOutcome::ApplyLspCompletion { index: 2 },
745            NativePickerAcceptOutcome::ApplyColorscheme {
746                name: "nord".into(),
747            },
748            NativePickerAcceptOutcome::NoOp,
749        ] {
750            assert_outcome_round_trips(outcome);
751        }
752    }
753
754    #[cfg(unix)]
755    #[test]
756    fn non_utf8_path_is_a_typed_error_not_a_lossy_cross() {
757        use std::os::unix::ffi::OsStrExt;
758        let bad = PathBuf::from(std::ffi::OsStr::from_bytes(b"/inv\xff/path"));
759        let outcome = NativePickerAcceptOutcome::OpenFile { path: bad };
760        let err = outcome.to_wit().expect_err("non-UTF-8 path must not cross");
761        assert!(err.contains("UTF-8"), "error explains why: {err}");
762    }
763
764    // ---- PS.1: guest-supplied display spans ----
765
766    use crate::lattice::plugin_host::types::DisplaySpan as WitDisplaySpan;
767
768    fn wit_span(start: u32, end: u32, slot: &str) -> WitDisplaySpan {
769        WitDisplaySpan {
770            start,
771            end,
772            slot: slot.to_string(),
773        }
774    }
775
776    fn candidate_with_spans(display: &str, spans: Vec<WitDisplaySpan>) -> NativeRawCandidate {
777        let wit = WitRawCandidate {
778            text: display.to_string(),
779            insert_text: None,
780            display: display.to_string(),
781            source: None,
782            kind: WitCandidateKind::Plain,
783            data: WitCandidateData::Plain,
784            annotations: Vec::new(),
785            display_spans: spans,
786        };
787        NativeRawCandidate::from_wit(wit).expect("from_wit")
788    }
789
790    /// PS.1: a guest's spans cross, and a `slot` resolves through the SAME
791    /// path a `highlights.scm` capture takes — so an org-roam row is coloured
792    /// by the theme's headline style rather than by a second palette.
793    #[test]
794    fn guest_display_spans_cross_and_resolve_their_slot() {
795        let c = candidate_with_spans(
796            "Reading list  (books)",
797            vec![wit_span(0, 12, "text.title.1"), wit_span(12, 21, "comment")],
798        );
799        assert_eq!(c.display_spans.len(), 2, "got {:?}", c.display_spans);
800        assert_eq!(c.display_spans[0].range, 0..12);
801        assert_eq!(
802            c.display_spans[0].style,
803            lattice_syntax::style::name_to_style_pub("text.title.1"),
804            "the slot must resolve exactly as the capture name does"
805        );
806        assert_eq!(
807            c.display_spans[1].style,
808            lattice_syntax::style::name_to_style_pub("comment")
809        );
810    }
811
812    /// **A span that is not on a UTF-8 boundary is dropped, not clamped.**
813    ///
814    /// This is the assertion that makes the validation non-optional rather
815    /// than tidy: slicing mid-codepoint panics, and a guest computing offsets
816    /// in `chars` instead of bytes is an ordinary bug that must not be able to
817    /// take the picker down. `Café` is 5 bytes; a char-counting guest sends 4,
818    /// which lands inside the `é`.
819    #[test]
820    fn a_span_off_a_utf8_boundary_is_dropped() {
821        let c = candidate_with_spans("Café notes", vec![wit_span(0, 4, "text.title.1")]);
822        assert!(
823            c.display_spans.is_empty(),
824            "a mid-codepoint span must be dropped, got {:?}",
825            c.display_spans
826        );
827        // The correct byte offset for the same text survives, so the rule is
828        // "bad spans go" and not "non-ASCII goes".
829        let ok = candidate_with_spans("Café notes", vec![wit_span(0, 5, "text.title.1")]);
830        assert_eq!(ok.display_spans.len(), 1);
831    }
832
833    /// Out of range and inverted spans are dropped, and the row keeps its
834    /// other runs — one malformed span must not cost a row its styling, and
835    /// must never be clamped into a run the guest did not ask for.
836    #[test]
837    fn malformed_spans_are_dropped_without_taking_the_row_with_them() {
838        let c = candidate_with_spans(
839            "abcdef",
840            vec![
841                wit_span(0, 3, "keyword"),  // fine
842                wit_span(4, 99, "comment"), // past the end
843                wit_span(5, 2, "comment"),  // inverted
844                wit_span(3, 3, "comment"),  // empty
845            ],
846        );
847        assert_eq!(c.display_spans.len(), 1, "got {:?}", c.display_spans);
848        assert_eq!(c.display_spans[0].range, 0..3);
849    }
850
851    /// An unresolvable slot KEEPS the run and renders it unstyled. The run is
852    /// where the guest said it was; only its colour is unknown, and a theme
853    /// element that is not registered yet is a normal transient state.
854    #[test]
855    fn an_unknown_slot_keeps_the_run_unstyled() {
856        let c = candidate_with_spans("abcdef", vec![wit_span(0, 3, "not.a.real.capture")]);
857        assert_eq!(c.display_spans.len(), 1);
858        assert_eq!(
859            c.display_spans[0].style,
860            lattice_cells::style::Style::Default
861        );
862    }
863
864    /// A source that styles nothing is byte-identical to the pre-PS.1
865    /// behaviour — the overwhelmingly common case must not have changed.
866    #[test]
867    fn a_candidate_with_no_spans_is_unchanged() {
868        let c = candidate_with_spans("plain row", Vec::new());
869        assert!(c.display_spans.is_empty());
870    }
871}