Skip to main content

lattice_completion/
candidate.rs

1#![allow(clippy::single_range_in_vec_init)]
2//! Candidate value types that flow through the completion pipeline
3//! (DESIGN.md §5.11.3).
4//!
5//! Three shapes, one per pipeline stage:
6//!
7//! - [`RawCandidate`] -- output of a generator. Insertion text + kind +
8//!   structured payload.
9//! - [`ScoredCandidate`] -- after the matcher: `RawCandidate` + score +
10//!   the byte ranges the matcher considered "matched" (vertico's
11//!   match-face highlights these).
12//! - [`RenderedCandidate`] -- after annotators: above + a vec of
13//!   typed [`Annotation`]s the renderer paints to the right of each
14//!   candidate (MARG.1, see `docs/dev/architecture/marginalia.md`).
15//!
16//! Three shapes (not one with optional fields) so the type system
17//! enforces stage ordering: a generator produces only `RawCandidate`s;
18//! a matcher produces only `ScoredCandidate`s; annotators only mutate
19//! `RenderedCandidate`s. Pipeline order can't accidentally invert.
20
21use std::borrow::Cow;
22use std::ops::Range;
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use lattice_grammar::source::SourceLocation;
27use lattice_protocol::KeyChord;
28
29/// What kind of thing this candidate is. Drives icon / colour /
30/// grouping in the popup, and (for CommandKind) hints at the
31/// follow-up `:describe-*` target.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
33pub enum CandidateKind {
34    Command,
35    Option,
36    File,
37    Directory,
38    Pattern,
39    Buffer,
40    Register,
41    Mark,
42    Chord,
43    Plain,
44    /// Plugin-defined kind. The numeric tag is registered alongside
45    /// the plugin's generator so `:describe-completion-source` can
46    /// resolve it back to a name.
47    Extension(u32),
48}
49
50impl CandidateKind {
51    /// Issue #35 (2026-05-22): one-char ASCII glyph for picker
52    /// marginalia. The renderer paints this in the left
53    /// margin of each picker row so the user can scan the
54    /// candidate list by kind. ASCII fallback chosen so it
55    /// works even when nerd-fonts are off; the icon system
56    /// (Phase 5.6.7) may layer a richer sprite on top later.
57    pub fn glyph(&self) -> char {
58        match self {
59            CandidateKind::Command => ':',
60            CandidateKind::Option => '=',
61            CandidateKind::File => 'f',
62            CandidateKind::Directory => 'd',
63            CandidateKind::Pattern => '/',
64            CandidateKind::Buffer => 'b',
65            CandidateKind::Register => '"',
66            CandidateKind::Mark => '\'',
67            CandidateKind::Chord => '@',
68            CandidateKind::Plain => '·',
69            CandidateKind::Extension(_) => '+',
70        }
71    }
72}
73
74/// Generator-supplied metadata travelling alongside the candidate.
75/// Annotators read this to produce display text without re-querying
76/// the underlying registry. Plugin generators stash their own
77/// payload in `Extension` (msgpack on the wire when WASM lands).
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub enum CandidateData {
80    /// `gen:commands` payload: command's full metadata at the time
81    /// of generation.
82    Command {
83        name: String,
84        doc: String,
85        kind_label: String,
86        source: SourceLocation,
87    },
88    /// `gen:files` payload.
89    File {
90        path: PathBuf,
91        is_dir: bool,
92        size: Option<u64>,
93    },
94    /// `gen:options` payload (typed options post-§5.12).
95    /// Emitted for option-name completion (`:set <Tab>`). `doc`
96    /// is `OptionDecl::DOC`.
97    Option {
98        name: String,
99        current_value: String,
100        doc: String,
101    },
102    /// `gen:options` payload — value-completion mode.
103    /// Slice `3c.unify.option-doc-annotator`. Emitted for
104    /// `:set foo=<Tab>` when the option's `OptionType::enumerate`
105    /// returns Some. `doc` is `EnumeratedValue::doc` — per-value
106    /// help text the type chose to surface (empty when the type
107    /// hasn't overridden `enumerate_with_docs`).
108    OptionValue {
109        option_name: String,
110        value: String,
111        doc: String,
112    },
113    /// `gen:chords` payload.
114    Chord {
115        chord: String,
116        mode_label: String,
117        doc: String,
118    },
119    /// `gen:registers` payload.
120    Register { name: char, preview: String },
121    /// `gen:marks` payload.
122    Mark { name: char, position: String },
123    /// Generator that needs no extra metadata (text alone is enough).
124    Plain,
125    /// Plugin-defined arbitrary payload. The pipeline preserves it
126    /// unchanged through matcher / ranker; annotators registered by
127    /// the same plugin recognise the `kind_id` and decode `payload`.
128    Extension { kind_id: u32, payload: Vec<u8> },
129}
130
131/// Output of a generator. The pipeline passes these through the
132/// matcher next.
133#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
134pub struct RawCandidate {
135    /// Text the matcher scores the query against, and — unless
136    /// [`insert_text`](Self::insert_text) overrides it — the text
137    /// inserted when the user accepts.
138    pub text: String,
139    /// OR.7: what to insert on accept, when that differs from what
140    /// the user matched against. `None` ⇒ insert `text`.
141    ///
142    /// Two sources already needed this and each grew its own escape
143    /// hatch: LSP carries `insert_text` inside its extension payload
144    /// and `do_completion_accept` special-cases it, snippets do the
145    /// same with a body. A third case — org-roam completing a node
146    /// *title* but inserting an `[[id:…][…]]` link — made the pattern
147    /// worth having generically, because a plugin source cannot reach
148    /// either existing hatch: both are keyed to a host-owned
149    /// `kind_id`.
150    ///
151    /// Folding text and insertion together is what forced those hatches
152    /// in the first place. A candidate matched on `text` and inserted
153    /// verbatim is the common case, not the only one.
154    #[serde(default)]
155    pub insert_text: Option<String>,
156    /// What appears in the popup before annotators run. May be
157    /// `text` verbatim or a richer form (e.g. `"~/Documents"` for a
158    /// `File` whose `text` is the absolute path).
159    pub display: String,
160    pub kind: CandidateKind,
161    pub data: CandidateData,
162    /// Source that produced this candidate. `None` for legacy /
163    /// non-insert callers (cmdline pickers, plain test
164    /// fixtures); insert-mode generators tag themselves so the
165    /// per-source-priority ranker (Phase 4.2.g.5) can resolve
166    /// the right priority bucket. The string is the same id
167    /// surfaced in `:set completion.source.<id>.priority=…` and
168    /// in `:help completion-sources`.
169    #[serde(default)]
170    pub source: Option<crate::insert::SourceId>,
171    /// What the host should do if the user accepts this
172    /// candidate. Slice `3c.unify.picker-generator-trait-unify`
173    /// (7b.0): adds a typed accept payload to the candidate so
174    /// `AcceptHandler` impls can be stateless — the default
175    /// handler (`source_registration::DefaultAcceptHandler`)
176    /// just clones this field. `None` ⇒ default behaviour:
177    /// cmdline replaces `cmdline[replace_start..]` with `text`;
178    /// picker echoes "no accept_action set" via the default
179    /// handler. Surfaces that need typed dispatch set this at
180    /// candidate-build time.
181    ///
182    /// Boxed (slice 8): `AcceptAction` is a tagged enum
183    /// carrying PathBuf / String / Args among its variants —
184    /// its inline size dominates RawCandidate. Boxing keeps
185    /// `Option<Box<AcceptAction>>` at 8 bytes (None = null
186    /// pointer) so cmdline-completion + insert-completion
187    /// candidates (which leave it unset) don't pay the cost.
188    /// Picker candidates pay one heap alloc per row at
189    /// construction, recovered ~10× over by smaller per-
190    /// candidate memcpy during pipeline matcher/ranker passes
191    /// (picker::refilter/n=5000 measured 2× faster than the
192    /// inline shape — see benchmarks.md).
193    ///
194    /// `#[serde(skip)]` because the `Custom` variant carries
195    /// `Arc<dyn Any>` which isn't serializable; the rest of the
196    /// candidate (text + display + data) round-trips through
197    /// the cache, but the accept action is recomputed at the
198    /// callsite when needed.
199    #[serde(skip)]
200    pub accept_action: Option<Box<crate::source_registration::AcceptAction>>,
201    /// MARG §8: source-supplied marginalia. A picker source that
202    /// knows its rows' metadata (the file/dir picker's perms / size /
203    /// mtime) attaches typed [`Annotation`]s here at build time; the
204    /// pipeline copies them onto the [`RenderedCandidate`] so the
205    /// renderer color-codes them. Empty for sources that contribute no
206    /// marginalia (the common case). `#[serde(skip)]`: annotations are
207    /// render-time data resolved against the live theme, never cached.
208    #[serde(skip)]
209    pub annotations: Vec<Annotation>,
210    /// PH.1: optional syntax-highlight overlay for the `display`
211    /// run (the matchable text itself, NOT a trailing column —
212    /// that's `annotations`). Each span carries a *semantic*
213    /// [`Style`](lattice_cells::style::Style), resolved to a
214    /// theme color only at the render seam (`resolve_syntax_style`),
215    /// so `:colorscheme` recolors picker previews live. Byte
216    /// offsets into `display`. Empty ⇒ today's plain single-color
217    /// preview. Producer contract (PH.2): spans are sorted by
218    /// `range.start`, non-overlapping, and aligned to char
219    /// boundaries; the renderer composes them under
220    /// `match_ranges` (fuzzy-match highlight wins on overlap).
221    /// `#[serde(skip)]` to match `annotations` — render-time
222    /// overlay, not cached.
223    /// See `docs/dev/architecture/picker-preview-highlight.md`.
224    #[serde(skip)]
225    pub display_spans: Vec<DisplaySpan>,
226}
227
228/// PH.1: a syntax-styled run within a candidate's `display`
229/// text. Carries a semantic [`Style`](lattice_cells::style::Style)
230/// (a closed enum with an existing `Style → ElementId` map),
231/// NOT a resolved color — the renderer resolves it via
232/// `resolve_syntax_style` at paint so `:colorscheme` recolors
233/// live, mirroring the `Annotation::Styled` carry-semantic /
234/// resolve-at-seam invariant (marginalia §3 / §8.2). `range`
235/// is a byte range into `display`.
236///
237/// Semantic `Style` (not a slot-key `Arc<str>` like
238/// `AnnotationSegment`) because syntax styles are a *closed*
239/// set with direct typed resolution, whereas annotation slots
240/// are *open* (plugins name arbitrary slots). See
241/// `docs/dev/architecture/picker-preview-highlight.md` §4.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct DisplaySpan {
244    pub range: Range<usize>,
245    pub style: lattice_cells::style::Style,
246}
247
248impl RawCandidate {
249    /// Convenience for plain text candidates with no metadata.
250    /// Leaves `source` + `accept_action` unset; insert-mode
251    /// producers chain [`Self::with_source`] to tag themselves;
252    /// picker generators set `accept_action` per row.
253    pub fn plain(text: impl Into<String>, kind: CandidateKind) -> Self {
254        let text = text.into();
255        Self {
256            display: text.clone(),
257            text,
258            insert_text: None,
259            kind,
260            data: CandidateData::Plain,
261            source: None,
262            accept_action: None,
263            annotations: Vec::new(),
264            display_spans: Vec::new(),
265        }
266    }
267
268    /// Tag the candidate with its producing source. Used by
269    /// insert-mode generators so the ranker can apply per-source
270    /// priority (Phase 4.2.g.5 (2/3)). Picker generators leave
271    /// the field unset -- their pipeline doesn't apply
272    /// per-source priority.
273    pub fn with_source(mut self, source: crate::insert::SourceId) -> Self {
274        self.source = Some(source);
275        self
276    }
277}
278
279/// Score the matcher assigned. Higher is better; `0` means
280/// "doesn't match" (and the candidate is filtered out before it
281/// becomes a `ScoredCandidate`).
282#[derive(
283    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
284)]
285pub struct MatchScore(pub u32);
286
287impl MatchScore {
288    pub const PERFECT: MatchScore = MatchScore(1000);
289    pub const PREFIX: MatchScore = MatchScore(900);
290    pub const FUZZY_HIGH: MatchScore = MatchScore(700);
291    pub const SUBSTRING: MatchScore = MatchScore(500);
292    pub const FUZZY_LOW: MatchScore = MatchScore(200);
293
294    pub fn get(self) -> u32 {
295        self.0
296    }
297}
298
299/// A `RawCandidate` plus the matcher's verdict. Survives the
300/// match stage; consumed by the ranker and (in turn) the annotators.
301#[derive(Debug, Clone)]
302pub struct ScoredCandidate {
303    pub raw: RawCandidate,
304    pub score: MatchScore,
305    /// Byte ranges in `raw.text` that the matcher consumed. Empty
306    /// vec for matchers that don't track ranges (e.g. coarse fuzzy);
307    /// renderers tolerate either case.
308    pub match_ranges: Vec<Range<usize>>,
309}
310
311/// One annotation attached to a completion candidate.
312///
313/// MARG.1 (2026-06-03): replaces the previous untyped
314/// `annotations: Vec<String>` with a tagged enum so the
315/// renderer can color-code each annotation by category. The
316/// payload preserves semantic info (e.g. `Keybinding` keeps
317/// the chord list for future affordances like "show conflicts"
318/// or "click to edit binding"); display-time formatting is the
319/// renderer's job via [`Annotation::display_text`].
320///
321/// Variants are open-by-versioning: adding a variant is a
322/// minor-version bump; removing one is breaking. The `Custom`
323/// variant is the escape hatch for in-tree extension crates
324/// (and future WASM plugins) that don't fit any built-in
325/// variant — payload includes a `slot` string the renderer
326/// resolves against the theme.
327///
328/// `Severity` variant is intentionally omitted in MARG.1 —
329/// no consumer yet (diagnostic-suggestion candidates land in
330/// a later slice). Adding it when needed is non-breaking.
331///
332/// See `docs/dev/architecture/marginalia.md` for the data-model
333/// rationale and the rejected `String + style` and pre-styled-
334/// spans alternatives.
335#[derive(Debug, Clone, PartialEq)]
336pub enum Annotation {
337    /// Category icon like `→` (motion), `:` (ex-command), `f` (file).
338    /// Emitted by `KindLabelAnnotator`. Renderer styles with
339    /// the kind-annotation slot.
340    Kind(Arc<str>),
341
342    /// First line of a command's doc string. Emitted by
343    /// `DocSnippetAnnotator`. Renderer styles with the doc
344    /// annotation slot.
345    DocSnippet(Arc<str>),
346
347    /// Chord(s) bound to this candidate's command. Emitted by
348    /// the keybinding annotator (MARG.2). The renderer formats
349    /// chords via [`KeyChord`]'s `Display` impl and styles with
350    /// the keybinding annotation slot. Empty vec is invalid —
351    /// annotators should not emit this variant when no chord
352    /// binds. Most candidates have 0-1 chords; the rare
353    /// multi-binding case uses `Vec` rather than `SmallVec` to
354    /// avoid an extra crate dep pre-v1 — perf-driven storage
355    /// swap deferred until a bench shows it matters.
356    Keybinding(Vec<KeyChord>),
357
358    /// Provenance: which crate / mode / user-config defined
359    /// this command. `Arc<str>` because most candidates share
360    /// the same source label (`"builtin"`, `"lsp"`,
361    /// `"user-init"`); copy-by-reference is cheaper than
362    /// cloning the string per-candidate.
363    Source(Arc<str>),
364
365    /// Escape hatch for plugin-contributed annotations that
366    /// don't fit any built-in variant. The annotator
367    /// pre-formats `text`; `slot` names a theme slot the
368    /// renderer resolves (unknown slot falls back to the
369    /// plugin-annotation default).
370    Custom { text: Arc<str>, slot: Arc<str> },
371
372    /// MARG §8: a single column cell whose text is colored
373    /// **per segment**. Generalizes `Custom` to N slots — used
374    /// for the file-permission string (`drwxr-xr-x`, one segment
375    /// per bit class) and any future multi-colored field
376    /// (size+unit, path head/tail, git status). Each segment
377    /// carries a slot KEY, never a resolved color, so theme
378    /// resolution stays at the render seam. `category` keys the
379    /// column exactly like the single-variant categories.
380    Styled {
381        category: Arc<str>,
382        segments: Vec<AnnotationSegment>,
383    },
384}
385
386/// MARG §8: one run of marginalia text sharing a theme slot,
387/// the unit of an [`Annotation::Styled`] cell. `slot` is a theme
388/// element name the renderer resolves at paint time (unknown slot
389/// → the custom/plugin annotation color); it is NEVER a baked
390/// color, so a `:colorscheme` swap recolors it live on both peers.
391#[derive(Debug, Clone, PartialEq)]
392pub struct AnnotationSegment {
393    pub text: Arc<str>,
394    pub slot: Arc<str>,
395}
396
397impl Annotation {
398    /// Borrow-or-format the annotation's text for paint. String-
399    /// payload variants return a borrowed `Cow`; structured
400    /// variants (`Keybinding`) format on demand.
401    pub fn display_text(&self) -> Cow<'_, str> {
402        match self {
403            Self::Kind(s) | Self::DocSnippet(s) | Self::Source(s) => Cow::Borrowed(s.as_ref()),
404            Self::Custom { text, .. } => Cow::Borrowed(text.as_ref()),
405            Self::Keybinding(chords) => {
406                if chords.is_empty() {
407                    Cow::Borrowed("")
408                } else {
409                    let mut buf = String::with_capacity(chords.len() * 4);
410                    for (i, c) in chords.iter().enumerate() {
411                        if i > 0 {
412                            buf.push(' ');
413                        }
414                        use std::fmt::Write;
415                        let _ = write!(&mut buf, "{c}");
416                    }
417                    Cow::Owned(buf)
418                }
419            }
420            // §8: the cell's text is its segments concatenated. A
421            // single segment borrows; multi-segment owns. `AnnotationColumns`
422            // width math consumes this, so a styled cell occupies the same
423            // column width as the equivalent flat string.
424            Self::Styled { segments, .. } => match segments.as_slice() {
425                [] => Cow::Borrowed(""),
426                [one] => Cow::Borrowed(one.text.as_ref()),
427                many => Cow::Owned(many.iter().map(|s| s.text.as_ref()).collect()),
428            },
429        }
430    }
431
432    /// Stable category key the renderer pattern-matches on to
433    /// pick a theme slot. Variant names mirror the theme slot
434    /// suffix (`annotation_kind`, `annotation_doc`, ...).
435    /// `Custom` returns its `slot` field; unknown slots fall
436    /// back to `annotation_plugin` at paint time.
437    pub fn category(&self) -> &str {
438        match self {
439            Self::Kind(_) => "kind",
440            Self::DocSnippet(_) => "doc",
441            Self::Keybinding(_) => "keybinding",
442            Self::Source(_) => "source",
443            Self::Custom { slot, .. } => slot.as_ref(),
444            Self::Styled { category, .. } => category.as_ref(),
445        }
446    }
447}
448
449/// MARG.5 (2026-06-03): pre-computed per-category column
450/// layout for the picker / completion annotation column.
451/// The renderer builds one of these from the visible
452/// candidate set, then renders each row against it — every
453/// row's annotation cells line up vertically because each
454/// column width is the max across visible candidates and
455/// rows that don't have a particular category render a
456/// blank cell of the same width.
457///
458/// Why this lives in `lattice-completion` rather than the
459/// renderer crates: both peer renderers (TUI + GPUI) need
460/// identical column-width math; centralising avoids two
461/// implementations drifting apart. The layout is data, not
462/// paint — peers consume it differently (ratatui spans vs.
463/// GPUI element-tree), but the column widths are universal.
464///
465/// Display order is variant-fixed via `category_order`:
466/// keybinding -> source -> kind -> doc -> custom (custom
467/// slots come last, grouped at the end). Source sits right
468/// after keybinding so the user sees which mode contributes
469/// the chord at a glance.
470#[derive(Debug, Clone, Default)]
471pub struct AnnotationColumns {
472    /// (category_key, max display width in `chars`)
473    /// ordered by display order.
474    cols: Vec<(String, usize)>,
475}
476
477impl AnnotationColumns {
478    /// Build the column layout from a borrowed iterator over
479    /// the visible candidates. `chars().count()` is used for
480    /// width — matches what `display_text()` yields and what
481    /// monospace terminals draw. (Combining-char / wide-glyph
482    /// edge cases are not handled here for parity with the
483    /// existing `display_col_chars` calculation in the picker
484    /// caller; a future Unicode-width pass would land in both
485    /// sites at once.)
486    pub fn from_visible<'a, I>(candidates: I) -> Self
487    where
488        I: IntoIterator<Item = &'a RenderedCandidate>,
489    {
490        let mut widths: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
491        for c in candidates {
492            for a in &c.annotations {
493                let cat = a.category().to_string();
494                let w = a.display_text().chars().count();
495                let e = widths.entry(cat).or_insert(0);
496                *e = (*e).max(w);
497            }
498        }
499        let mut cols: Vec<(String, usize)> = widths.into_iter().collect();
500        cols.sort_by(|(a, _), (b, _)| {
501            category_order(a)
502                .cmp(&category_order(b))
503                .then_with(|| a.cmp(b))
504        });
505        Self { cols }
506    }
507
508    /// Iterate `(category_key, column_width)` pairs in
509    /// display order. Renderers walk this once per row; for
510    /// each column they either render the candidate's
511    /// matching annotation (padded to `column_width`) or a
512    /// blank cell of `column_width` spaces.
513    pub fn iter(&self) -> impl Iterator<Item = (&str, usize)> {
514        self.cols.iter().map(|(k, w)| (k.as_str(), *w))
515    }
516
517    /// True when no visible candidate carries any annotation.
518    /// Renderers skip the annotation-column rendering
519    /// entirely (including the leading pad-to-display_col
520    /// spacing) when this is true.
521    pub fn is_empty(&self) -> bool {
522        self.cols.is_empty()
523    }
524}
525
526/// Display-order rank for an annotation `category()` key.
527/// Lower values render leftmost. Keybinding leads because
528/// the user's eye is on the command name and chord
529/// proximity is the high-value scan affordance (per the
530/// MARG.2 placement-fix discussion). Unknown categories
531/// (typically `Custom` `slot` strings) get the rank reserved
532/// for plugin-supplied annotations.
533fn category_order(category: &str) -> u8 {
534    match category {
535        "keybinding" => 0,
536        "source" => 1,
537        "kind" => 2,
538        "doc" => 3,
539        // MARG §8: file-metadata columns render in `ls`-style order
540        // (permissions → size → mtime), to the right of any command
541        // columns. Explicit ranks because the default tie-break is
542        // alphabetical, which would mis-order them (mtime < perm < size).
543        "perm" => 5,
544        "size" => 6,
545        "mtime" => 7,
546        // MARG §9: picker rollout columns, to the right of file metadata.
547        "location" => 8,
548        "status" => 9,
549        "latency" => 10,
550        "args" => 11,
551        "buffer-id" => 12,
552        "register" => 13,
553        _ => 4,
554    }
555}
556
557/// What the renderer paints. Annotators append to `annotations`;
558/// the renderer paints each typed [`Annotation`] with the style
559/// that category resolves to, joined by two spaces of row-styled
560/// padding. MARG.1 (2026-06-03): replaced `Vec<String>` with
561/// `Vec<Annotation>` so annotation category survives into the
562/// paint path.
563#[derive(Debug, Clone)]
564pub struct RenderedCandidate {
565    pub raw: RawCandidate,
566    pub score: MatchScore,
567    pub match_ranges: Vec<Range<usize>>,
568    pub annotations: Vec<Annotation>,
569}
570
571impl RenderedCandidate {
572    pub fn from_scored(s: ScoredCandidate) -> Self {
573        // MARG §8: carry any source-supplied marginalia through to the
574        // rendered candidate. Annotators (when present) append more on
575        // top; the picker runs no annotators, so for it this IS the
576        // annotation source.
577        let annotations = s.raw.annotations.clone();
578        Self {
579            raw: s.raw,
580            score: s.score,
581            match_ranges: s.match_ranges,
582            annotations,
583        }
584    }
585}
586
587/// Cache key for [`crate::CandidateGenerator::cache_key`]. Treated as
588/// opaque by the caching layer; semantic meaning is per-generator.
589#[derive(Debug, Clone, PartialEq, Eq, Hash)]
590pub struct CacheKey(pub String);
591
592impl CacheKey {
593    pub fn new(s: impl Into<String>) -> Self {
594        Self(s.into())
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    #![allow(clippy::unwrap_used, clippy::panic)]
601    use super::*;
602
603    #[test]
604    fn plain_candidate_uses_text_as_display() {
605        let c = RawCandidate::plain("hello", CandidateKind::Plain);
606        assert_eq!(c.text, "hello");
607        assert_eq!(c.display, "hello");
608        assert!(matches!(c.data, CandidateData::Plain));
609    }
610
611    #[test]
612    fn match_score_constants_are_ordered() {
613        assert!(MatchScore::PERFECT > MatchScore::PREFIX);
614        assert!(MatchScore::PREFIX > MatchScore::FUZZY_HIGH);
615        assert!(MatchScore::FUZZY_HIGH > MatchScore::SUBSTRING);
616        assert!(MatchScore::SUBSTRING > MatchScore::FUZZY_LOW);
617    }
618
619    #[test]
620    fn from_scored_initialises_empty_annotations() {
621        let scored = ScoredCandidate {
622            raw: RawCandidate::plain("x", CandidateKind::Plain),
623            score: MatchScore::PERFECT,
624            match_ranges: vec![0..1],
625        };
626        let r = RenderedCandidate::from_scored(scored);
627        assert!(r.annotations.is_empty());
628        assert_eq!(r.match_ranges, vec![0..1]);
629    }
630
631    #[test]
632    fn cache_key_round_trips() {
633        let k = CacheKey::new("commands:v1");
634        assert_eq!(k.0, "commands:v1");
635    }
636
637    /// Build a `RenderedCandidate` carrying the given annotations
638    /// for `AnnotationColumns` layout tests.
639    fn candidate_with(annotations: Vec<Annotation>) -> RenderedCandidate {
640        let scored = ScoredCandidate {
641            raw: RawCandidate::plain("cmd", CandidateKind::Plain),
642            score: MatchScore::PERFECT,
643            match_ranges: vec![],
644        };
645        let mut c = RenderedCandidate::from_scored(scored);
646        c.annotations = annotations;
647        c
648    }
649
650    #[test]
651    fn columns_width_is_max_across_visible() {
652        // Two candidates, same category, different widths — the
653        // column width is the max so every row's cell lines up.
654        let cands = [
655            candidate_with(vec![Annotation::Kind("→".into())]),
656            candidate_with(vec![Annotation::Kind(":".into())]),
657        ];
658        let cols = AnnotationColumns::from_visible(cands.iter());
659        let kind = cols.iter().find(|(c, _)| *c == "kind").unwrap();
660        assert_eq!(kind.1, "→".chars().count());
661    }
662
663    fn seg(text: &str, slot: &str) -> AnnotationSegment {
664        AnnotationSegment {
665            text: text.into(),
666            slot: slot.into(),
667        }
668    }
669
670    #[test]
671    fn styled_display_text_concatenates_segments() {
672        // MR.2: the cell's text is its segments joined; `category()`
673        // keys the column like any single-variant annotation.
674        let perm = Annotation::Styled {
675            category: "perm".into(),
676            segments: vec![
677                seg("d", "completion.annotation.perm.type"),
678                seg("rwx", "completion.annotation.perm.read"),
679            ],
680        };
681        assert_eq!(perm.display_text(), "drwx");
682        assert_eq!(perm.category(), "perm");
683    }
684
685    #[test]
686    fn styled_single_segment_borrows_display_text() {
687        let one = Annotation::Styled {
688            category: "size".into(),
689            segments: vec![seg("1.2k", "completion.annotation.size")],
690        };
691        assert_eq!(one.display_text(), "1.2k");
692        // Empty segment list is a valid (blank) cell, not a panic.
693        let empty = Annotation::Styled {
694            category: "x".into(),
695            segments: vec![],
696        };
697        assert_eq!(empty.display_text(), "");
698    }
699
700    #[test]
701    fn annotation_columns_width_counts_styled_cell() {
702        // A styled cell occupies the width of its concatenated text, so
703        // it aligns alongside single-variant cells in the same column.
704        let cands = [
705            candidate_with(vec![Annotation::Styled {
706                category: "perm".into(),
707                segments: vec![seg("drwxr-xr-x", "completion.annotation.perm.type")],
708            }]),
709            candidate_with(vec![Annotation::Styled {
710                category: "perm".into(),
711                segments: vec![seg("lrwx", "completion.annotation.perm.type")],
712            }]),
713        ];
714        let cols = AnnotationColumns::from_visible(cands.iter());
715        let perm = cols.iter().find(|(c, _)| *c == "perm").unwrap();
716        assert_eq!(perm.1, "drwxr-xr-x".chars().count());
717    }
718
719    #[test]
720    fn columns_ordered_keybinding_then_source() {
721        // Display order: keybinding -> source -> kind -> doc ->
722        // custom. Source sits right after keybinding so the user
723        // sees the contributing mode at a glance.
724        let cands = [candidate_with(vec![
725            Annotation::DocSnippet("docs".into()),
726            Annotation::Source("builtin".into()),
727            Annotation::Kind("ex".into()),
728            Annotation::Keybinding(vec![]),
729        ])];
730        let cols = AnnotationColumns::from_visible(cands.iter());
731        let order: Vec<&str> = cols.iter().map(|(c, _)| c).collect();
732        assert_eq!(order, vec!["keybinding", "source", "kind", "doc"]);
733    }
734
735    #[test]
736    fn columns_empty_when_no_annotations() {
737        let cands = [candidate_with(vec![]), candidate_with(vec![])];
738        let cols = AnnotationColumns::from_visible(cands.iter());
739        assert!(cols.is_empty());
740        assert_eq!(cols.iter().count(), 0);
741    }
742
743    #[test]
744    fn columns_include_category_missing_from_some_rows() {
745        // The whole point of the alignment fix: one row has a
746        // keybinding, the other doesn't. The keybinding column
747        // still exists (width from the row that has it) so the
748        // row without one renders a blank cell of that width and
749        // the kind column stays aligned across both rows.
750        let cands = [
751            candidate_with(vec![
752                Annotation::Keybinding(vec![]),
753                Annotation::Kind("ex".into()),
754            ]),
755            candidate_with(vec![Annotation::Kind("motion".into())]),
756        ];
757        let cols = AnnotationColumns::from_visible(cands.iter());
758        let keys: Vec<&str> = cols.iter().map(|(c, _)| c).collect();
759        assert_eq!(keys, vec!["keybinding", "kind"]);
760        // kind width is the max across both rows.
761        let kind = cols.iter().find(|(c, _)| *c == "kind").unwrap();
762        assert_eq!(kind.1, 6);
763    }
764
765    #[test]
766    fn columns_custom_slots_sort_after_builtins() {
767        let cands = [candidate_with(vec![
768            Annotation::Custom {
769                text: "plug".into(),
770                slot: "annotation_plugin".into(),
771            },
772            Annotation::Kind("ex".into()),
773        ])];
774        let cols = AnnotationColumns::from_visible(cands.iter());
775        let order: Vec<&str> = cols.iter().map(|(c, _)| c).collect();
776        assert_eq!(order, vec!["kind", "annotation_plugin"]);
777    }
778}