1use lattice_grammar::CommandRegistry;
66use lattice_grammar::command::CommandInvocation;
67use lattice_grammar::source::SourceLocation;
68
69use crate::chord::{KeyChord, KeyKind, KeyMods};
70use crate::keymap::BindingMode;
71use crate::keymap_registry::KeymapHandle;
72use crate::keymap_trie::{ChordPattern, KeymapLayer};
73
74struct HelpPrefixEntry {
83 chord: &'static [ChordPattern; 2],
84 command: &'static str,
85 doc: &'static str,
86}
87
88pub fn register_help_prefix_bindings(handle: &KeymapHandle, command_registry: &CommandRegistry) {
98 let layer = KeymapLayer::Builtin;
99 let mode = BindingMode::Normal;
100
101 for entry in help_prefix_table() {
102 let Some(cmd_id) = command_registry.id_by_name(entry.command) else {
103 tracing::warn!(
104 command = entry.command,
105 doc = entry.doc,
106 "keymap_help: command not registered in CommandRegistry; skipping <C-h>-prefix binding",
107 );
108 continue;
109 };
110 handle.bind(
111 layer,
112 mode,
113 entry.chord,
114 CommandInvocation::of(cmd_id),
115 SourceLocation::builtin_file(file!(), line!()),
116 );
117 }
118}
119
120fn help_prefix_table() -> &'static [HelpPrefixEntry] {
123 const C_H_C_H: &[ChordPattern; 2] = &[
124 ChordPattern::Literal(KeyChord {
125 key: KeyKind::Char('h'),
126 mods: KeyMods::CTRL,
127 }),
128 ChordPattern::Literal(KeyChord {
129 key: KeyKind::Char('h'),
130 mods: KeyMods::CTRL,
131 }),
132 ];
133 const C_H_QUESTION: &[ChordPattern; 2] = &[
134 ChordPattern::Literal(KeyChord {
135 key: KeyKind::Char('h'),
136 mods: KeyMods::CTRL,
137 }),
138 ChordPattern::Literal(KeyChord {
139 key: KeyKind::Char('?'),
140 mods: KeyMods::NONE,
141 }),
142 ];
143 const C_H_K: &[ChordPattern; 2] = &[
144 ChordPattern::Literal(KeyChord {
145 key: KeyKind::Char('h'),
146 mods: KeyMods::CTRL,
147 }),
148 ChordPattern::Literal(KeyChord {
149 key: KeyKind::Char('k'),
150 mods: KeyMods::NONE,
151 }),
152 ];
153 const C_H_C: &[ChordPattern; 2] = &[
154 ChordPattern::Literal(KeyChord {
155 key: KeyKind::Char('h'),
156 mods: KeyMods::CTRL,
157 }),
158 ChordPattern::Literal(KeyChord {
159 key: KeyKind::Char('c'),
160 mods: KeyMods::NONE,
161 }),
162 ];
163 const C_H_O: &[ChordPattern; 2] = &[
164 ChordPattern::Literal(KeyChord {
165 key: KeyKind::Char('h'),
166 mods: KeyMods::CTRL,
167 }),
168 ChordPattern::Literal(KeyChord {
169 key: KeyKind::Char('o'),
170 mods: KeyMods::NONE,
171 }),
172 ];
173 const C_H_E: &[ChordPattern; 2] = &[
174 ChordPattern::Literal(KeyChord {
175 key: KeyKind::Char('h'),
176 mods: KeyMods::CTRL,
177 }),
178 ChordPattern::Literal(KeyChord {
179 key: KeyKind::Char('e'),
180 mods: KeyMods::NONE,
181 }),
182 ];
183 const C_H_F: &[ChordPattern; 2] = &[
184 ChordPattern::Literal(KeyChord {
185 key: KeyKind::Char('h'),
186 mods: KeyMods::CTRL,
187 }),
188 ChordPattern::Literal(KeyChord {
189 key: KeyKind::Char('f'),
190 mods: KeyMods::NONE,
191 }),
192 ];
193 const C_H_M: &[ChordPattern; 2] = &[
194 ChordPattern::Literal(KeyChord {
195 key: KeyKind::Char('h'),
196 mods: KeyMods::CTRL,
197 }),
198 ChordPattern::Literal(KeyChord {
199 key: KeyKind::Char('m'),
200 mods: KeyMods::NONE,
201 }),
202 ];
203 const C_H_CAP_M: &[ChordPattern; 2] = &[
204 ChordPattern::Literal(KeyChord {
205 key: KeyKind::Char('h'),
206 mods: KeyMods::CTRL,
207 }),
208 ChordPattern::Literal(KeyChord {
209 key: KeyKind::Char('M'),
210 mods: KeyMods::NONE,
211 }),
212 ];
213 const C_H_B: &[ChordPattern; 2] = &[
214 ChordPattern::Literal(KeyChord {
215 key: KeyKind::Char('h'),
216 mods: KeyMods::CTRL,
217 }),
218 ChordPattern::Literal(KeyChord {
219 key: KeyKind::Char('b'),
220 mods: KeyMods::NONE,
221 }),
222 ];
223 const C_H_A: &[ChordPattern; 2] = &[
224 ChordPattern::Literal(KeyChord {
225 key: KeyKind::Char('h'),
226 mods: KeyMods::CTRL,
227 }),
228 ChordPattern::Literal(KeyChord {
229 key: KeyKind::Char('a'),
230 mods: KeyMods::NONE,
231 }),
232 ];
233 const C_H_CAP_K: &[ChordPattern; 2] = &[
234 ChordPattern::Literal(KeyChord {
235 key: KeyKind::Char('h'),
236 mods: KeyMods::CTRL,
237 }),
238 ChordPattern::Literal(KeyChord {
239 key: KeyKind::Char('K'),
240 mods: KeyMods::NONE,
241 }),
242 ];
243
244 &[
245 HelpPrefixEntry {
246 chord: C_H_C_H,
247 command: "ex:help",
248 doc: "Open the help-for-help index (alias of `<C-h>?`).",
249 },
250 HelpPrefixEntry {
251 chord: C_H_QUESTION,
252 command: "ex:help",
253 doc: "Open the help-for-help index.",
254 },
255 HelpPrefixEntry {
256 chord: C_H_K,
257 command: "ex:describe-key",
258 doc: "Prompt for a chord and show its resolved binding + provenance.",
259 },
260 HelpPrefixEntry {
261 chord: C_H_C,
262 command: "ex:describe-command",
263 doc: "Prompt for a command name and show its metadata.",
264 },
265 HelpPrefixEntry {
266 chord: C_H_O,
267 command: "ex:describe-option",
268 doc: "Prompt for an option name and show its metadata + current value.",
269 },
270 HelpPrefixEntry {
271 chord: C_H_E,
272 command: "ex:describe-event",
273 doc: "Prompt for a typed-event name and show its descriptor.",
274 },
275 HelpPrefixEntry {
276 chord: C_H_F,
277 command: "ex:describe-element",
278 doc: "Prompt for a theme element / face name and show its metadata + resolved style.",
279 },
280 HelpPrefixEntry {
281 chord: C_H_M,
282 command: "ex:describe-active-modes",
283 doc: "Show the active major + minor modes on the current buffer, with their chords.",
284 },
285 HelpPrefixEntry {
286 chord: C_H_CAP_M,
287 command: "ex:describe-mode",
288 doc: "Prompt for a mode name and show that mode's metadata.",
289 },
290 HelpPrefixEntry {
291 chord: C_H_B,
292 command: "ex:describe-buffer",
293 doc: "Show metadata for the current buffer (kind, flags, mode stack, …).",
294 },
295 HelpPrefixEntry {
296 chord: C_H_A,
297 command: "ex:apropos",
298 doc: "Cross-cutting search across commands / options / events.",
299 },
300 HelpPrefixEntry {
301 chord: C_H_CAP_K,
302 command: "ex:describe-bindings",
303 doc: "List the chords that can fire on the current buffer.",
304 },
305 ]
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 use lattice_grammar::CommandRegistry;
313
314 use crate::keymap_registry::KeymapHandle;
315 use crate::keymap_trie::LookupResult;
316
317 fn registry_with_help_commands() -> CommandRegistry {
320 let mut r = CommandRegistry::new();
321 let _ = lattice_grammar::builtins::populate(&mut r);
322 let _ = lattice_grammar::ex_commands::populate(&mut r);
323 r
324 }
325
326 fn lookup_normal(handle: &KeymapHandle, chords: &[KeyChord]) -> LookupResult {
327 handle.lookup_with_context(BindingMode::Normal, chords, &[])
328 }
329
330 #[test]
331 fn help_prefix_chord_table_resolves_all_commands() {
332 let registry = registry_with_help_commands();
339 for entry in help_prefix_table() {
340 assert!(
341 registry.id_by_name(entry.command).is_some(),
342 "help-prefix row `{}` ({}) references unregistered command",
343 entry.command,
344 entry.doc,
345 );
346 }
347 }
348
349 #[test]
350 fn register_help_prefix_binds_double_ctrl_h_to_ex_help() {
351 let handle = KeymapHandle::new();
352 let registry = registry_with_help_commands();
353 register_help_prefix_bindings(&handle, ®istry);
354
355 let expected = registry
356 .id_by_name("ex:help")
357 .expect("ex:help registered by ex_commands::populate");
358 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::ctrl('h')]) {
359 LookupResult::Bound { command, .. } => {
360 assert_eq!(command.command.command, expected);
361 assert_eq!(command.layer, KeymapLayer::Builtin);
362 }
363 other => panic!("expected Bound on <C-h><C-h>, got {other:?}"),
364 }
365 }
366
367 #[test]
368 fn register_help_prefix_binds_describe_key_chord() {
369 let handle = KeymapHandle::new();
370 let registry = registry_with_help_commands();
371 register_help_prefix_bindings(&handle, ®istry);
372
373 let expected = registry
374 .id_by_name("ex:describe-key")
375 .expect("ex:describe-key registered");
376 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('k')]) {
377 LookupResult::Bound { command, .. } => {
378 assert_eq!(command.command.command, expected);
379 }
380 other => panic!("expected Bound on <C-h>k, got {other:?}"),
381 }
382 }
383
384 #[test]
385 fn ctrl_h_m_shows_active_modes_without_prompting() {
386 let handle = KeymapHandle::new();
392 let registry = registry_with_help_commands();
393 register_help_prefix_bindings(&handle, ®istry);
394
395 let expected = registry
396 .id_by_name("ex:describe-active-modes")
397 .expect("ex:describe-active-modes registered");
398 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('m')]) {
399 LookupResult::Bound { command, .. } => {
400 assert_eq!(command.command.command, expected);
401 assert_eq!(command.layer, KeymapLayer::Builtin);
402 }
403 other => panic!("expected Bound on <C-h>m, got {other:?}"),
404 }
405
406 let spec = registry
408 .lookup_by_name("ex:describe-active-modes")
409 .expect("registered");
410 assert!(
411 spec.args_schema.is_empty(),
412 "<C-h>m must not be able to prompt",
413 );
414 }
415
416 #[test]
417 fn ctrl_h_capital_m_keeps_the_prompt_for_any_mode_path() {
418 let handle = KeymapHandle::new();
422 let registry = registry_with_help_commands();
423 register_help_prefix_bindings(&handle, ®istry);
424
425 let expected = registry
426 .id_by_name("ex:describe-mode")
427 .expect("ex:describe-mode registered");
428 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('M')]) {
429 LookupResult::Bound { command, .. } => {
430 assert_eq!(command.command.command, expected);
431 }
432 other => panic!("expected Bound on <C-h>M, got {other:?}"),
433 }
434
435 let spec = registry
436 .lookup_by_name("ex:describe-mode")
437 .expect("registered");
438 assert_eq!(
439 spec.args_schema.len(),
440 1,
441 "<C-h>M must still prompt for a mode name",
442 );
443 }
444
445 #[test]
446 fn ctrl_h_m_and_capital_m_are_distinct_bindings() {
447 let handle = KeymapHandle::new();
451 let registry = registry_with_help_commands();
452 register_help_prefix_bindings(&handle, ®istry);
453
454 let lower = match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('m')]) {
455 LookupResult::Bound { command, .. } => command.command.command,
456 other => panic!("expected Bound on <C-h>m, got {other:?}"),
457 };
458 let upper = match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('M')]) {
459 LookupResult::Bound { command, .. } => command.command.command,
460 other => panic!("expected Bound on <C-h>M, got {other:?}"),
461 };
462 assert_ne!(lower, upper, "<C-h>m and <C-h>M must not collapse");
463 }
464
465 #[test]
466 fn capital_m_help_prefix_does_not_fire_outside_normal() {
467 let handle = help_prefix_handle_for_mode_scope_tests();
470 for mode in [
471 BindingMode::Insert,
472 BindingMode::Visual,
473 BindingMode::OperatorPending,
474 BindingMode::Command,
475 BindingMode::Search,
476 BindingMode::Replace,
477 ] {
478 let result =
479 handle.lookup_with_context(mode, &[KeyChord::ctrl('h'), KeyChord::char('M')], &[]);
480 assert!(
481 matches!(result, LookupResult::Unbound),
482 "<C-h>M leaked into {mode:?}",
483 );
484 }
485 }
486
487 #[test]
488 fn ctrl_h_capital_k_scopes_bindings_to_the_buffer() {
489 let handle = KeymapHandle::new();
494 let registry = registry_with_help_commands();
495 register_help_prefix_bindings(&handle, ®istry);
496
497 let expected = registry
498 .id_by_name("ex:describe-bindings")
499 .expect("ex:describe-bindings registered");
500 let keymap_id = registry
501 .id_by_name("ex:keymap")
502 .expect("ex:keymap still registered");
503 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::char('K')]) {
504 LookupResult::Bound { command, .. } => {
505 assert_eq!(command.command.command, expected);
506 assert_ne!(
507 command.command.command, keymap_id,
508 "<C-h>K must no longer open the full catalog",
509 );
510 }
511 other => panic!("expected Bound on <C-h>K, got {other:?}"),
512 }
513 }
514
515 #[test]
516 fn bare_ctrl_h_is_partial_not_bound() {
517 let handle = KeymapHandle::new();
522 let registry = registry_with_help_commands();
523 register_help_prefix_bindings(&handle, ®istry);
524
525 match lookup_normal(&handle, &[KeyChord::ctrl('h')]) {
526 LookupResult::Partial => { }
527 other => panic!("expected Partial on bare <C-h>, got {other:?}"),
528 }
529 }
530
531 #[test]
532 fn help_prefix_does_not_fire_in_insert_mode() {
533 let handle = KeymapHandle::new();
538 let registry = registry_with_help_commands();
539 register_help_prefix_bindings(&handle, ®istry);
540
541 let result = handle.lookup_with_context(
542 BindingMode::Insert,
543 &[KeyChord::ctrl('h'), KeyChord::char('k')],
544 &[],
545 );
546 assert!(matches!(result, LookupResult::Unbound));
547 }
548
549 #[test]
550 fn register_help_prefix_warns_and_skips_unresolvable_commands() {
551 let handle = KeymapHandle::new();
555 let empty_registry = CommandRegistry::new();
556 register_help_prefix_bindings(&handle, &empty_registry);
557
558 match lookup_normal(&handle, &[KeyChord::ctrl('h'), KeyChord::ctrl('h')]) {
559 LookupResult::Unbound => { }
560 other => panic!("expected Unbound with empty registry, got {other:?}"),
561 }
562 }
563
564 fn help_prefix_handle_for_mode_scope_tests() -> KeymapHandle {
574 let handle = KeymapHandle::new();
575 let registry = registry_with_help_commands();
576 register_help_prefix_bindings(&handle, ®istry);
577 handle
578 }
579
580 #[test]
581 fn help_prefix_does_not_fire_in_visual_mode() {
582 let handle = help_prefix_handle_for_mode_scope_tests();
583 let result = handle.lookup_with_context(
584 BindingMode::Visual,
585 &[KeyChord::ctrl('h'), KeyChord::char('k')],
586 &[],
587 );
588 assert!(matches!(result, LookupResult::Unbound));
589 }
590
591 #[test]
592 fn help_prefix_does_not_fire_in_operator_pending_mode() {
593 let handle = help_prefix_handle_for_mode_scope_tests();
598 let result = handle.lookup_with_context(
599 BindingMode::OperatorPending,
600 &[KeyChord::ctrl('h'), KeyChord::char('k')],
601 &[],
602 );
603 assert!(matches!(result, LookupResult::Unbound));
604 }
605
606 #[test]
607 fn help_prefix_does_not_fire_in_cmdline_mode() {
608 let handle = help_prefix_handle_for_mode_scope_tests();
613 let result = handle.lookup_with_context(
614 BindingMode::Command,
615 &[KeyChord::ctrl('h'), KeyChord::char('k')],
616 &[],
617 );
618 assert!(matches!(result, LookupResult::Unbound));
619 }
620
621 #[test]
622 fn help_prefix_does_not_fire_in_search_mode() {
623 let handle = help_prefix_handle_for_mode_scope_tests();
626 let result = handle.lookup_with_context(
627 BindingMode::Search,
628 &[KeyChord::ctrl('h'), KeyChord::char('k')],
629 &[],
630 );
631 assert!(matches!(result, LookupResult::Unbound));
632 }
633
634 #[test]
635 fn help_prefix_does_not_fire_in_replace_mode() {
636 let handle = help_prefix_handle_for_mode_scope_tests();
640 let result = handle.lookup_with_context(
641 BindingMode::Replace,
642 &[KeyChord::ctrl('h'), KeyChord::char('k')],
643 &[],
644 );
645 assert!(matches!(result, LookupResult::Unbound));
646 }
647}