Skip to main content

hjkl_vim/
descriptors.rs

1//! Static descriptor table for the vim FSM's built-in bindings.
2//!
3//! [`children_for`] returns the direct children of a prefix in the engine
4//! FSM's dispatch table for a given [`crate::Mode`]. The caller (typically
5//! `which_key::entries_for` in the hjkl app) merges these with app-keymap
6//! entries; app bindings win on conflict (`:nmap` shadows builtins).
7//!
8//! **Completeness policy (v1):** Normal-mode root, g-prefix, z-prefix, and
9//! operator-pending (d/c/y) children are covered. Visual-mode and text-object
10//! completeness are out of scope for v1 per issue #64.
11//!
12//! **Drift risk:** The table is hand-maintained. If `normal.rs` or
13//! `hjkl-engine`'s `apply_after_g` / `apply_after_z` add a new binding,
14//! this table will miss it until updated. The `COUNT_*` constants + tests
15//! assert exact counts so drift triggers test failures, not silent gaps.
16
17use hjkl_keymap::{KeyCode, KeyEvent, KeyModifiers};
18
19use crate::Mode;
20
21// ─── Public types ─────────────────────────────────────────────────────────────
22
23/// A single built-in vim FSM binding, for which-key display.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct VimDescriptor {
26    /// The key event that triggers this binding.
27    pub key: KeyEvent,
28    /// Short human-readable description.
29    /// `None` means prefix-only node (submenu — shows as "…" in the popup).
30    pub desc: Option<&'static str>,
31}
32
33impl VimDescriptor {
34    fn char(c: char, desc: &'static str) -> Self {
35        Self {
36            key: KeyEvent::char(c),
37            desc: Some(desc),
38        }
39    }
40
41    fn ctrl(c: char, desc: &'static str) -> Self {
42        Self {
43            key: KeyEvent::ctrl(c),
44            desc: Some(desc),
45        }
46    }
47
48    fn prefix(c: char) -> Self {
49        Self {
50            key: KeyEvent::char(c),
51            desc: None,
52        }
53    }
54}
55
56// ─── Public API ───────────────────────────────────────────────────────────────
57
58/// Return the direct children of `prefix` in the engine FSM's dispatch table
59/// for the given `mode`.
60///
61/// - Empty prefix → root keys for the mode.
62/// - `[KeyEvent::char('g')]` → g-prefix children.
63/// - `[KeyEvent::char('z')]` → z-prefix children.
64/// - `[KeyEvent::char('d')]` / `['c']` / `['y']` → operator-pending children
65///   (motions + sub-prefixes for text objects).
66/// - Unknown prefix → empty.
67///
68/// Results are in declaration order (not sorted — the caller sorts for display).
69pub fn children_for(mode: Mode, prefix: &[KeyEvent]) -> Vec<VimDescriptor> {
70    match mode {
71        Mode::Normal => children_normal(prefix),
72        Mode::Visual | Mode::VisualLine | Mode::VisualBlock => children_visual(prefix),
73        Mode::OpPending => children_op_pending(prefix),
74        Mode::Insert | Mode::CommandLine => vec![],
75    }
76}
77
78// ─── Expected counts (used by tests to catch drift) ───────────────────────────
79
80/// Expected count of root Normal-mode descriptors.
81pub const COUNT_NORMAL_ROOT: usize = 84;
82/// Expected count of g-prefix descriptors.
83pub const COUNT_G_PREFIX: usize = 23;
84/// Expected count of z-prefix descriptors.
85pub const COUNT_Z_PREFIX: usize = 11;
86/// Expected count of operator-pending root descriptors (d/c/y prefix children).
87pub const COUNT_OP_PENDING_ROOT: usize = 25;
88
89// ─── Normal-mode dispatch ──────────────────────────────────────────────────────
90
91fn children_normal(prefix: &[KeyEvent]) -> Vec<VimDescriptor> {
92    if prefix.is_empty() {
93        return normal_root();
94    }
95    // Single-char prefixes.
96    if prefix.len() == 1 {
97        let k = prefix[0];
98        if k == KeyEvent::char('g') {
99            return g_prefix();
100        }
101        if k == KeyEvent::char('z') {
102            return z_prefix();
103        }
104        // Operator prefixes — show motion/text-object children.
105        if k == KeyEvent::char('d')
106            || k == KeyEvent::char('c')
107            || k == KeyEvent::char('y')
108            || k == KeyEvent::char('>')
109            || k == KeyEvent::char('<')
110            || k == KeyEvent::char('=')
111        {
112            return op_pending_root();
113        }
114    }
115    vec![]
116}
117
118fn children_visual(prefix: &[KeyEvent]) -> Vec<VimDescriptor> {
119    if prefix.is_empty() {
120        return visual_root();
121    }
122    if prefix.len() == 1 && prefix[0] == KeyEvent::char('z') {
123        return z_prefix();
124    }
125    if prefix.len() == 1 && prefix[0] == KeyEvent::char('g') {
126        // In visual mode only `gc` is relevant; return the same g-prefix table
127        // so which-key shows it alongside gv / gj / gk that visual mode supports.
128        return g_prefix();
129    }
130    vec![]
131}
132
133fn children_op_pending(prefix: &[KeyEvent]) -> Vec<VimDescriptor> {
134    if prefix.is_empty() {
135        return op_pending_root();
136    }
137    vec![]
138}
139
140// ─── Root tables ──────────────────────────────────────────────────────────────
141
142fn normal_root() -> Vec<VimDescriptor> {
143    // Sourced from normal.rs: handle_normal_only + parse_motion + pending-entry
144    // arms + Ctrl branches. Keys the engine silently swallows but doesn't act
145    // on (unknown chars) are excluded.
146    vec![
147        // ── Insert-mode entries ───────────────────────────────────────────────
148        VimDescriptor::char('i', "insert before cursor"),
149        VimDescriptor::char('I', "insert at line start"),
150        VimDescriptor::char('a', "append after cursor"),
151        VimDescriptor::char('A', "append at line end"),
152        VimDescriptor::char('o', "open line below"),
153        VimDescriptor::char('O', "open line above"),
154        VimDescriptor::char('R', "enter replace mode"),
155        VimDescriptor::char('s', "substitute char"),
156        VimDescriptor::char('S', "substitute line"),
157        // ── Delete / change / yank ────────────────────────────────────────────
158        VimDescriptor::prefix('d'),
159        VimDescriptor::prefix('c'),
160        VimDescriptor::prefix('y'),
161        VimDescriptor::char('x', "delete char forward"),
162        VimDescriptor::char('X', "delete char backward"),
163        VimDescriptor::char('D', "delete to end of line"),
164        VimDescriptor::char('C', "change to end of line"),
165        VimDescriptor::char('Y', "yank to end of line"),
166        // ── Paste / undo / redo ───────────────────────────────────────────────
167        VimDescriptor::char('p', "paste after"),
168        VimDescriptor::char('P', "paste before"),
169        VimDescriptor::char('u', "undo"),
170        VimDescriptor::ctrl('r', "redo"),
171        // ── Case / misc edits ─────────────────────────────────────────────────
172        VimDescriptor::char('~', "toggle case at cursor"),
173        VimDescriptor::char('J', "join line below"),
174        VimDescriptor::char('r', "replace character"),
175        VimDescriptor::char('.', "repeat last change"),
176        // ── Indentation ───────────────────────────────────────────────────────
177        VimDescriptor::prefix('>'),
178        VimDescriptor::prefix('<'),
179        VimDescriptor::prefix('='),
180        // ── Motions ───────────────────────────────────────────────────────────
181        VimDescriptor::char('h', "left"),
182        VimDescriptor::char('j', "down"),
183        VimDescriptor::char('k', "up"),
184        VimDescriptor::char('l', "right"),
185        VimDescriptor::char('w', "word forward"),
186        VimDescriptor::char('W', "WORD forward"),
187        VimDescriptor::char('b', "word backward"),
188        VimDescriptor::char('B', "WORD backward"),
189        VimDescriptor::char('e', "word end"),
190        VimDescriptor::char('E', "WORD end"),
191        VimDescriptor::char('0', "line start"),
192        VimDescriptor::char('^', "first non-blank"),
193        VimDescriptor::char('$', "line end"),
194        VimDescriptor::char('G', "file bottom / go to line"),
195        VimDescriptor::char('%', "match bracket"),
196        VimDescriptor::char('H', "viewport top"),
197        VimDescriptor::char('M', "viewport middle"),
198        VimDescriptor::char('L', "viewport bottom"),
199        VimDescriptor::char('{', "paragraph prev"),
200        VimDescriptor::char('}', "paragraph next"),
201        VimDescriptor::char('(', "sentence prev"),
202        VimDescriptor::char(')', "sentence next"),
203        VimDescriptor::char('|', "goto column"),
204        VimDescriptor::char('n', "search next"),
205        VimDescriptor::char('N', "search prev"),
206        VimDescriptor::char('*', "search word forward"),
207        VimDescriptor::char('#', "search word backward"),
208        VimDescriptor::char(';', "repeat find"),
209        VimDescriptor::char(',', "repeat find reverse"),
210        // ── Find char ────────────────────────────────────────────────────────
211        VimDescriptor::char('f', "find char forward"),
212        VimDescriptor::char('F', "find char backward"),
213        VimDescriptor::char('t', "till char forward"),
214        VimDescriptor::char('T', "till char backward"),
215        // ── Prefix keys ───────────────────────────────────────────────────────
216        VimDescriptor::prefix('g'),
217        VimDescriptor::prefix('z'),
218        // ── Marks ────────────────────────────────────────────────────────────
219        VimDescriptor::char('m', "set mark"),
220        VimDescriptor::char('\'', "goto mark (linewise)"),
221        VimDescriptor::char('`', "goto mark (charwise)"),
222        // ── Registers / macros ────────────────────────────────────────────────
223        VimDescriptor::char('"', "select register"),
224        VimDescriptor::char('@', "play macro"),
225        VimDescriptor::char('q', "record macro"),
226        // ── Scroll ───────────────────────────────────────────────────────────
227        VimDescriptor::ctrl('d', "scroll half-page down"),
228        VimDescriptor::ctrl('u', "scroll half-page up"),
229        VimDescriptor::ctrl('f', "scroll full-page down"),
230        VimDescriptor::ctrl('b', "scroll full-page up"),
231        VimDescriptor::ctrl('e', "scroll line down"),
232        VimDescriptor::ctrl('y', "scroll line up"),
233        // ── Number adjust ────────────────────────────────────────────────────
234        VimDescriptor::ctrl('a', "increment number"),
235        VimDescriptor::ctrl('x', "decrement number"),
236        // ── Jump list ────────────────────────────────────────────────────────
237        VimDescriptor::ctrl('o', "jump back"),
238        VimDescriptor::ctrl('i', "jump forward"),
239        // ── Visual mode entry ────────────────────────────────────────────────
240        VimDescriptor::char('v', "enter visual mode"),
241        VimDescriptor::char('V', "enter visual-line mode"),
242        VimDescriptor {
243            key: KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CTRL),
244            desc: Some("enter visual-block mode"),
245        },
246        // ── Search ───────────────────────────────────────────────────────────
247        VimDescriptor::char('/', "search forward"),
248        VimDescriptor::char('?', "search backward"),
249    ]
250}
251
252fn visual_root() -> Vec<VimDescriptor> {
253    vec![
254        // Motions (same as normal) — subset most useful in visual.
255        VimDescriptor::char('h', "left"),
256        VimDescriptor::char('j', "down"),
257        VimDescriptor::char('k', "up"),
258        VimDescriptor::char('l', "right"),
259        VimDescriptor::char('w', "word forward"),
260        VimDescriptor::char('b', "word backward"),
261        VimDescriptor::char('e', "word end"),
262        VimDescriptor::char('0', "line start"),
263        VimDescriptor::char('$', "line end"),
264        VimDescriptor::char('G', "file bottom"),
265        VimDescriptor::char('%', "match bracket"),
266        VimDescriptor::char('n', "search next"),
267        VimDescriptor::char('N', "search prev"),
268        // Visual operators.
269        VimDescriptor::char('d', "delete selection"),
270        VimDescriptor::char('c', "change selection"),
271        VimDescriptor::char('y', "yank selection"),
272        VimDescriptor::char('x', "delete selection"),
273        VimDescriptor::char('s', "substitute selection"),
274        VimDescriptor::char('U', "uppercase selection"),
275        VimDescriptor::char('u', "lowercase selection"),
276        VimDescriptor::char('~', "toggle case selection"),
277        VimDescriptor::char('>', "indent selection"),
278        VimDescriptor::char('<', "outdent selection"),
279        VimDescriptor::char('=', "auto-indent selection"),
280        VimDescriptor::char('o', "swap anchor and cursor"),
281        // Text-object prefix.
282        VimDescriptor::char('i', "inner text object"),
283        VimDescriptor::char('a', "around text object"),
284        // Prefix.
285        VimDescriptor::prefix('z'),
286        // Mark goto.
287        VimDescriptor::char('`', "goto mark (charwise)"),
288        // Comment toggle.
289        VimDescriptor::char('g', "g-prefix (gc = toggle comment)"),
290    ]
291}
292
293fn g_prefix() -> Vec<VimDescriptor> {
294    // Sourced from apply_after_g in hjkl-engine::vim (confirmed against actual dispatch).
295    vec![
296        VimDescriptor::char('g', "go to first line"),
297        VimDescriptor::char('e', "word end backward"),
298        VimDescriptor::char('E', "WORD end backward"),
299        VimDescriptor::char('_', "last non-blank on line"),
300        VimDescriptor::char('M', "middle of line"),
301        VimDescriptor::char('v', "reselect last visual"),
302        VimDescriptor::char('j', "display-line down"),
303        VimDescriptor::char('k', "display-line up"),
304        VimDescriptor::char('U', "uppercase operator"),
305        VimDescriptor::char('u', "lowercase operator"),
306        VimDescriptor::char('~', "toggle case operator"),
307        VimDescriptor::char('q', "reflow operator"),
308        VimDescriptor::char('w', "reflow operator (keep cursor)"),
309        VimDescriptor::char('J', "join without space"),
310        VimDescriptor::char('d', "goto definition"),
311        VimDescriptor::char('i', "goto last insert position"),
312        VimDescriptor::char(';', "goto older change"),
313        VimDescriptor::char(',', "goto newer change"),
314        VimDescriptor::char('*', "search word (partial) forward"),
315        VimDescriptor::char('#', "search word (partial) backward"),
316        VimDescriptor::char('c', "toggle comment operator"),
317        VimDescriptor::char('-', "undo time-travel (earlier)"),
318        VimDescriptor::char('+', "undo time-travel (later)"),
319    ]
320}
321
322fn z_prefix() -> Vec<VimDescriptor> {
323    // Sourced from apply_after_z in hjkl-engine::vim (confirmed against actual dispatch).
324    vec![
325        VimDescriptor::char('z', "center cursor line"),
326        VimDescriptor::char('t', "cursor line to top"),
327        VimDescriptor::char('b', "cursor line to bottom"),
328        VimDescriptor::char('o', "open fold"),
329        VimDescriptor::char('c', "close fold"),
330        VimDescriptor::char('a', "toggle fold"),
331        VimDescriptor::char('R', "open all folds"),
332        VimDescriptor::char('M', "close all folds"),
333        VimDescriptor::char('E', "clear all folds"),
334        VimDescriptor::char('d', "delete fold at cursor"),
335        VimDescriptor::char('f', "create fold (visual/motion)"),
336    ]
337}
338
339fn op_pending_root() -> Vec<VimDescriptor> {
340    // Motion keys available after d/c/y (same parse_motion table as normal mode).
341    // Also includes text-object prefixes (i/a) and g sub-prefix.
342    vec![
343        VimDescriptor::char('h', "left"),
344        VimDescriptor::char('j', "down"),
345        VimDescriptor::char('k', "up"),
346        VimDescriptor::char('l', "right"),
347        VimDescriptor::char('w', "word forward"),
348        VimDescriptor::char('W', "WORD forward"),
349        VimDescriptor::char('b', "word backward"),
350        VimDescriptor::char('B', "WORD backward"),
351        VimDescriptor::char('e', "word end"),
352        VimDescriptor::char('E', "WORD end"),
353        VimDescriptor::char('0', "line start"),
354        VimDescriptor::char('^', "first non-blank"),
355        VimDescriptor::char('$', "line end"),
356        VimDescriptor::char('G', "file bottom"),
357        VimDescriptor::char('%', "match bracket"),
358        VimDescriptor::char('n', "search next"),
359        VimDescriptor::char('N', "search prev"),
360        VimDescriptor::char('f', "find char forward"),
361        VimDescriptor::char('F', "find char backward"),
362        VimDescriptor::char('t', "till char forward"),
363        VimDescriptor::char('T', "till char backward"),
364        VimDescriptor::char('|', "goto column"),
365        // Text-object prefixes.
366        VimDescriptor::char('i', "inner text object"),
367        VimDescriptor::char('a', "around text object"),
368        // g sub-prefix (dgg / dge etc.).
369        VimDescriptor::prefix('g'),
370    ]
371}
372
373// ─── Tests ────────────────────────────────────────────────────────────────────
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // ── Normal root ──────────────────────────────────────────────────────────
380
381    #[test]
382    fn normal_root_count_matches_expected() {
383        let got = children_for(Mode::Normal, &[]);
384        assert_eq!(
385            got.len(),
386            COUNT_NORMAL_ROOT,
387            "normal root count drifted: got {}, expected {}",
388            got.len(),
389            COUNT_NORMAL_ROOT
390        );
391    }
392
393    #[test]
394    fn normal_root_includes_basic_motions() {
395        let got = children_for(Mode::Normal, &[]);
396        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
397        for ch in ['h', 'j', 'k', 'l'] {
398            assert!(
399                keys.contains(&KeyEvent::char(ch)),
400                "normal root missing '{ch}'"
401            );
402        }
403    }
404
405    #[test]
406    fn normal_root_includes_insert_entries() {
407        let got = children_for(Mode::Normal, &[]);
408        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
409        for ch in ['i', 'a', 'I', 'A', 'o', 'O'] {
410            assert!(
411                keys.contains(&KeyEvent::char(ch)),
412                "normal root missing insert entry '{ch}'"
413            );
414        }
415    }
416
417    #[test]
418    fn normal_root_g_and_z_are_prefix_nodes() {
419        let got = children_for(Mode::Normal, &[]);
420        let g = got.iter().find(|d| d.key == KeyEvent::char('g')).unwrap();
421        let z = got.iter().find(|d| d.key == KeyEvent::char('z')).unwrap();
422        assert_eq!(g.desc, None, "g should be a prefix node (desc = None)");
423        assert_eq!(z.desc, None, "z should be a prefix node (desc = None)");
424    }
425
426    #[test]
427    fn normal_root_operator_prefixes_are_prefix_nodes() {
428        let got = children_for(Mode::Normal, &[]);
429        for ch in ['d', 'c', 'y'] {
430            let entry = got
431                .iter()
432                .find(|d| d.key == KeyEvent::char(ch))
433                .unwrap_or_else(|| panic!("normal root missing operator prefix '{ch}'"));
434            assert_eq!(
435                entry.desc, None,
436                "operator '{ch}' should be a prefix node (desc = None)"
437            );
438        }
439    }
440
441    #[test]
442    fn normal_root_has_ctrl_scroll_keys() {
443        let got = children_for(Mode::Normal, &[]);
444        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
445        for ch in ['d', 'u', 'f', 'b', 'e', 'y'] {
446            assert!(
447                keys.contains(&KeyEvent::ctrl(ch)),
448                "normal root missing <C-{ch}>"
449            );
450        }
451    }
452
453    // ── g-prefix ────────────────────────────────────────────────────────────
454
455    #[test]
456    fn g_prefix_count_matches_expected() {
457        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
458        assert_eq!(
459            got.len(),
460            COUNT_G_PREFIX,
461            "g-prefix count drifted: got {}, expected {}",
462            got.len(),
463            COUNT_G_PREFIX
464        );
465    }
466
467    #[test]
468    fn g_prefix_includes_gg() {
469        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
470        let found = got
471            .iter()
472            .any(|d| d.key == KeyEvent::char('g') && d.desc.is_some());
473        assert!(found, "g-prefix missing gg");
474    }
475
476    #[test]
477    fn g_prefix_includes_gj_gk() {
478        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
479        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
480        assert!(keys.contains(&KeyEvent::char('j')), "g-prefix missing gj");
481        assert!(keys.contains(&KeyEvent::char('k')), "g-prefix missing gk");
482    }
483
484    #[test]
485    fn g_prefix_includes_gd() {
486        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
487        let found = got.iter().any(|d| d.key == KeyEvent::char('d'));
488        assert!(found, "g-prefix missing gd (goto definition)");
489    }
490
491    #[test]
492    fn g_prefix_includes_case_operators() {
493        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
494        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
495        for ch in ['U', 'u', '~', 'q'] {
496            assert!(keys.contains(&KeyEvent::char(ch)), "g-prefix missing g{ch}");
497        }
498    }
499
500    // ── z-prefix ────────────────────────────────────────────────────────────
501
502    #[test]
503    fn z_prefix_count_matches_expected() {
504        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
505        assert_eq!(
506            got.len(),
507            COUNT_Z_PREFIX,
508            "z-prefix count drifted: got {}, expected {}",
509            got.len(),
510            COUNT_Z_PREFIX
511        );
512    }
513
514    #[test]
515    fn z_prefix_includes_zz() {
516        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
517        let found = got
518            .iter()
519            .any(|d| d.key == KeyEvent::char('z') && d.desc.is_some());
520        assert!(found, "z-prefix missing zz");
521    }
522
523    #[test]
524    fn z_prefix_includes_zt_zb() {
525        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
526        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
527        assert!(keys.contains(&KeyEvent::char('t')), "z-prefix missing zt");
528        assert!(keys.contains(&KeyEvent::char('b')), "z-prefix missing zb");
529    }
530
531    #[test]
532    fn z_prefix_includes_fold_ops() {
533        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
534        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
535        for ch in ['o', 'c', 'a', 'R', 'M', 'E', 'd', 'f'] {
536            assert!(keys.contains(&KeyEvent::char(ch)), "z-prefix missing z{ch}");
537        }
538    }
539
540    // ── operator-pending prefix ──────────────────────────────────────────────
541
542    #[test]
543    fn op_pending_root_count_matches_expected() {
544        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
545        assert_eq!(
546            got.len(),
547            COUNT_OP_PENDING_ROOT,
548            "op-pending root count drifted: got {}, expected {}",
549            got.len(),
550            COUNT_OP_PENDING_ROOT
551        );
552    }
553
554    #[test]
555    fn op_pending_same_for_d_c_y() {
556        let d = children_for(Mode::Normal, &[KeyEvent::char('d')]);
557        let c = children_for(Mode::Normal, &[KeyEvent::char('c')]);
558        let y = children_for(Mode::Normal, &[KeyEvent::char('y')]);
559        assert_eq!(d, c, "d and c op-pending children should match");
560        assert_eq!(d, y, "d and y op-pending children should match");
561    }
562
563    #[test]
564    fn op_pending_has_text_object_prefixes() {
565        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
566        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
567        assert!(
568            keys.contains(&KeyEvent::char('i')),
569            "op-pending missing 'i' (inner text obj)"
570        );
571        assert!(
572            keys.contains(&KeyEvent::char('a')),
573            "op-pending missing 'a' (around text obj)"
574        );
575    }
576
577    #[test]
578    fn op_pending_has_g_sub_prefix() {
579        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
580        let g = got
581            .iter()
582            .find(|d| d.key == KeyEvent::char('g'))
583            .expect("op-pending missing g sub-prefix");
584        assert_eq!(g.desc, None, "g in op-pending should be a prefix node");
585    }
586
587    // ── Unknown prefix → empty ────────────────────────────────────────────────
588
589    #[test]
590    fn unknown_prefix_returns_empty() {
591        // 'q' has no sub-prefix in the engine (it opens RecordMacroTarget,
592        // which is a one-key pending, not a named prefix).
593        let got = children_for(Mode::Normal, &[KeyEvent::char('q')]);
594        assert!(got.is_empty(), "unknown prefix should return empty vec");
595    }
596
597    #[test]
598    fn insert_mode_always_empty() {
599        assert!(children_for(Mode::Insert, &[]).is_empty());
600        assert!(children_for(Mode::Insert, &[KeyEvent::char('g')]).is_empty());
601    }
602
603    #[test]
604    fn op_pending_mode_root_matches_normal_d_prefix() {
605        let via_normal = children_for(Mode::Normal, &[KeyEvent::char('d')]);
606        let via_op = children_for(Mode::OpPending, &[]);
607        assert_eq!(via_normal, via_op);
608    }
609
610    // ── Visual mode ──────────────────────────────────────────────────────────
611
612    #[test]
613    fn visual_mode_root_non_empty() {
614        let got = children_for(Mode::Visual, &[]);
615        assert!(!got.is_empty(), "visual root should not be empty");
616    }
617
618    #[test]
619    fn visual_mode_z_prefix_same_as_normal() {
620        let vn = children_for(Mode::Visual, &[KeyEvent::char('z')]);
621        let nn = children_for(Mode::Normal, &[KeyEvent::char('z')]);
622        assert_eq!(vn, nn, "visual z-prefix should equal normal z-prefix");
623    }
624}