1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KeybindingSource {
22 AlwaysOn,
25 Mode(Arc<str>),
29}
30
31pub trait KeymapReverseLookup: Send + Sync {
57 fn chords_for(&self, command_name: &str) -> Vec<KeyChord>;
58
59 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
70pub 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
102pub 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 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
134pub 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 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 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 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 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 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 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 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 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}