Skip to main content

lattice_completion/builtins/
annotators.rs

1//! Built-in annotators (DESIGN.md §5.11.3).
2//!
3//! Annotators run AFTER ranking. Each appends typed
4//! [`Annotation`] values to `RenderedCandidate.annotations`;
5//! the renderer paints each one with the style its category
6//! resolves to. See `docs/dev/architecture/marginalia.md`.
7
8use std::sync::Arc;
9
10use lattice_grammar::kind_icon;
11use lattice_protocol::KeyChord;
12
13use crate::candidate::{Annotation, CandidateData, CandidateKind, RenderedCandidate};
14use crate::traits::CandidateAnnotator;
15
16/// Structured provenance for a keybinding annotation: where the
17/// chord comes from. Used by both the filtering logic (show only
18/// if the source mode is active) and the display column (show the
19/// mode name alongside the chord).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KeybindingSource {
22    /// Always-on layer: Builtin, User, or Buffer.
23    /// Always shows in the completion margin (no active-mode gate).
24    AlwaysOn,
25    /// A minor/major mode binding. The string is the mode's name
26    /// (e.g. `"emacs-keys-mode"`). Only shows when the mode is
27    /// active in the current buffer.
28    Mode(Arc<str>),
29}
30
31/// Reverse-lookup contract the keybinding annotator depends on:
32/// given a command's canonical name, return the chord
33/// sequence(s) bound to it. Empty vec means "no binding."
34///
35/// MARG.2 (2026-06-03): the lattice-host side maintains the
36/// reverse cache (built alongside the merged keymap trie on
37/// every `bind` / `unbind`) and supplies an implementor at
38/// boot. Keeping the trait in `lattice-completion` lets the
39/// annotator type live with the other annotators while leaving
40/// the cache-build / invalidation logic in the host crate where
41/// the trie lives.
42///
43/// The empty-vec contract is important: `KeybindingAnnotator`
44/// short-circuits on empty results so commands without a bound
45/// chord don't get a misleading empty annotation. Implementors
46/// should NOT return `vec![]` to mean "lookup failed" — return
47/// it to mean "no binding," same thing semantically.
48///
49/// MARG.3 (2026-07-15): `chords_with_source` returns structured
50/// provenance alongside each chord so callers can filter by
51/// active modes and display the source mode label. The default
52/// impl wraps every chord from `chords_for` with
53/// [`KeybindingSource::AlwaysOn`] — implementors that maintain
54/// mode-aware caches (e.g. the host's keymap registry) should
55/// override this to return the real provenance.
56pub trait KeymapReverseLookup: Send + Sync {
57    fn chords_for(&self, command_name: &str) -> Vec<KeyChord>;
58
59    /// Like [`chords_for`](Self::chords_for) but returns each
60    /// chord tagged with its [`KeybindingSource`] so callers can
61    /// filter by active mode and display provenance.
62    fn chords_with_source(&self, command_name: &str) -> Vec<(KeyChord, KeybindingSource)> {
63        self.chords_for(command_name)
64            .into_iter()
65            .map(|c| (c, KeybindingSource::AlwaysOn))
66            .collect()
67    }
68}
69
70/// `anno:kind-label`. Tags every candidate with `(command)`,
71/// `(file)`, `(motion)`, etc. -- the kind label. Pushes
72/// [`Annotation::Kind`] so the renderer styles it with the
73/// kind-annotation theme slot.
74pub struct KindLabelAnnotator;
75
76impl CandidateAnnotator for KindLabelAnnotator {
77    fn annotate(&self, c: &mut RenderedCandidate) {
78        let label = match (&c.raw.kind, &c.raw.data) {
79            (CandidateKind::Command, CandidateData::Command { kind_label, .. }) => {
80                kind_label.clone()
81            }
82            (CandidateKind::Command, _) => "command".to_string(),
83            (CandidateKind::Option, _) => "option".to_string(),
84            (CandidateKind::File, CandidateData::File { is_dir: true, .. }) => {
85                "directory".to_string()
86            }
87            (CandidateKind::File, _) => "file".to_string(),
88            (CandidateKind::Directory, _) => "directory".to_string(),
89            (CandidateKind::Pattern, _) => "pattern".to_string(),
90            (CandidateKind::Buffer, _) => "buffer".to_string(),
91            (CandidateKind::Register, _) => "register".to_string(),
92            (CandidateKind::Mark, _) => "mark".to_string(),
93            (CandidateKind::Chord, _) => "chord".to_string(),
94            (CandidateKind::Plain, _) => return,
95            (CandidateKind::Extension(_), _) => return,
96        };
97        c.annotations
98            .push(Annotation::Kind(Arc::from(kind_icon(&label))));
99    }
100}
101
102/// `anno:doc-snippet`. Appends the first line of the candidate's
103/// documentation if available. Pushes [`Annotation::DocSnippet`]
104/// so the renderer styles it with the doc-annotation theme slot.
105pub struct DocSnippetAnnotator;
106
107impl CandidateAnnotator for DocSnippetAnnotator {
108    fn annotate(&self, c: &mut RenderedCandidate) {
109        let snippet = match &c.raw.data {
110            CandidateData::Command { doc, .. } => first_line(doc),
111            CandidateData::Option { doc, .. } => first_line(doc),
112            // Slice `3c.unify.option-doc-annotator`: per-value
113            // doc surfaces in the marginalia column. Empty doc
114            // (when the type hasn't overridden
115            // `enumerate_with_docs`) annotates nothing.
116            CandidateData::OptionValue { doc, .. } => first_line(doc),
117            CandidateData::Chord { doc, .. } => first_line(doc),
118            CandidateData::File { path, .. } => path.display().to_string(),
119            CandidateData::Register { preview, .. } => preview.clone(),
120            CandidateData::Mark { position, .. } => position.clone(),
121            CandidateData::Plain | CandidateData::Extension { .. } => return,
122        };
123        if !snippet.is_empty() {
124            c.annotations
125                .push(Annotation::DocSnippet(Arc::from(snippet)));
126        }
127    }
128}
129
130fn first_line(text: &str) -> String {
131    text.lines().next().unwrap_or("").to_string()
132}
133
134/// `anno:keybinding`. For `CandidateData::Command` candidates,
135/// looks up the chord(s) bound to the command and pushes
136/// [`Annotation::Keybinding`] so the renderer styles it with
137/// the keybinding theme slot. Skips silently for
138/// non-command candidates and for commands with no binding.
139///
140/// MARG.2 (2026-06-03): see
141/// `docs/dev/architecture/marginalia.md` §6.
142///
143/// The reverse-lookup source is supplied at construction time
144/// — typically the lattice-host's keymap registry, which
145/// rebuilds the reverse cache atomically alongside the merged
146/// trie on every `bind` / `unbind`. The annotator does NOT
147/// cache the result internally; each call hits the lookup
148/// fresh so changes (e.g. user `:map`) take effect on the
149/// next popup open without restart.
150pub struct KeybindingAnnotator {
151    reverse: Arc<dyn KeymapReverseLookup>,
152}
153
154impl KeybindingAnnotator {
155    pub fn new(reverse: Arc<dyn KeymapReverseLookup>) -> Self {
156        Self { reverse }
157    }
158}
159
160impl CandidateAnnotator for KeybindingAnnotator {
161    fn annotate(&self, c: &mut RenderedCandidate) {
162        let name = match (&c.raw.kind, &c.raw.data) {
163            (CandidateKind::Command, CandidateData::Command { name, .. }) => name.as_str(),
164            _ => return,
165        };
166        let source_chords = self.reverse.chords_with_source(name);
167        if source_chords.is_empty() {
168            return;
169        }
170        let chords: Vec<KeyChord> = source_chords.iter().map(|(chord, _)| *chord).collect();
171        c.annotations.push(Annotation::Keybinding(chords));
172
173        let mut seen = std::collections::BTreeSet::new();
174        for (_, source) in &source_chords {
175            if let KeybindingSource::Mode(label) = source {
176                seen.insert(label.clone());
177            }
178        }
179        if !seen.is_empty() {
180            let joined: Arc<str> = seen.into_iter().collect::<Vec<_>>().join(", ").into();
181            c.annotations.push(Annotation::Source(joined));
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    #![allow(clippy::unwrap_used, clippy::panic)]
189    use super::*;
190    use crate::candidate::{CandidateData, CandidateKind, MatchScore, RawCandidate};
191    use lattice_grammar::source::SourceLocation;
192
193    fn rendered(text: &str, kind: CandidateKind, data: CandidateData) -> RenderedCandidate {
194        RenderedCandidate {
195            raw: RawCandidate {
196                insert_text: None,
197                text: text.into(),
198                display: text.into(),
199                kind,
200                data,
201                source: None,
202                accept_action: None,
203                annotations: Vec::new(),
204                display_spans: Vec::new(),
205            },
206            score: MatchScore::PERFECT,
207            match_ranges: Vec::new(),
208            annotations: Vec::new(),
209        }
210    }
211
212    /// Lift annotation text out of the typed enum for ergonomic
213    /// assertion: every test in this module wants to compare
214    /// against `Vec<&str>` of display text. Category is asserted
215    /// separately in the `*_categorizes_as_*` tests.
216    fn display_texts(c: &RenderedCandidate) -> Vec<String> {
217        c.annotations
218            .iter()
219            .map(|a| a.display_text().into_owned())
220            .collect()
221    }
222
223    #[test]
224    fn kind_label_uses_command_kind_label_when_present() {
225        let mut c = rendered(
226            "motion:line-down",
227            CandidateKind::Command,
228            CandidateData::Command {
229                name: "motion:line-down".into(),
230                doc: "".into(),
231                kind_label: "motion".into(),
232                source: SourceLocation::synthetic("test"),
233            },
234        );
235        KindLabelAnnotator.annotate(&mut c);
236        assert_eq!(display_texts(&c), vec!["→"]);
237    }
238
239    #[test]
240    fn kind_label_categorizes_as_kind() {
241        // MARG.1: typed annotation carries its category; the
242        // renderer pattern-matches on this to pick the theme
243        // slot. Asserts the variant tag, not the text.
244        let mut c = rendered(
245            "x",
246            CandidateKind::Command,
247            CandidateData::Command {
248                name: "x".into(),
249                doc: "".into(),
250                kind_label: "motion".into(),
251                source: SourceLocation::synthetic("test"),
252            },
253        );
254        KindLabelAnnotator.annotate(&mut c);
255        assert_eq!(c.annotations[0].category(), "kind");
256        assert!(matches!(c.annotations[0], Annotation::Kind(_)));
257    }
258
259    #[test]
260    fn kind_label_falls_back_to_command_for_non_command_data() {
261        let mut c = rendered("x", CandidateKind::Command, CandidateData::Plain);
262        KindLabelAnnotator.annotate(&mut c);
263        assert_eq!(display_texts(&c), vec!["·"]);
264    }
265
266    #[test]
267    fn kind_label_distinguishes_files_from_directories() {
268        let mut f = rendered(
269            "file.rs",
270            CandidateKind::File,
271            CandidateData::File {
272                path: "/tmp/file.rs".into(),
273                is_dir: false,
274                size: None,
275            },
276        );
277        KindLabelAnnotator.annotate(&mut f);
278        assert_eq!(display_texts(&f), vec!["f"]);
279
280        let mut d = rendered(
281            "Documents",
282            CandidateKind::File,
283            CandidateData::File {
284                path: "/tmp/Documents".into(),
285                is_dir: true,
286                size: None,
287            },
288        );
289        KindLabelAnnotator.annotate(&mut d);
290        assert_eq!(display_texts(&d), vec!["d"]);
291    }
292
293    #[test]
294    fn kind_label_skips_plain_kind() {
295        let mut c = rendered("x", CandidateKind::Plain, CandidateData::Plain);
296        KindLabelAnnotator.annotate(&mut c);
297        assert!(c.annotations.is_empty());
298    }
299
300    #[test]
301    fn doc_snippet_appends_first_line_of_command_doc() {
302        let mut c = rendered(
303            "ex:write",
304            CandidateKind::Command,
305            CandidateData::Command {
306                name: "ex:write".into(),
307                doc: "Write the buffer.\nMore detail follows.".into(),
308                kind_label: "ex-command".into(),
309                source: SourceLocation::synthetic("test"),
310            },
311        );
312        DocSnippetAnnotator.annotate(&mut c);
313        assert_eq!(display_texts(&c), vec!["Write the buffer."]);
314    }
315
316    #[test]
317    fn doc_snippet_categorizes_as_doc() {
318        let mut c = rendered(
319            "ex:write",
320            CandidateKind::Command,
321            CandidateData::Command {
322                name: "ex:write".into(),
323                doc: "Write the buffer.".into(),
324                kind_label: "ex-command".into(),
325                source: SourceLocation::synthetic("test"),
326            },
327        );
328        DocSnippetAnnotator.annotate(&mut c);
329        assert_eq!(c.annotations[0].category(), "doc");
330        assert!(matches!(c.annotations[0], Annotation::DocSnippet(_)));
331    }
332
333    #[test]
334    fn doc_snippet_uses_path_for_files() {
335        let mut c = rendered(
336            "file.rs",
337            CandidateKind::File,
338            CandidateData::File {
339                path: "/tmp/foo/file.rs".into(),
340                is_dir: false,
341                size: None,
342            },
343        );
344        DocSnippetAnnotator.annotate(&mut c);
345        assert_eq!(display_texts(&c), vec!["/tmp/foo/file.rs"]);
346    }
347
348    #[test]
349    fn doc_snippet_skips_when_doc_is_empty() {
350        let mut c = rendered(
351            "x",
352            CandidateKind::Command,
353            CandidateData::Command {
354                name: "x".into(),
355                doc: "".into(),
356                kind_label: "ex-command".into(),
357                source: SourceLocation::synthetic("test"),
358            },
359        );
360        DocSnippetAnnotator.annotate(&mut c);
361        assert!(c.annotations.is_empty());
362    }
363
364    #[test]
365    fn doc_snippet_skips_extension_data() {
366        let mut c = rendered(
367            "x",
368            CandidateKind::Extension(7),
369            CandidateData::Extension {
370                kind_id: 7,
371                payload: vec![],
372            },
373        );
374        DocSnippetAnnotator.annotate(&mut c);
375        assert!(c.annotations.is_empty());
376    }
377
378    #[test]
379    fn annotators_chain_in_registration_order() {
380        // Pure trait-level test: verify both annotators leave their
381        // marks when run in sequence, in order. Asserts both the
382        // category tags AND the display text are preserved so the
383        // renderer's per-variant style lookup downstream gets the
384        // right slot.
385        let mut c = rendered(
386            "ex:write",
387            CandidateKind::Command,
388            CandidateData::Command {
389                name: "ex:write".into(),
390                doc: "Write the buffer.".into(),
391                kind_label: "ex-command".into(),
392                source: SourceLocation::synthetic("test"),
393            },
394        );
395        KindLabelAnnotator.annotate(&mut c);
396        DocSnippetAnnotator.annotate(&mut c);
397        assert_eq!(display_texts(&c), vec![":", "Write the buffer."]);
398        assert_eq!(c.annotations[0].category(), "kind");
399        assert_eq!(c.annotations[1].category(), "doc");
400    }
401
402    #[test]
403    fn keybinding_display_text_formats_chords_space_separated() {
404        // MARG.1 sets up the Keybinding variant; MARG.2 wires the
405        // annotator. Display-text formatting belongs to the
406        // variant — assert it here so the contract is locked in
407        // before the annotator lands.
408        use lattice_protocol::KeyChord;
409        let ann = Annotation::Keybinding(vec![KeyChord::ctrl('w'), KeyChord::char('v')]);
410        assert_eq!(ann.display_text(), "<C-w> v");
411        assert_eq!(ann.category(), "keybinding");
412    }
413
414    #[test]
415    fn keybinding_display_text_handles_single_chord() {
416        use lattice_protocol::KeyChord;
417        let ann = Annotation::Keybinding(vec![KeyChord::char('j')]);
418        assert_eq!(ann.display_text(), "j");
419    }
420
421    #[test]
422    fn keybinding_display_text_is_empty_for_empty_chord_list() {
423        // Annotators should not emit Keybinding with an empty
424        // list (the contract is documented on the variant); but
425        // if they do, display falls back to empty so we never
426        // panic at paint time.
427        let ann = Annotation::Keybinding(vec![]);
428        assert_eq!(ann.display_text(), "");
429    }
430
431    #[test]
432    fn custom_annotation_passes_slot_through() {
433        let ann = Annotation::Custom {
434            text: "[lsp]".into(),
435            slot: "annotation_lsp".into(),
436        };
437        assert_eq!(ann.display_text(), "[lsp]");
438        assert_eq!(ann.category(), "annotation_lsp");
439    }
440
441    /// In-memory `KeymapReverseLookup` impl for testing
442    /// `KeybindingAnnotator` without standing up a real keymap
443    /// registry. Real impl lives in `lattice-host`.
444    struct FakeLookup(std::collections::HashMap<String, Vec<KeyChord>>);
445
446    impl KeymapReverseLookup for FakeLookup {
447        fn chords_for(&self, name: &str) -> Vec<KeyChord> {
448            self.0.get(name).cloned().unwrap_or_default()
449        }
450    }
451
452    #[test]
453    fn keybinding_annotator_pushes_chord_for_bound_command() {
454        let mut lookup_map = std::collections::HashMap::new();
455        lookup_map.insert("ex:write".into(), vec![KeyChord::ctrl('s')]);
456        let annot = KeybindingAnnotator::new(Arc::new(FakeLookup(lookup_map)));
457        let mut c = rendered(
458            "ex:write",
459            CandidateKind::Command,
460            CandidateData::Command {
461                name: "ex:write".into(),
462                doc: "Write the buffer.".into(),
463                kind_label: "ex-command".into(),
464                source: SourceLocation::synthetic("test"),
465            },
466        );
467        annot.annotate(&mut c);
468        assert_eq!(c.annotations.len(), 1);
469        assert_eq!(c.annotations[0].category(), "keybinding");
470        assert_eq!(c.annotations[0].display_text(), "<C-s>");
471    }
472
473    #[test]
474    fn keybinding_annotator_skips_unbound_command() {
475        let annot =
476            KeybindingAnnotator::new(Arc::new(FakeLookup(std::collections::HashMap::new())));
477        let mut c = rendered(
478            "ex:write",
479            CandidateKind::Command,
480            CandidateData::Command {
481                name: "ex:write".into(),
482                doc: "".into(),
483                kind_label: "ex-command".into(),
484                source: SourceLocation::synthetic("test"),
485            },
486        );
487        annot.annotate(&mut c);
488        // No binding ⇒ no annotation. Critical: an empty
489        // annotation would still paint a styled span with
490        // zero-width text, which would print a stray
491        // background-only blank in the popup.
492        assert!(c.annotations.is_empty());
493    }
494
495    #[test]
496    fn keybinding_annotator_skips_non_command_candidates() {
497        let mut lookup_map = std::collections::HashMap::new();
498        // Even if a name happens to match a file's text, the
499        // annotator must not synthesize a keybinding for it.
500        lookup_map.insert("file.rs".into(), vec![KeyChord::char('q')]);
501        let annot = KeybindingAnnotator::new(Arc::new(FakeLookup(lookup_map)));
502        let mut c = rendered(
503            "file.rs",
504            CandidateKind::File,
505            CandidateData::File {
506                path: "/tmp/file.rs".into(),
507                is_dir: false,
508                size: None,
509            },
510        );
511        annot.annotate(&mut c);
512        assert!(c.annotations.is_empty());
513    }
514
515    #[test]
516    fn keybinding_annotator_renders_multi_key_chord_sequence() {
517        let mut lookup_map = std::collections::HashMap::new();
518        lookup_map.insert(
519            "ex:split-pane-vertical".into(),
520            vec![KeyChord::ctrl('w'), KeyChord::char('v')],
521        );
522        let annot = KeybindingAnnotator::new(Arc::new(FakeLookup(lookup_map)));
523        let mut c = rendered(
524            "ex:split-pane-vertical",
525            CandidateKind::Command,
526            CandidateData::Command {
527                name: "ex:split-pane-vertical".into(),
528                doc: "".into(),
529                kind_label: "ex-command".into(),
530                source: SourceLocation::synthetic("test"),
531            },
532        );
533        annot.annotate(&mut c);
534        assert_eq!(c.annotations[0].display_text(), "<C-w> v");
535    }
536}