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 = 21;
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    ]
318}
319
320fn z_prefix() -> Vec<VimDescriptor> {
321    // Sourced from apply_after_z in hjkl-engine::vim (confirmed against actual dispatch).
322    vec![
323        VimDescriptor::char('z', "center cursor line"),
324        VimDescriptor::char('t', "cursor line to top"),
325        VimDescriptor::char('b', "cursor line to bottom"),
326        VimDescriptor::char('o', "open fold"),
327        VimDescriptor::char('c', "close fold"),
328        VimDescriptor::char('a', "toggle fold"),
329        VimDescriptor::char('R', "open all folds"),
330        VimDescriptor::char('M', "close all folds"),
331        VimDescriptor::char('E', "clear all folds"),
332        VimDescriptor::char('d', "delete fold at cursor"),
333        VimDescriptor::char('f', "create fold (visual/motion)"),
334    ]
335}
336
337fn op_pending_root() -> Vec<VimDescriptor> {
338    // Motion keys available after d/c/y (same parse_motion table as normal mode).
339    // Also includes text-object prefixes (i/a) and g sub-prefix.
340    vec![
341        VimDescriptor::char('h', "left"),
342        VimDescriptor::char('j', "down"),
343        VimDescriptor::char('k', "up"),
344        VimDescriptor::char('l', "right"),
345        VimDescriptor::char('w', "word forward"),
346        VimDescriptor::char('W', "WORD forward"),
347        VimDescriptor::char('b', "word backward"),
348        VimDescriptor::char('B', "WORD backward"),
349        VimDescriptor::char('e', "word end"),
350        VimDescriptor::char('E', "WORD end"),
351        VimDescriptor::char('0', "line start"),
352        VimDescriptor::char('^', "first non-blank"),
353        VimDescriptor::char('$', "line end"),
354        VimDescriptor::char('G', "file bottom"),
355        VimDescriptor::char('%', "match bracket"),
356        VimDescriptor::char('n', "search next"),
357        VimDescriptor::char('N', "search prev"),
358        VimDescriptor::char('f', "find char forward"),
359        VimDescriptor::char('F', "find char backward"),
360        VimDescriptor::char('t', "till char forward"),
361        VimDescriptor::char('T', "till char backward"),
362        VimDescriptor::char('|', "goto column"),
363        // Text-object prefixes.
364        VimDescriptor::char('i', "inner text object"),
365        VimDescriptor::char('a', "around text object"),
366        // g sub-prefix (dgg / dge etc.).
367        VimDescriptor::prefix('g'),
368    ]
369}
370
371// ─── Tests ────────────────────────────────────────────────────────────────────
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    // ── Normal root ──────────────────────────────────────────────────────────
378
379    #[test]
380    fn normal_root_count_matches_expected() {
381        let got = children_for(Mode::Normal, &[]);
382        assert_eq!(
383            got.len(),
384            COUNT_NORMAL_ROOT,
385            "normal root count drifted: got {}, expected {}",
386            got.len(),
387            COUNT_NORMAL_ROOT
388        );
389    }
390
391    #[test]
392    fn normal_root_includes_basic_motions() {
393        let got = children_for(Mode::Normal, &[]);
394        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
395        for ch in ['h', 'j', 'k', 'l'] {
396            assert!(
397                keys.contains(&KeyEvent::char(ch)),
398                "normal root missing '{ch}'"
399            );
400        }
401    }
402
403    #[test]
404    fn normal_root_includes_insert_entries() {
405        let got = children_for(Mode::Normal, &[]);
406        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
407        for ch in ['i', 'a', 'I', 'A', 'o', 'O'] {
408            assert!(
409                keys.contains(&KeyEvent::char(ch)),
410                "normal root missing insert entry '{ch}'"
411            );
412        }
413    }
414
415    #[test]
416    fn normal_root_g_and_z_are_prefix_nodes() {
417        let got = children_for(Mode::Normal, &[]);
418        let g = got.iter().find(|d| d.key == KeyEvent::char('g')).unwrap();
419        let z = got.iter().find(|d| d.key == KeyEvent::char('z')).unwrap();
420        assert_eq!(g.desc, None, "g should be a prefix node (desc = None)");
421        assert_eq!(z.desc, None, "z should be a prefix node (desc = None)");
422    }
423
424    #[test]
425    fn normal_root_operator_prefixes_are_prefix_nodes() {
426        let got = children_for(Mode::Normal, &[]);
427        for ch in ['d', 'c', 'y'] {
428            let entry = got
429                .iter()
430                .find(|d| d.key == KeyEvent::char(ch))
431                .unwrap_or_else(|| panic!("normal root missing operator prefix '{ch}'"));
432            assert_eq!(
433                entry.desc, None,
434                "operator '{ch}' should be a prefix node (desc = None)"
435            );
436        }
437    }
438
439    #[test]
440    fn normal_root_has_ctrl_scroll_keys() {
441        let got = children_for(Mode::Normal, &[]);
442        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
443        for ch in ['d', 'u', 'f', 'b', 'e', 'y'] {
444            assert!(
445                keys.contains(&KeyEvent::ctrl(ch)),
446                "normal root missing <C-{ch}>"
447            );
448        }
449    }
450
451    // ── g-prefix ────────────────────────────────────────────────────────────
452
453    #[test]
454    fn g_prefix_count_matches_expected() {
455        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
456        assert_eq!(
457            got.len(),
458            COUNT_G_PREFIX,
459            "g-prefix count drifted: got {}, expected {}",
460            got.len(),
461            COUNT_G_PREFIX
462        );
463    }
464
465    #[test]
466    fn g_prefix_includes_gg() {
467        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
468        let found = got
469            .iter()
470            .any(|d| d.key == KeyEvent::char('g') && d.desc.is_some());
471        assert!(found, "g-prefix missing gg");
472    }
473
474    #[test]
475    fn g_prefix_includes_gj_gk() {
476        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
477        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
478        assert!(keys.contains(&KeyEvent::char('j')), "g-prefix missing gj");
479        assert!(keys.contains(&KeyEvent::char('k')), "g-prefix missing gk");
480    }
481
482    #[test]
483    fn g_prefix_includes_gd() {
484        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
485        let found = got.iter().any(|d| d.key == KeyEvent::char('d'));
486        assert!(found, "g-prefix missing gd (goto definition)");
487    }
488
489    #[test]
490    fn g_prefix_includes_case_operators() {
491        let got = children_for(Mode::Normal, &[KeyEvent::char('g')]);
492        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
493        for ch in ['U', 'u', '~', 'q'] {
494            assert!(keys.contains(&KeyEvent::char(ch)), "g-prefix missing g{ch}");
495        }
496    }
497
498    // ── z-prefix ────────────────────────────────────────────────────────────
499
500    #[test]
501    fn z_prefix_count_matches_expected() {
502        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
503        assert_eq!(
504            got.len(),
505            COUNT_Z_PREFIX,
506            "z-prefix count drifted: got {}, expected {}",
507            got.len(),
508            COUNT_Z_PREFIX
509        );
510    }
511
512    #[test]
513    fn z_prefix_includes_zz() {
514        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
515        let found = got
516            .iter()
517            .any(|d| d.key == KeyEvent::char('z') && d.desc.is_some());
518        assert!(found, "z-prefix missing zz");
519    }
520
521    #[test]
522    fn z_prefix_includes_zt_zb() {
523        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
524        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
525        assert!(keys.contains(&KeyEvent::char('t')), "z-prefix missing zt");
526        assert!(keys.contains(&KeyEvent::char('b')), "z-prefix missing zb");
527    }
528
529    #[test]
530    fn z_prefix_includes_fold_ops() {
531        let got = children_for(Mode::Normal, &[KeyEvent::char('z')]);
532        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
533        for ch in ['o', 'c', 'a', 'R', 'M', 'E', 'd', 'f'] {
534            assert!(keys.contains(&KeyEvent::char(ch)), "z-prefix missing z{ch}");
535        }
536    }
537
538    // ── operator-pending prefix ──────────────────────────────────────────────
539
540    #[test]
541    fn op_pending_root_count_matches_expected() {
542        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
543        assert_eq!(
544            got.len(),
545            COUNT_OP_PENDING_ROOT,
546            "op-pending root count drifted: got {}, expected {}",
547            got.len(),
548            COUNT_OP_PENDING_ROOT
549        );
550    }
551
552    #[test]
553    fn op_pending_same_for_d_c_y() {
554        let d = children_for(Mode::Normal, &[KeyEvent::char('d')]);
555        let c = children_for(Mode::Normal, &[KeyEvent::char('c')]);
556        let y = children_for(Mode::Normal, &[KeyEvent::char('y')]);
557        assert_eq!(d, c, "d and c op-pending children should match");
558        assert_eq!(d, y, "d and y op-pending children should match");
559    }
560
561    #[test]
562    fn op_pending_has_text_object_prefixes() {
563        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
564        let keys: Vec<_> = got.iter().map(|d| d.key).collect();
565        assert!(
566            keys.contains(&KeyEvent::char('i')),
567            "op-pending missing 'i' (inner text obj)"
568        );
569        assert!(
570            keys.contains(&KeyEvent::char('a')),
571            "op-pending missing 'a' (around text obj)"
572        );
573    }
574
575    #[test]
576    fn op_pending_has_g_sub_prefix() {
577        let got = children_for(Mode::Normal, &[KeyEvent::char('d')]);
578        let g = got
579            .iter()
580            .find(|d| d.key == KeyEvent::char('g'))
581            .expect("op-pending missing g sub-prefix");
582        assert_eq!(g.desc, None, "g in op-pending should be a prefix node");
583    }
584
585    // ── Unknown prefix → empty ────────────────────────────────────────────────
586
587    #[test]
588    fn unknown_prefix_returns_empty() {
589        // 'q' has no sub-prefix in the engine (it opens RecordMacroTarget,
590        // which is a one-key pending, not a named prefix).
591        let got = children_for(Mode::Normal, &[KeyEvent::char('q')]);
592        assert!(got.is_empty(), "unknown prefix should return empty vec");
593    }
594
595    #[test]
596    fn insert_mode_always_empty() {
597        assert!(children_for(Mode::Insert, &[]).is_empty());
598        assert!(children_for(Mode::Insert, &[KeyEvent::char('g')]).is_empty());
599    }
600
601    #[test]
602    fn op_pending_mode_root_matches_normal_d_prefix() {
603        let via_normal = children_for(Mode::Normal, &[KeyEvent::char('d')]);
604        let via_op = children_for(Mode::OpPending, &[]);
605        assert_eq!(via_normal, via_op);
606    }
607
608    // ── Visual mode ──────────────────────────────────────────────────────────
609
610    #[test]
611    fn visual_mode_root_non_empty() {
612        let got = children_for(Mode::Visual, &[]);
613        assert!(!got.is_empty(), "visual root should not be empty");
614    }
615
616    #[test]
617    fn visual_mode_z_prefix_same_as_normal() {
618        let vn = children_for(Mode::Visual, &[KeyEvent::char('z')]);
619        let nn = children_for(Mode::Normal, &[KeyEvent::char('z')]);
620        assert_eq!(vn, nn, "visual z-prefix should equal normal z-prefix");
621    }
622}