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}