Skip to main content

lattice_host/
keymap_normal.rs

1//! Normal-mode binding registration + lookup helper.
2//!
3//! Audit slice 8.g (Normal mode) is sub-sliced; this module
4//! handles the 8.g.i deliverable -- the **simple single-key
5//! bindings** that don't depend on App-side state (counts,
6//! pending prefixes, macro recording, operator-pending). The
7//! remaining sub-slices migrate the rest:
8//!
9//! - 8.g.ii -- `g_` / `z_` family.
10//! - 8.g.iii -- operator-pending -> motion / text-object trie
11//!   expansion.
12//! - 8.g.iv -- count accumulator (stays input-side; attaches to
13//!   the resolved invocation).
14//! - 8.g.v -- mark / register / find-char wildcards.
15//! - 8.g.vi -- `<C-w>` window-management sub-tree.
16//!
17//! ## What 8.g.i registers
18//!
19//! - **Motions** (with `<Left>` / `<Down>` / `<Up>` /
20//!   `<Right>` / `<Home>` / `<End>` aliases): `h`, `j`, `k`,
21//!   `l`, `0`, `$`, `^`, `w`, `b`, `e`, `W`, `B`, `E`, `}`,
22//!   `{`, `)`, `(`, `G`. Each binds to
23//!   `CommandInvocation::of(motion.0)` -- the dispatcher
24//!   returns `Action::Invoke(command.clone())`.
25//! - **Viewport jumps**: `H` / `M` / `L`.
26//! - **Mode entry**: `i`, `a`, `o`, `O`, `:`, `v`, `V`, `R`.
27//! - **Paste**: `p`, `P`.
28//! - **Pseudo-operators with built-in target**: `D` (= `d$`),
29//!   `C` (= `c$`), `S` (= `cc`), `Y` (= `yy`), `x` (= delete
30//!   one char right). These bind to the typed
31//!   `CommandInvocation` directly (no operator-pending state),
32//!   so they stay single-chord even though they conceptually
33//!   compose an operator with a target.
34//! - **Misc single-chord**: `J` (line-join), `;` / `,`
35//!   (find-repeat), `~` (toggle case), `K` (LSP hover), `/` /
36//!   `?` (enter search), `n` / `N` / `*` / `#` (search nav),
37//!   `%` (match bracket), `u` (undo), `.` (dot-repeat), `-`
38//!   (oil parent dir), `<Tab>` (jump-list forward), `<PageUp>`
39//!   / `<PageDown>` (count-10 line-down/up).
40//!
41//! ## What 8.g.i leaves in `input::translate_normal`
42//!
43//! - Pending-state resolution (`AfterCtrlW`, `AfterG`,
44//!   `AfterOperator`, `AfterFindChar`, `AfterTextObject`,
45//!   `AfterZ`, `AfterSetMark`, `AfterJumpMark{Line,Exact}`,
46//!   `AfterRegister`, `AfterMacroStart`, `AfterMacroPlay`).
47//! - `<C-...>` chords (8.g.vi).
48//! - Numeric prefix accumulator.
49//! - Operator-leading single keys (`d` / `c` / `y` / `>` / `<`)
50//!   -- they set `Pending::AfterOperator` (8.g.iii).
51//! - Pending-prefix single keys (`g`, `z`, `q`, `@`, `"`, `m`,
52//!   `'`, `` ` ``, `f`, `F`, `t`, `T`).
53//!
54//! `input::translate_normal` now starts with a `lookup_normal`
55//! call against the registry; on `Some(action)` it returns
56//! immediately, on `None` it falls through to the legacy
57//! match arm. As subsequent sub-slices migrate more bindings,
58//! the legacy match arm shrinks; 8.g.vi closes it out.
59//!
60//! ## Modifier transparency
61//!
62//! `dispatch_normal`'s `lookup_normal` tries the chord AS PRESSED
63//! first and only falls back to a form with `ALT` and `SUPER`
64//! stripped (OS.0c, `lookup_normal_chord`); `CTRL` and `SHIFT` are
65//! preserved either way. It stripped unconditionally until OS.0c,
66//! which made an ALT-bearing Normal binding — legal over the
67//! `modes` WIT seam, and what org's `<M-CR>` and meta-arrows need —
68//! impossible to fire.
69//! `KeyChord::from_event` already strips redundant SHIFT on
70//! bare letters, so `(Char('H'), NONE)` is the canonical chord
71//! for both `H` and `<S-h>` -- the trie only needs one entry
72//! per uppercase letter. CTRL-bearing chords are filtered by
73//! the legacy CTRL guard above the lookup call site, so the
74//! trie never sees them in slice 8.g.i (they migrate in
75//! 8.g.vi together with the `<C-w>` sub-tree).
76
77use std::collections::{HashMap, HashSet};
78use std::sync::Arc;
79
80use lattice_grammar::SourceLocation;
81use lattice_grammar::Target;
82use lattice_grammar::args::{ArgValue, Args};
83use lattice_grammar::builtins::Builtins;
84use lattice_grammar::command::CommandInvocation;
85use lattice_protocol::ids::CommandId;
86use lattice_syntax::SyntaxMotionIds;
87use lattice_syntax::SyntaxTextObjectIds;
88
89use crate::action::{Action, FindKind};
90use crate::actions::ActionIds;
91use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
92use crate::keymap::BindingMode;
93use crate::keymap_registry::KeymapHandle;
94use crate::keymap_trie::{BoundCommand, ChordPattern, KeymapLayer, LookupResult};
95
96/// Register the slice 8.g.i Normal-mode catalog into the
97/// supplied handle's `Builtin` layer. The legacy
98/// `input::translate_normal` keeps its match arm for the
99/// bindings not yet in this catalog.
100///
101/// `actions` is the App-side action ID table (slice 8.i; see
102/// `docs/dev/notes/8i-approach.md`). Bindings that historically fired an
103/// `Action::Foo` directly via `bind_legacy` get migrated to
104/// `bind(... CommandInvocation::of(actions.foo) ...)` as the
105/// per-batch slices land; the rest stay on the bridge until
106/// their batch's turn.
107pub fn register_normal_bindings(
108    handle: &KeymapHandle,
109    builtins: &Builtins,
110    actions: &ActionIds,
111    syntax_textobjects: &SyntaxTextObjectIds,
112    syntax_motions: &SyntaxMotionIds,
113) {
114    let layer = KeymapLayer::Builtin;
115    let mode = BindingMode::Normal;
116
117    // ---- Motions: typed CommandInvocation, no count baked in.
118    // Sourced from the shared `motion_rows` table so Normal /
119    // operator-pending / Visual never drift -- a motion added there
120    // lights up in all three surfaces.
121    for (chord, motion) in motion_rows(builtins) {
122        handle.bind(
123            layer,
124            mode,
125            std::slice::from_ref(&chord),
126            CommandInvocation::of(motion.0),
127            source(),
128        );
129    }
130
131    // ---- TSM.4: the sixteen tree-sitter structural motions
132    // (`]f`/`[f`/`]F`/`[F`, `]c`/`[c`/`]C`/`[C`, `]a`/`[a`/`]A`/`[A`,
133    // `]l`/`[l`/`]L`/`[L`). Sourced from the shared `syntax_motion_rows`
134    // table so Normal / operator-pending / Visual never drift, exactly
135    // as `motion_rows` above. Each row is a full two-key sequence.
136    for (seq, motion) in syntax_motion_rows(syntax_motions) {
137        handle.bind(layer, mode, &seq, CommandInvocation::of(motion.0), source());
138    }
139
140    // ---- Pseudo-operators: typed invocations with built-in
141    // ---- targets / ranges, no follow-up motion needed.
142    // `Y` -> yy (linewise yank).
143    handle.bind(
144        layer,
145        mode,
146        &[lit_char('Y')],
147        CommandInvocation::of(builtins.yank.0).with_range(lattice_grammar::Range::CurrentLine),
148        source(),
149    );
150    // `x` -> delete one char to the right.
151    handle.bind(
152        layer,
153        mode,
154        &[lit_char('x')],
155        CommandInvocation::of(builtins.delete.0)
156            .with_target(Target::Motion(builtins.char_right, Args::None)),
157        source(),
158    );
159    // `D` = `d$` (delete to end of line).
160    handle.bind(
161        layer,
162        mode,
163        &[lit_char('D')],
164        CommandInvocation::of(builtins.delete.0)
165            .with_target(Target::Motion(builtins.line_end, Args::None)),
166        source(),
167    );
168    // `C` = `c$` (change to end of line).
169    handle.bind(
170        layer,
171        mode,
172        &[lit_char('C')],
173        CommandInvocation::of(builtins.change.0)
174            .with_target(Target::Motion(builtins.line_end, Args::None)),
175        source(),
176    );
177    // `S` = `cc` (substitute line).
178    handle.bind(
179        layer,
180        mode,
181        &[lit_char('S')],
182        CommandInvocation::of(builtins.change.0).with_range(lattice_grammar::Range::CurrentLine),
183        source(),
184    );
185
186    // ---- Legacy-action bindings (no `CommandInvocation` peer
187    // ---- today; bridge stays until 8.i).
188
189    // VM.3f: `H` / `M` / `L` are MOTIONS, as in vim (`dL`, `yH`, `vM` all work
190    // there; checked in 9.2). As actions they composed with nothing and were
191    // dead in Visual. The action ids stay registered for the WIT
192    // `JumpViewport` effect.
193    for (key, motion) in [
194        ('H', builtins.viewport_top),
195        ('M', builtins.viewport_middle),
196        ('L', builtins.viewport_bottom),
197    ] {
198        handle.bind(
199            layer,
200            mode,
201            &[lit_char(key)],
202            CommandInvocation::of(motion.0),
203            source(),
204        );
205    }
206
207    // Paste.
208    handle.bind(
209        layer,
210        mode,
211        &[lit_char('p')],
212        CommandInvocation::of(actions.paste_after),
213        source(),
214    );
215    handle.bind(
216        layer,
217        mode,
218        &[lit_char('P')],
219        CommandInvocation::of(actions.paste_before),
220        source(),
221    );
222
223    // Mode entry.
224    handle.bind(
225        layer,
226        mode,
227        &[lit_char('i')],
228        CommandInvocation::of(actions.enter_mode_insert),
229        source(),
230    );
231    handle.bind(
232        layer,
233        mode,
234        &[lit_char('a')],
235        CommandInvocation::of(actions.enter_append),
236        source(),
237    );
238    handle.bind(
239        layer,
240        mode,
241        &[lit_char('I')],
242        CommandInvocation::of(actions.enter_insert_first_non_blank),
243        source(),
244    );
245    handle.bind(
246        layer,
247        mode,
248        &[lit_char('A')],
249        CommandInvocation::of(actions.enter_append_end_of_line),
250        source(),
251    );
252    handle.bind(
253        layer,
254        mode,
255        &[lit_char('o')],
256        CommandInvocation::of(actions.open_line_below),
257        source(),
258    );
259    handle.bind(
260        layer,
261        mode,
262        &[lit_char('O')],
263        CommandInvocation::of(actions.open_line_above),
264        source(),
265    );
266    handle.bind(
267        layer,
268        mode,
269        &[lit_char(':')],
270        CommandInvocation::of(actions.enter_command_line),
271        source(),
272    );
273    handle.bind(
274        layer,
275        mode,
276        &[lit_char('v')],
277        CommandInvocation::of(actions.enter_visual_charwise),
278        source(),
279    );
280    handle.bind(
281        layer,
282        mode,
283        &[lit_char('V')],
284        CommandInvocation::of(actions.enter_visual_linewise),
285        source(),
286    );
287    handle.bind(
288        layer,
289        mode,
290        &[lit_char('R')],
291        CommandInvocation::of(actions.enter_mode_replace),
292        source(),
293    );
294
295    // Misc single-chord.
296    handle.bind(
297        layer,
298        mode,
299        &[lit_char('J')],
300        CommandInvocation::of(actions.join_lines_with_space),
301        source(),
302    );
303    // VM.3c: `;` / `,` are the MOTIONS, not `action:find-repeat*`. As actions
304    // they could not take an operator (`d;`) and VM.1's derivation could not
305    // mirror them into Visual, so both were unreachable from a selection.
306    // The action ids stay registered — `AppEffect::FindRepeat` crosses the
307    // plugin boundary.
308    handle.bind(
309        layer,
310        mode,
311        &[lit_char(';')],
312        CommandInvocation::of(builtins.find_repeat.0),
313        source(),
314    );
315    handle.bind(
316        layer,
317        mode,
318        &[lit_char(',')],
319        CommandInvocation::of(builtins.find_repeat_reverse.0),
320        source(),
321    );
322    handle.bind(
323        layer,
324        mode,
325        &[lit_char('~')],
326        CommandInvocation::of(actions.toggle_case_at_cursor),
327        source(),
328    );
329    handle.bind(
330        layer,
331        mode,
332        &[lit_char('/')],
333        CommandInvocation::of(actions.enter_search_forward),
334        source(),
335    );
336    handle.bind(
337        layer,
338        mode,
339        &[lit_char('?')],
340        CommandInvocation::of(actions.enter_search_backward),
341        source(),
342    );
343    // VM.3d-2: `n` / `N` / `*` / `#` are MOTIONS, as in vim (`dn`, `yN`, `vn`,
344    // `d*` all work there; checked in 9.2). As actions they composed with
345    // nothing and were dead in Visual. Normal is the only mode named here: the
346    // keymap's mirror puts them in Visual, and `expand_grammar_rows` gives them
347    // their operator rows. The action ids stay registered for the WIT effects.
348    // VM.3d-3: `g*` / `g#`, the partial-word peers. Two keys rather than one,
349    // so they are bound below rather than in the single-char loop; `g` is a
350    // prefix with many chords, so extending it is safe (a TERMINAL `g` would
351    // kill every longer one).
352    for (key, motion) in [
353        ('*', builtins.search_word_forward_partial),
354        ('#', builtins.search_word_backward_partial),
355    ] {
356        handle.bind(
357            layer,
358            mode,
359            &[lit_char('g'), lit_char(key)],
360            CommandInvocation::of(motion.0),
361            source(),
362        );
363    }
364    for (key, motion) in [
365        ('n', builtins.search_next),
366        ('N', builtins.search_prev),
367        ('*', builtins.search_word_forward),
368        ('#', builtins.search_word_backward),
369    ] {
370        handle.bind(
371            layer,
372            mode,
373            &[lit_char(key)],
374            CommandInvocation::of(motion.0),
375            source(),
376        );
377    }
378    // VM.3b: `%` is the MOTION, not `action:match-bracket`. Binding the
379    // action left `d%` and `v%` unbound — an action does not compose with an
380    // operator and VM.1's derivation only mirrors motions into Visual. The
381    // action id stays registered because `AppEffect::MatchBracket` crosses the
382    // plugin boundary and a guest may still emit it.
383    handle.bind(
384        layer,
385        mode,
386        &[lit_char('%')],
387        CommandInvocation::of(builtins.match_pair.0),
388        source(),
389    );
390    handle.bind(
391        layer,
392        mode,
393        &[lit_char('u')],
394        CommandInvocation::of(actions.undo),
395        source(),
396    );
397    handle.bind(
398        layer,
399        mode,
400        &[lit_char('.')],
401        CommandInvocation::of(actions.repeat_last_change),
402        source(),
403    );
404    // `-` -- open oil for the parent directory of the current buffer's file,
405    // or navigate up one directory from within an oil buffer.
406    handle.bind(
407        layer,
408        mode,
409        &[lit_char('-')],
410        CommandInvocation::of(actions.oil_navigate_up),
411        source(),
412    );
413    // Specials.
414    handle.bind(
415        layer,
416        mode,
417        &[lit_special(SpecialKey::Tab)],
418        CommandInvocation::of(actions.jump_history_forward),
419        source(),
420    );
421    // PageDown / PageUp -- count-10 line-down / line-up. These
422    // bake the count into the invocation directly (no
423    // pending-count interaction); legacy parity preserved.
424    handle.bind(
425        layer,
426        mode,
427        &[lit_special(SpecialKey::PageDown)],
428        CommandInvocation::of(builtins.line_down.0).with_count(lattice_grammar::command::Count(10)),
429        source(),
430    );
431    handle.bind(
432        layer,
433        mode,
434        &[lit_special(SpecialKey::PageUp)],
435        CommandInvocation::of(builtins.line_up.0).with_count(lattice_grammar::command::Count(10)),
436        source(),
437    );
438    // Issue #29 slice 3 (2026-05-22): tab navigation aliases.
439    // `<C-PageDown>` = next tab; `<C-PageUp>` = previous tab.
440    // Standard GUI/terminal binding many users expect alongside
441    // `gt` / `gT`.
442    handle.bind(
443        layer,
444        mode,
445        &[ChordPattern::Literal(KeyChord {
446            key: KeyKind::Special(SpecialKey::PageDown),
447            mods: KeyMods::CTRL,
448        })],
449        CommandInvocation::of(actions.next_tab),
450        source(),
451    );
452    handle.bind(
453        layer,
454        mode,
455        &[ChordPattern::Literal(KeyChord {
456            key: KeyKind::Special(SpecialKey::PageUp),
457            mods: KeyMods::CTRL,
458        })],
459        CommandInvocation::of(actions.prev_tab),
460        source(),
461    );
462
463    // ---- Slice 8.g.ii: `g_` family.
464    //
465    // `[g]` itself stays a partial trie node -- no terminal
466    // binding here -- so lookup of `[g]` returns
467    // `LookupResult::Partial`, which `lookup_normal`
468    // translates into `SetPending(Pending::AfterG)`. The
469    // second keystroke arrives with `pending = AfterG`; the
470    // App's `Pending::AfterG` arm in `input::translate_normal`
471    // calls `lookup_normal_with_prefix(handle, &[g_chord], event)`
472    // to walk `[g, X]` against the same trie.
473    let g = lit_char('g');
474
475    handle.bind(
476        layer,
477        mode,
478        &[g.clone(), lit_char('g')],
479        CommandInvocation::of(builtins.goto_first_line.0),
480        source(),
481    );
482    // RF.2: `gq` / `gw` get NO depth-2 terminal binding here, and that
483    // omission is deliberate.
484    //
485    // A node with a terminal binding resolves as `Bound` before the walk
486    // descends, so binding `[g, q]` to an operator-prefix action KILLS
487    // every longer chord under it: `gqq`, `gqap`, `gqi(` all became
488    // unreachable, silently, while the trie still reported them as
489    // bound. The operator-pending cross-product below
490    // (`register_operator_bindings`) is what makes `gq{motion}` work,
491    // and it needs `[g, q]` to stay an internal node.
492    //
493    // Found by a test that pressed the keys rather than invoking the
494    // operator — the trie lookup for `gqq` answered `Bound` the whole
495    // time, so nothing short of a real keystroke would have caught it.
496    // See `a-bound-prefix-kills-its-longer-chords`.
497    handle.bind(
498        layer,
499        mode,
500        &[g.clone(), lit_char('U')],
501        CommandInvocation::of(actions.absorb_operator_upper),
502        source(),
503    );
504    handle.bind(
505        layer,
506        mode,
507        &[g.clone(), lit_char('u')],
508        CommandInvocation::of(actions.absorb_operator_lower),
509        source(),
510    );
511    handle.bind(
512        layer,
513        mode,
514        &[g.clone(), lit_char('~')],
515        CommandInvocation::of(actions.absorb_operator_toggle_case),
516        source(),
517    );
518    // `g/` (Lattice extension): the project-search operator -- the global
519    // counterpart of buffer search `/`. Armed like the case operators; the
520    // following motion / text object supplies the query span.
521    handle.bind(
522        layer,
523        mode,
524        &[g.clone(), lit_char('/')],
525        CommandInvocation::of(actions.absorb_operator_search),
526        source(),
527    );
528    handle.bind(
529        layer,
530        mode,
531        &[g.clone(), lit_char('v')],
532        CommandInvocation::of(actions.reselect_last_visual),
533        source(),
534    );
535    // SN.3d Select-mode entry: `gh` charwise, `gH` linewise,
536    // `g<C-h>` blockwise — the Select analogues of `v` / `V` / `<C-v>`.
537    // CTRL is preserved by `normalize_for_normal_lookup`, so
538    // `g<C-h>` does not collide with `gh`.
539    handle.bind(
540        layer,
541        mode,
542        &[g.clone(), lit_char('h')],
543        CommandInvocation::of(actions.enter_select_charwise),
544        source(),
545    );
546    handle.bind(
547        layer,
548        mode,
549        &[g.clone(), lit_char('H')],
550        CommandInvocation::of(actions.enter_select_linewise),
551        source(),
552    );
553    handle.bind(
554        layer,
555        mode,
556        &[g.clone(), lit(KeyChord::ctrl('h'))],
557        CommandInvocation::of(actions.enter_select_blockwise),
558        source(),
559    );
560    // Issue #29 (2026-05-22): `gt` next tab, `gT` previous tab.
561    // For `{N}gt` (absolute tab target), the count is picked
562    // up via the existing chord-count prefix mechanism — the
563    // handler `do_goto_tab` reads the count off the dispatched
564    // action's payload (set by the count-override hook in
565    // dispatch.rs when `count.is_some()`).
566    handle.bind(
567        layer,
568        mode,
569        &[g.clone(), lit_char('t')],
570        CommandInvocation::of(actions.next_tab),
571        source(),
572    );
573    handle.bind(
574        layer,
575        mode,
576        &[g.clone(), lit_char('T')],
577        CommandInvocation::of(actions.prev_tab),
578        source(),
579    );
580    handle.bind(
581        layer,
582        mode,
583        &[g.clone(), lit_char('J')],
584        CommandInvocation::of(actions.join_lines_bare),
585        source(),
586    );
587    handle.bind(
588        layer,
589        mode,
590        &[g.clone(), lit_char(';')],
591        CommandInvocation::of(actions.walk_mark_history_back),
592        source(),
593    );
594    handle.bind(
595        layer,
596        mode,
597        &[g.clone(), lit_char(',')],
598        CommandInvocation::of(actions.walk_mark_history_forward),
599        source(),
600    );
601    // CM.2 / CM.7 (2026-07-22): the error navigation chord set.
602    // Builtin grammar (universal navigation over a core substrate, like
603    // `<C-o>`/`<C-i>` over the jump ring), NOT a mode keymap. On an empty
604    // list they echo `no error list` (no diagnostic fallback —
605    // diagnostics are owned by `]d`/`[d`). `]q` / `[q` are PREFIXES here (`]qq`/`]qf`,
606    // `[qq`/`[qf`), so neither is bound directly; the capital `]Q`/`[Q`
607    // are the list extremes.
608    //
609    //   [Q first    ]Q last
610    //   [qq prev     ]qq next
611    //   [qf prevfile ]qf nextfile
612    handle.bind(
613        layer,
614        mode,
615        &[lit_char('['), lit_char('Q')],
616        CommandInvocation::of(actions.error_first),
617        source(),
618    );
619    handle.bind(
620        layer,
621        mode,
622        &[lit_char(']'), lit_char('Q')],
623        CommandInvocation::of(actions.error_last),
624        source(),
625    );
626    handle.bind(
627        layer,
628        mode,
629        &[lit_char('['), lit_char('q'), lit_char('q')],
630        CommandInvocation::of(actions.error_prev),
631        source(),
632    );
633    handle.bind(
634        layer,
635        mode,
636        &[lit_char(']'), lit_char('q'), lit_char('q')],
637        CommandInvocation::of(actions.error_next),
638        source(),
639    );
640    handle.bind(
641        layer,
642        mode,
643        &[lit_char('['), lit_char('q'), lit_char('f')],
644        CommandInvocation::of(actions.error_prev_file),
645        source(),
646    );
647    handle.bind(
648        layer,
649        mode,
650        &[lit_char(']'), lit_char('q'), lit_char('f')],
651        CommandInvocation::of(actions.error_next_file),
652        source(),
653    );
654    // W.6: display-line motions (soft-wrap aware).
655    handle.bind(
656        layer,
657        mode,
658        &[g.clone(), lit_char('j')],
659        CommandInvocation::of(actions.display_line_down),
660        source(),
661    );
662    handle.bind(
663        layer,
664        mode,
665        &[g.clone(), lit_special(SpecialKey::Down)],
666        CommandInvocation::of(actions.display_line_down),
667        source(),
668    );
669    handle.bind(
670        layer,
671        mode,
672        &[g.clone(), lit_char('k')],
673        CommandInvocation::of(actions.display_line_up),
674        source(),
675    );
676    handle.bind(
677        layer,
678        mode,
679        &[g.clone(), lit_special(SpecialKey::Up)],
680        CommandInvocation::of(actions.display_line_up),
681        source(),
682    );
683    handle.bind(
684        layer,
685        mode,
686        &[g.clone(), lit_char('0')],
687        CommandInvocation::of(actions.display_line_start),
688        source(),
689    );
690    handle.bind(
691        layer,
692        mode,
693        &[g.clone(), lit_char('$')],
694        CommandInvocation::of(actions.display_line_end),
695        source(),
696    );
697
698    // ---- Slice 8.g.ii: `z_` family.
699    //
700    // Same pattern: `[z]` is a partial trie node;
701    // `lookup_normal` converts it to
702    // `SetPending(Pending::AfterZ)`.
703    let z = lit_char('z');
704    // VM.3h: the `z` family works in Visual, as in vim. Two binds rather than
705    // `bind_modes`, which rebuilds the merged trie per call. Not Select: `z`
706    // is printable there and must overtype. The fold commands decide for
707    // themselves what a selection means (range for `zo`/`zc`/`zd`/`zO`/`zC`/
708    // `zD`, the cursor for `za`); scrolls and jumps act at the cursor, and a
709    // jump extends the selection like any cursor move.
710    let bind_nv = |path: &[ChordPattern], action: lattice_grammar::CommandId| {
711        for m in [mode, BindingMode::Visual] {
712            handle.bind(layer, m, path, CommandInvocation::of(action), source());
713        }
714    };
715
716    // Center-cursor scrolls. `zz` and `z.` both center.
717    bind_nv(&[z.clone(), lit_char('z')], actions.scroll_cursor_to_center);
718    bind_nv(&[z.clone(), lit_char('.')], actions.scroll_cursor_to_center);
719    // Top-of-viewport scrolls. `zt` and `z<CR>` both align top.
720    bind_nv(&[z.clone(), lit_char('t')], actions.scroll_cursor_to_top);
721    bind_nv(
722        &[z.clone(), lit_special(SpecialKey::Enter)],
723        actions.scroll_cursor_to_top,
724    );
725    // Bottom-of-viewport scrolls. `zb` and `z-` both align bottom.
726    bind_nv(&[z.clone(), lit_char('b')], actions.scroll_cursor_to_bottom);
727    bind_nv(&[z.clone(), lit_char('-')], actions.scroll_cursor_to_bottom);
728
729    // HS.2: horizontal scroll (wrap off). zl/zh scroll [count]
730    // columns, zL/zH half the body width, zs/ze put the cursor's
731    // column at the left / right edge.
732    bind_nv(&[z.clone(), lit_char('l')], actions.h_scroll_right);
733    bind_nv(&[z.clone(), lit_char('h')], actions.h_scroll_left);
734    bind_nv(&[z.clone(), lit_char('L')], actions.h_scroll_half_right);
735    bind_nv(&[z.clone(), lit_char('H')], actions.h_scroll_half_left);
736    bind_nv(
737        &[z.clone(), lit_char('s')],
738        actions.h_scroll_cursor_left_edge,
739    );
740    bind_nv(
741        &[z.clone(), lit_char('e')],
742        actions.h_scroll_cursor_right_edge,
743    );
744
745    // Folds.
746    // `zf` is an operator (VM.3h), registered with the others below. The action
747    // it used to bind here required Visual but was bound in Normal only, so it
748    // could never succeed.
749    bind_nv(&[z.clone(), lit_char('o')], actions.open_fold_at_cursor);
750    bind_nv(&[z.clone(), lit_char('c')], actions.close_fold_at_cursor);
751    bind_nv(&[z.clone(), lit_char('a')], actions.toggle_fold_at_cursor);
752    bind_nv(&[z.clone(), lit_char('R')], actions.open_all_folds);
753    bind_nv(&[z.clone(), lit_char('M')], actions.close_all_folds);
754    // org-cycle: `z<Space>` cycles the fold under the cursor
755    // (FOLDED→CHILDREN→SUBTREE); `z<Tab>` cycles the whole buffer
756    // (OVERVIEW→CONTENTS→SHOW-ALL). Both also reachable as `:fold-cycle` /
757    // `:fold-cycle-global`. `<Tab>` here is a `z`-prefixed chord, distinct
758    // from the bare `<Tab>` jump-list-forward binding.
759    bind_nv(&[z.clone(), lit_char(' ')], actions.cycle_fold_at_cursor);
760    bind_nv(
761        &[z.clone(), lit_special(SpecialKey::Tab)],
762        actions.cycle_folds_global,
763    );
764    // `zp`: go to the parent heading (one level up the fold hierarchy) —
765    // emacs `outline-up-heading`. Distinct from `zj`/`zk` (next/prev fold edge).
766    bind_nv(&[z.clone(), lit_char('p')], actions.goto_parent_fold);
767    bind_nv(&[z.clone(), lit_char('d')], actions.delete_fold_at_cursor);
768    // VM.3h: vim's recursive fold commands, previously unbound.
769    bind_nv(&[z.clone(), lit_char('O')], actions.open_folds_recursively);
770    bind_nv(&[z.clone(), lit_char('C')], actions.close_folds_recursively);
771    bind_nv(
772        &[z.clone(), lit_char('D')],
773        actions.delete_folds_recursively,
774    );
775    // VM.3i: `zj` / `zk` are motions, so Normal is the only mode named here:
776    // the keymap's mirror puts them in Visual (not Select, `z` is typed text),
777    // and `expand_grammar_rows` gives them `dzj` / `yzk`.
778    for (key, motion) in [
779        ('j', builtins.goto_next_fold),
780        ('k', builtins.goto_prev_fold),
781    ] {
782        handle.bind(
783            layer,
784            mode,
785            &[z.clone(), lit_char(key)],
786            CommandInvocation::of(motion.0),
787            source(),
788        );
789    }
790    bind_nv(&[z, lit_char('i')], actions.toggle_fold_enable);
791
792    // ---- Slice 8.g.iii: operator-pending resolution.
793    //
794    // Each operator gets the same target / doubled / text-object /
795    // find-char paths registered under its primary chord(s). The
796    // five "single-chord" operators register under their own chord
797    // (`[d]`, `[c]`, `[y]`, `[>]`, `[<]`) plus a terminal
798    // `SetPending(AfterOperator(op))` at depth 1. The case
799    // operators (`upper` / `lower` / `toggle_case`) ride under the
800    // `g` prefix at depth 2 (`[g, U]`, `[g, u]`, `[g, ~]`); 8.g.ii
801    // already wired their depth-2 terminal `SetPending` bindings,
802    // so this slice just extends the path with depth-3 (motion /
803    // doubled / text-object pending / find-char pending) and
804    // depth-4 (text-object resolution) entries.
805    register_operator_bindings(
806        handle,
807        &[lit_char('d')],
808        builtins.delete,
809        Some(ChordPattern::Literal(KeyChord::char('d'))),
810        builtins,
811        syntax_textobjects,
812        syntax_motions,
813        false,
814    );
815    register_operator_bindings(
816        handle,
817        &[lit_char('c')],
818        builtins.change,
819        Some(ChordPattern::Literal(KeyChord::char('c'))),
820        builtins,
821        syntax_textobjects,
822        syntax_motions,
823        false,
824    );
825    register_operator_bindings(
826        handle,
827        &[lit_char('y')],
828        builtins.yank,
829        Some(ChordPattern::Literal(KeyChord::char('y'))),
830        builtins,
831        syntax_textobjects,
832        syntax_motions,
833        false,
834    );
835    register_operator_bindings(
836        handle,
837        &[lit_char('>')],
838        builtins.indent_right,
839        Some(ChordPattern::Literal(KeyChord::char('>'))),
840        builtins,
841        syntax_textobjects,
842        syntax_motions,
843        false,
844    );
845    register_operator_bindings(
846        handle,
847        &[lit_char('<')],
848        builtins.indent_left,
849        Some(ChordPattern::Literal(KeyChord::char('<'))),
850        builtins,
851        syntax_textobjects,
852        syntax_motions,
853        false,
854    );
855    // IN.7: vim's `=` -- reindent. Registered exactly like `>` / `<`
856    // so it composes with every motion and text object, and picks up
857    // the doubled `==` current-line form from the same helper.
858    register_operator_bindings(
859        handle,
860        &[lit_char('=')],
861        builtins.reindent,
862        Some(ChordPattern::Literal(KeyChord::char('='))),
863        builtins,
864        syntax_textobjects,
865        syntax_motions,
866        false,
867    );
868    // RF.2: reflow. TWO prefixes, ONE operator -- `gq` and `gw` are
869    // the same verb, so both get the full operator-pending
870    // cross-product and `gq{motion}` / `gw{motion}` / `gqi{obj}` all
871    // compose.
872    //
873    // The doubled forms are `gqq` and `gww`, NOT the mixed `gqw` /
874    // `gwq`. Zed collapses all four, and that is the one place this
875    // does not follow it: `gqw` is "reflow over the `w` motion" in vim
876    // and composes like every other operator+motion pair. Spending a
877    // composition to gain a second spelling of `gqq` is a bad trade
878    // against paramount #3, and nobody asked for the second spelling.
879    for (prefix_char, doubled) in [('q', 'q'), ('w', 'w')] {
880        register_operator_bindings(
881            handle,
882            &[lit_char('g'), lit_char(prefix_char)],
883            builtins.reflow,
884            Some(ChordPattern::Literal(KeyChord::char(doubled))),
885            builtins,
886            syntax_textobjects,
887            syntax_motions,
888            false,
889        );
890    }
891    // RF.6: `g=` — the operator form of `:format`. Not a vim chord; it
892    // exists because `:format` had no operator shape, which is why
893    // people reach for `gq` and are disappointed. Doubled form `g==`.
894    //
895    // Like `gq` / `gw` above, `[g, =]` gets NO depth-2 terminal — a
896    // terminal there would resolve before the walk descends and kill
897    // `g==`, `g=ap`, `g=i{` silently.
898    register_operator_bindings(
899        handle,
900        &[lit_char('g'), lit_char('=')],
901        builtins.reformat,
902        Some(ChordPattern::Literal(KeyChord::char('='))),
903        builtins,
904        syntax_textobjects,
905        syntax_motions,
906        false,
907    );
908    // Case operators -- prefix is the two-key sequence registered
909    // at slice 8.g.ii. Their doubled forms (`gUU` / `guu` / `g~~`)
910    // operate on the current line.
911    register_operator_bindings(
912        handle,
913        &[lit_char('g'), lit_char('U')],
914        builtins.upper,
915        Some(ChordPattern::Literal(KeyChord::char('U'))),
916        builtins,
917        syntax_textobjects,
918        syntax_motions,
919        false,
920    );
921    register_operator_bindings(
922        handle,
923        &[lit_char('g'), lit_char('u')],
924        builtins.lower,
925        Some(ChordPattern::Literal(KeyChord::char('u'))),
926        builtins,
927        syntax_textobjects,
928        syntax_motions,
929        false,
930    );
931    register_operator_bindings(
932        handle,
933        &[lit_char('g'), lit_char('~')],
934        builtins.toggle_case,
935        Some(ChordPattern::Literal(KeyChord::char('~'))),
936        builtins,
937        syntax_textobjects,
938        syntax_motions,
939        false,
940    );
941    // `g/` search operator (Lattice extension) -- same operator-pending
942    // cross-product as the case operators, so `g/{motion}` / `g/i{obj}`
943    // resolve. The doubled form `g//` runs it linewise on the current
944    // line (like `gUU`).
945    register_operator_bindings(
946        handle,
947        &[lit_char('g'), lit_char('/')],
948        builtins.search,
949        Some(ChordPattern::Literal(KeyChord::char('/'))),
950        builtins,
951        syntax_textobjects,
952        syntax_motions,
953        false,
954    );
955
956    // VM.3h: `zf` — vim's fold operator. `zf{motion}`, `zfip`, `zff{char}`, and
957    // `{Visual}zf` from the same call. No doubled form (see `doubled_self`).
958    register_operator_bindings(
959        handle,
960        &[lit_char('z'), lit_char('f')],
961        builtins.create_fold,
962        None,
963        builtins,
964        syntax_textobjects,
965        syntax_motions,
966        false,
967    );
968
969    // ---- Slice 8.g.v: mark / register / find-char / macro
970    // ---- wildcards. Each prefix chord is a partial trie node
971    // ---- whose terminal sub-binding is a `CharLiteral`
972    // ---- (matches any bare-printable char). The depth-1
973    // ---- binding here arms the legacy `Pending::After*` state
974    // ---- so the App's existing two-keystroke flow stays
975    // ---- intact; the depth-2 wildcard binding carries a
976    // ---- placeholder action that the dispatcher's
977    // ---- `substitute_normal_capture` rewrites with the
978    // ---- captured char.
979
980    // `m<X>` -- set mark X. The `[m]` standalone prefix is
981    // unbound (slice 8.i.4.a): the trie returns Partial because
982    // `[m, *]` exists, and `lookup_normal` synthesises
983    // `Action::AbsorbPartialChord(m)` so `App::partial_chord` =
984    // `[m]`. Same shape for `'`, `` ` ``, `"`, `q`, `@`, `<C-w>`
985    // below, plus `g` and `z` which never had a standalone bind.
986    handle.bind(
987        layer,
988        mode,
989        &[lit_char('m'), ChordPattern::CharLiteral],
990        CommandInvocation::of(actions.set_mark),
991        source(),
992    );
993
994    // `'<X>` / `` `<X> `` are motions now (VM.3e): see
995    // [`register_mark_paths`], which binds them beside `f` / `t`.
996
997    // `"<X>` -- select register X for the next operator / paste.
998    handle.bind(
999        layer,
1000        mode,
1001        &[lit_char('"'), ChordPattern::CharLiteral],
1002        CommandInvocation::of(actions.select_register),
1003        source(),
1004    );
1005
1006    // MB.3: `q:` -- open the command-line history picker (vim's
1007    // command-line window, modernised as a fuzzy picker). Registered
1008    // as an EXACT `[q, ':']` path so it wins over the `[q, <reg>]`
1009    // macro wildcard below (trie precedence: exact children beat the
1010    // char wildcard). `:` is not a valid macro register, so this
1011    // steals nothing from `qa`..`qz` recording.
1012    handle.bind(
1013        layer,
1014        mode,
1015        &[lit_char('q'), lit_char(':')],
1016        CommandInvocation::of(actions.open_history_picker),
1017        source(),
1018    );
1019    // MB.5: `q/` / `q?` — open the search-line history picker.
1020    // Same exact-path trick as `q:` above so they don't steal
1021    // from `qa`..`qz` macro recording (`/` and `?` aren't valid
1022    // macro registers).
1023    handle.bind(
1024        layer,
1025        mode,
1026        &[lit_char('q'), lit_char('/')],
1027        CommandInvocation::of(actions.open_search_history_picker),
1028        source(),
1029    );
1030    handle.bind(
1031        layer,
1032        mode,
1033        &[lit_char('q'), lit_char('?')],
1034        CommandInvocation::of(actions.open_search_history_picker),
1035        source(),
1036    );
1037
1038    // `q<X>` -- start macro recording into register X.
1039    // `q` while recording stops; that case is handled before
1040    // the trie lookup in `compute_normal_action` because it
1041    // depends on the App-side `recording_macro` state.
1042    handle.bind(
1043        layer,
1044        mode,
1045        &[lit_char('q'), ChordPattern::CharLiteral],
1046        CommandInvocation::of(actions.start_macro_record),
1047        source(),
1048    );
1049
1050    // `@<X>` -- play macro from register X (`@@` repeats last).
1051    handle.bind(
1052        layer,
1053        mode,
1054        &[lit_char('@'), ChordPattern::CharLiteral],
1055        CommandInvocation::of(actions.play_macro),
1056        source(),
1057    );
1058
1059    // `f<X>` / `F<X>` / `t<X>` / `T<X>` -- find-char on the
1060    // current line (no operator). `[f]` etc. arm the pending
1061    // state; `[f, CharLiteral]` resolves to a typed
1062    // `Invoke(find_char_*, Args::Char(captured))`.
1063    register_find_char_paths(KeymapLayer::Builtin, handle, &[], None, builtins, &source());
1064    register_mark_paths(KeymapLayer::Builtin, handle, &[], None, builtins, &source());
1065
1066    // `r<X>` -- replace the char(s) under the cursor with X (vim's
1067    // `r{char}`). The affected span is `char_right x count`, exactly
1068    // as `x` = delete over `char_right`; the `replace-char` operator
1069    // overwrites that range and stays in Normal. The target motion
1070    // carries a non-`None` `Args::Char('\0')` placeholder so
1071    // `substitute_invocation_char_arg` routes the captured char to the
1072    // OPERATOR's args (the replacement) rather than to the motion --
1073    // `char_right` ignores its args. `[r]` alone returns Partial (the
1074    // `[r, CharLiteral]` child exists) → the App arms its partial-chord
1075    // state and waits for the replacement key.
1076    handle.bind(
1077        layer,
1078        mode,
1079        &[lit_char('r'), ChordPattern::CharLiteral],
1080        CommandInvocation::of(builtins.replace_char.0)
1081            .with_target(Target::Motion(builtins.char_right, Args::Char('\0'))),
1082        source(),
1083    );
1084
1085    // ---- d/c/y/>/< as single-chord terminals that arm the
1086    // operator-pending state. `[g, U]` / `[g, u]` / `[g, ~]` were
1087    // already registered at slice 8.g.ii; their depth-2 binding
1088    // sets the same `SetPending(AfterOperator(...))` action.
1089    handle.bind(
1090        layer,
1091        mode,
1092        &[lit_char('d')],
1093        CommandInvocation::of(actions.absorb_operator_delete),
1094        source(),
1095    );
1096    handle.bind(
1097        layer,
1098        mode,
1099        &[lit_char('c')],
1100        CommandInvocation::of(actions.absorb_operator_change),
1101        source(),
1102    );
1103    handle.bind(
1104        layer,
1105        mode,
1106        &[lit_char('y')],
1107        CommandInvocation::of(actions.absorb_operator_yank),
1108        source(),
1109    );
1110    handle.bind(
1111        layer,
1112        mode,
1113        &[lit_char('>')],
1114        CommandInvocation::of(actions.absorb_operator_indent_right),
1115        source(),
1116    );
1117    handle.bind(
1118        layer,
1119        mode,
1120        &[lit_char('<')],
1121        CommandInvocation::of(actions.absorb_operator_indent_left),
1122        source(),
1123    );
1124    handle.bind(
1125        layer,
1126        mode,
1127        &[lit_char('=')],
1128        CommandInvocation::of(actions.absorb_operator_reindent),
1129        source(),
1130    );
1131
1132    // ---- Slice 8.g.vi: CTRL chord bindings.
1133    //
1134    // Direct depth-1 entries, modifier preserved. Modifier
1135    // normalisation in `lookup_normal` keeps CTRL+SHIFT, so
1136    // `<C-d>` resolves as `(Char('d'), CTRL)` -- the chord the
1137    // trie stores. The legacy CTRL guard at the top of
1138    // `compute_normal_action` retires once these registrations
1139    // land; every CTRL-bearing chord now flows through the
1140    // registry like every other binding.
1141    //
1142    // CM.3d (2026-07-22): `<C-c>` is deliberately NOT bound at the
1143    // Builtin layer — it belongs to modes. Compilation-mode binds
1144    // it to `:compilation-kill`; ACP conversation-mode binds it to
1145    // interrupt; claude-code-mode binds it for terminal signals.
1146    // Users who want `<C-c>` → quit can `:map <C-c> :qa<CR>` or
1147    // bind it in their init.rs. The universal quit hatch in
1148    // `input::translate` was removed.
1149
1150    // `<C-d>` / `<C-u>` -- half-page scroll. Bake `Count(10)`
1151    // into the invocation so 8.g.iv's `attach_count` honours it
1152    // when no user-typed prefix is in flight.
1153    // VM.3j-2: `<C-d>` / `<C-u>` are SCROLL commands, like `<C-f>` / `<C-b>`
1154    // below — not motions. They were bound to the `j` / `k` motions with a baked
1155    // `Count(10)`, which moved ten lines whatever the window height and, because
1156    // a motion composes, made `d<C-d>` delete eleven lines where vim deletes
1157    // nothing. Bound in Visual and Select too, for the reason VM.3h gives: vim
1158    // scrolls in those modes and a Ctrl chord never overtypes.
1159    for (key, action) in [('d', actions.half_page_down), ('u', actions.half_page_up)] {
1160        for m in [mode, BindingMode::Visual, BindingMode::Select] {
1161            handle.bind(
1162                layer,
1163                m,
1164                &[lit(KeyChord::ctrl(key))],
1165                CommandInvocation::of(action),
1166                source(),
1167            );
1168        }
1169    }
1170
1171    // Viewport / scroll / undo-tree / jump history / tag stack /
1172    // redraw / blockwise visual.
1173    // VM.3h: vim scrolls with these in Visual and Select too. They're
1174    // scrolling commands, not motions, so the motion mirror doesn't reach them;
1175    // a Ctrl chord never overtypes, so Select takes them. Separate binds rather
1176    // than `bind_modes`, which rebuilds the merged trie per call.
1177    for (key, action) in [
1178        ('f', actions.page_down),
1179        ('b', actions.page_up),
1180        ('e', actions.scroll_line_down),
1181        ('y', actions.scroll_line_up),
1182    ] {
1183        for m in [mode, BindingMode::Visual, BindingMode::Select] {
1184            handle.bind(
1185                layer,
1186                m,
1187                &[lit(KeyChord::ctrl(key))],
1188                CommandInvocation::of(action),
1189                source(),
1190            );
1191        }
1192    }
1193    handle.bind(
1194        layer,
1195        mode,
1196        &[lit(KeyChord::ctrl('r'))],
1197        CommandInvocation::of(actions.redo),
1198        source(),
1199    );
1200    handle.bind(
1201        layer,
1202        mode,
1203        &[lit(KeyChord::ctrl('o'))],
1204        CommandInvocation::of(actions.jump_history_back),
1205        source(),
1206    );
1207    handle.bind(
1208        layer,
1209        mode,
1210        &[lit(KeyChord::ctrl('i'))],
1211        CommandInvocation::of(actions.jump_history_forward),
1212        source(),
1213    );
1214    // PBH.3: the per-pane buffer trail, beside the global position ring
1215    // it parallels. `<C-6>` / `<C-7>` rather than `<C-^>` / `<C-_>`:
1216    // terminals send 0x1E / 0x1F and crossterm maps `b'\x1C'..=b'\x1F'`
1217    // to `Char('4'..'7') + CONTROL`, so these are the only spellings
1218    // that ever match. See docs/dev/architecture/pane-buffer-history.md §7.
1219    handle.bind(
1220        layer,
1221        mode,
1222        &[lit(KeyChord::ctrl('6'))],
1223        CommandInvocation::of(actions.pane_history_back),
1224        source(),
1225    );
1226    handle.bind(
1227        layer,
1228        mode,
1229        &[lit(KeyChord::ctrl('7'))],
1230        CommandInvocation::of(actions.pane_history_forward),
1231        source(),
1232    );
1233    handle.bind(
1234        layer,
1235        mode,
1236        &[lit(KeyChord::ctrl('t'))],
1237        CommandInvocation::of(actions.tag_stack_pop),
1238        source(),
1239    );
1240    handle.bind(
1241        layer,
1242        mode,
1243        &[lit(KeyChord::ctrl('l'))],
1244        CommandInvocation::of(actions.redraw_screen),
1245        source(),
1246    );
1247    handle.bind(
1248        layer,
1249        mode,
1250        &[lit(KeyChord::ctrl('v'))],
1251        CommandInvocation::of(actions.enter_visual_blockwise),
1252        source(),
1253    );
1254    handle.bind(
1255        layer,
1256        mode,
1257        &[lit(KeyChord::ctrl('q'))],
1258        CommandInvocation::of(actions.enter_visual_blockwise),
1259        source(),
1260    );
1261
1262    // ---- `<C-w>` window-management sub-tree.
1263    //
1264    // Slice 8.i.4.a: the standalone `[<C-w>]` bind is gone --
1265    // the trie returns `Partial` and `lookup_normal` emits
1266    // `AbsorbPartialChord` (same shape as `g` / `z` / `m` / `'`
1267    // / etc. above). Children of `[<C-w>, *]` register their
1268    // resolutions below.
1269    register_ctrl_w_sub_tree(handle, actions);
1270}
1271
1272/// Register every `[<C-w>, X]` path covered by the legacy
1273/// `resolve_after_ctrl_w`. Vim is lenient about the second key
1274/// after `<C-w>`: both ctrl-modified (`<C-w><C-l>`) and bare
1275/// (`<C-w>l`) variants navigate identically. Many terminals
1276/// also collapse `<C-h>` to Backspace and `<C-i>` to Tab; we
1277/// honour those mappings via the bare-key paths.
1278fn register_ctrl_w_sub_tree(handle: &KeymapHandle, actions: &ActionIds) {
1279    let layer = KeymapLayer::Builtin;
1280    let mode = BindingMode::Normal;
1281    let cw = lit(KeyChord::ctrl('w'));
1282
1283    // Bare-key second chord (NextPane / PrevPane / split / close /
1284    // navigate). Includes the Tab / BackTab / arrow / Backspace
1285    // aliases the legacy supported.
1286    // Slice 8.i.4.d: pane chords routed through typed
1287    // `CommandKind::Action` invocations. Each chord binds the
1288    // resolved action's `CommandId` directly; App's
1289    // `apply_app_effect` maps `AppEffect::*Pane*` to the
1290    // legacy `Action::*Pane*` arms (the latter retire when
1291    // those arms are inlined in a future cleanup slice).
1292    let bare_table: &[(&[ChordPattern], CommandId)] = &[
1293        (
1294            &[lit_char('s'), lit_char('S')],
1295            actions.split_pane_horizontal,
1296        ),
1297        (&[lit_char('v')], actions.split_pane_vertical),
1298        (&[lit_char('c'), lit_char('q')], actions.close_pane),
1299        (
1300            &[
1301                lit_char('h'),
1302                lit_special(SpecialKey::Left),
1303                lit_special(SpecialKey::Backspace),
1304            ],
1305            actions.navigate_pane_left,
1306        ),
1307        (
1308            &[lit_char('j'), lit_special(SpecialKey::Down)],
1309            actions.navigate_pane_down,
1310        ),
1311        (
1312            &[lit_char('k'), lit_special(SpecialKey::Up)],
1313            actions.navigate_pane_up,
1314        ),
1315        (
1316            &[lit_char('l'), lit_special(SpecialKey::Right)],
1317            actions.navigate_pane_right,
1318        ),
1319        (
1320            &[lit_char('w'), lit_special(SpecialKey::Tab)],
1321            actions.next_pane,
1322        ),
1323        (
1324            &[
1325                lit_char('W'),
1326                ChordPattern::Literal(KeyChord {
1327                    key: KeyKind::Special(SpecialKey::Tab),
1328                    mods: KeyMods::SHIFT,
1329                }),
1330            ],
1331            actions.prev_pane,
1332        ),
1333        // Issue #28 (2026-05-22): split-ratio adjustment.
1334        (&[lit_char('=')], actions.equalize_panes),
1335        (&[lit_char('+')], actions.grow_pane_height),
1336        (&[lit_char('-')], actions.shrink_pane_height),
1337        (&[lit_char('>')], actions.grow_pane_width),
1338        (&[lit_char('<')], actions.shrink_pane_width),
1339        // T4 (2026-05-25): `<C-w>T` — move active pane to new tab.
1340        (&[lit_char('T')], actions.move_pane_to_new_tab),
1341        // ZP.2: `<C-w>z` — tmux's `prefix z`. `z` was free in this
1342        // layer, and it is the mnemonic every terminal user already
1343        // has. Deliberately NOT `o`: that is vim's destructive
1344        // `:only`, and a non-destructive command wearing it looks
1345        // identical on the first press and diverges on the second.
1346        (&[lit_char('z')], actions.toggle_zoom_pane),
1347    ];
1348    for (chords, action_id) in bare_table {
1349        for chord in chords.iter() {
1350            handle.bind(
1351                layer,
1352                mode,
1353                &[cw.clone(), chord.clone()],
1354                CommandInvocation::of(*action_id),
1355                source(),
1356            );
1357        }
1358    }
1359
1360    // Ctrl-modified second chord: `<C-w><C-X>` mirrors `<C-w>X`
1361    // for the navigation / split / close / NextPane bindings.
1362    // `<C-c>` and `<C-q>` both map to ClosePane in the legacy
1363    // even though `<C-c>` is intercepted as Quit before the
1364    // pending arm runs (so the `<C-c>` registration is
1365    // unreachable in practice -- kept for parity with the
1366    // legacy table).
1367    let ctrl_table: &[(char, CommandId)] = &[
1368        ('w', actions.next_pane),
1369        ('h', actions.navigate_pane_left),
1370        ('j', actions.navigate_pane_down),
1371        ('k', actions.navigate_pane_up),
1372        ('l', actions.navigate_pane_right),
1373        ('s', actions.split_pane_horizontal),
1374        ('v', actions.split_pane_vertical),
1375        ('c', actions.close_pane),
1376        ('q', actions.close_pane),
1377        // ZP.2: `<C-w><C-z>`, for parity with the ctrl-modified
1378        // twins above — a held Ctrl should not change what the
1379        // chord means.
1380        ('z', actions.toggle_zoom_pane),
1381    ];
1382    for (c, action_id) in ctrl_table {
1383        handle.bind(
1384            layer,
1385            mode,
1386            &[cw.clone(), lit(KeyChord::ctrl(*c))],
1387            CommandInvocation::of(*action_id),
1388            source(),
1389        );
1390    }
1391}
1392
1393fn lit(chord: KeyChord) -> ChordPattern {
1394    ChordPattern::Literal(chord)
1395}
1396
1397/// Register the slice 8.g.iii operator-pending paths for one
1398/// operator under `op_prefix`. Same shape across every operator:
1399/// motion targets, the doubled-operator current-line shorthand,
1400/// `i_` / `a_` text-object pendings + their resolutions, and
1401/// the `f` / `F` / `t` / `T` find-char pendings (resolution stays
1402/// in legacy `resolve_after_find_char` until 8.g.v).
1403///
1404/// `doubled_self` is the chord that triggers the linewise form
1405/// (e.g. `'d'` for `dd`, `'U'` for `gUU`). It's the trailing key
1406/// of the doubled form, not the prefix. `None` binds no doubled form:
1407/// vim has no `zff`, and a bound `[z, f, f]` would shadow `zff{char}`
1408/// (fold to the next `{char}`), because a bound prefix kills its longer
1409/// chords.
1410///
1411/// N.1.3 (2026-06-10): `pub` so boot can wire a *provider-contributed*
1412/// operator's chord (the narrow `zn`) into this universal
1413/// operator-pending layer. The operator SPEC + `apply` are owned by
1414/// the provider crate; only this chord-wiring lives here, because
1415/// operator-pending composition needs the host-resolved `Builtins`.
1416pub fn register_operator_bindings(
1417    handle: &KeymapHandle,
1418    op_prefix: &[ChordPattern],
1419    op: lattice_grammar::registry::OperatorId,
1420    doubled_self: Option<ChordPattern>,
1421    builtins: &Builtins,
1422    syntax_textobjects: &SyntaxTextObjectIds,
1423    syntax_motions: &SyntaxMotionIds,
1424    post_motion_char: bool,
1425) {
1426    register_operator_bindings_in(
1427        KeymapLayer::Builtin,
1428        source(),
1429        handle,
1430        op_prefix,
1431        op,
1432        doubled_self,
1433        builtins,
1434        syntax_textobjects,
1435        syntax_motions,
1436        post_motion_char,
1437    );
1438}
1439
1440/// CM.2: [`register_operator_bindings`] with the layer chosen by the caller.
1441///
1442/// Extracted rather than adding a parameter to the function above, because
1443/// fifteen native call sites all want `Builtin` and churning them to say so
1444/// would be noise. The one caller that wants something else is the plugin
1445/// path: a plugin operator's chords belong to its minor mode, not to the
1446/// universal grammar. Bound at `Builtin` a plugin's `gc` would outlive
1447/// `:set <id>.enabled=false`, pointing at a handler that is gone.
1448#[allow(clippy::too_many_arguments)]
1449pub fn register_operator_bindings_in(
1450    layer: KeymapLayer,
1451    // CM.4: where these bindings came from. Stamped onto every chord the
1452    // composition creates, so `:describe-key gc` names the PLUGIN rather than
1453    // this file. A binding made on a plugin's behalf and attributed to host
1454    // code is a provenance lie, and provenance is a feature here — it is the
1455    // whole of what `:describe-key` answers.
1456    binding_source: lattice_grammar::SourceLocation,
1457    handle: &KeymapHandle,
1458    op_prefix: &[ChordPattern],
1459    op: lattice_grammar::registry::OperatorId,
1460    doubled_self: Option<ChordPattern>,
1461    builtins: &Builtins,
1462    syntax_textobjects: &SyntaxTextObjectIds,
1463    syntax_motions: &SyntaxMotionIds,
1464    post_motion_char: bool,
1465) {
1466    let mode = BindingMode::Normal;
1467
1468    // ---- Motion targets. Each operator's `[op_prefix..., motion_chord]`
1469    // ---- resolves to `Invoke(op, Target::Motion(motion))`. Sourced
1470    // ---- from the shared `motion_rows` table so a new motion works
1471    // ---- as `d<motion>` / `y<motion>` / ... automatically. (This is
1472    // ---- also where `dG` / `cG` / `yG` come from now -- `G` was
1473    // ---- previously absent from the operator-pending list.)
1474    //
1475    // ---- When `post_motion_char` is true (e.g. surround's `ys{motion}{char}`),
1476    // ---- appends `ChordPattern::CharLiteral` so the wrapping char is
1477    // ---- captured as `Args::Char` by the wildcard resolution.
1478    // ---- The motion target uses `Args::Char('\0')` as a placeholder so
1479    // ---- `substitute_invocation_char_arg` routes the captured char to
1480    // ---- the operator's args (not the motion's).
1481    for (chord, motion) in motion_rows(builtins) {
1482        let mut path: Vec<ChordPattern> = op_prefix.to_vec();
1483        path.push(chord);
1484        let motion_args = if post_motion_char {
1485            path.push(ChordPattern::CharLiteral);
1486            Args::Char('\0')
1487        } else {
1488            Args::None
1489        };
1490        handle.bind(
1491            layer,
1492            mode,
1493            &path,
1494            CommandInvocation::of(op.0).with_target(Target::Motion(motion, motion_args)),
1495            binding_source.clone(),
1496        );
1497    }
1498
1499    // ---- TSM.4: the sixteen tree-sitter structural motion targets --
1500    // ---- `[op_prefix..., ']', 'f']` etc. resolve to `Invoke(op,
1501    // ---- Target::Motion(motion))`, exactly as the builtin motions
1502    // ---- above. Sourced from the shared `syntax_motion_rows` table so
1503    // ---- `d]f` / `y]c` / `>]l` / ... all work automatically.
1504    for (seq, motion) in syntax_motion_rows(syntax_motions) {
1505        let mut path: Vec<ChordPattern> = op_prefix.to_vec();
1506        path.extend(seq);
1507        let motion_args = if post_motion_char {
1508            path.push(ChordPattern::CharLiteral);
1509            Args::Char('\0')
1510        } else {
1511            Args::None
1512        };
1513        handle.bind(
1514            layer,
1515            mode,
1516            &path,
1517            CommandInvocation::of(op.0).with_target(Target::Motion(motion, motion_args)),
1518            binding_source.clone(),
1519        );
1520    }
1521
1522    // ---- Doubled-operator -> `Range::CurrentLine`. `dd`, `cc`,
1523    // ---- `yy`, `>>`, `<<`, `gUU`, `guu`, `g~~`.
1524    //
1525    // ---- SU.3e: honours `post_motion_char` like every other path in
1526    // ---- this function. It did not, and that is what made `yss{char}`
1527    // ---- unreachable: this block bound `[y, s, s]` at three chords, the
1528    // ---- trie returns a node's own binding before descending into its
1529    // ---- children, so surround-mode's four-chord `[y, s, s, CharLiteral]`
1530    // ---- could never be walked to. `yss"` fired surround-add with no
1531    // ---- wrapper character and the quote landed as the start of a fresh
1532    // ---- chord. Design fragment §3.3 spells out that step 3 must resolve
1533    // ---- `Partial`.
1534    //
1535    // ---- No `Args::Char('\0')` placeholder here, unlike the motion and
1536    // ---- text-object paths above: those need it so
1537    // ---- `substitute_invocation_char_arg` routes the captured char to the
1538    // ---- OPERATOR rather than to the motion it is targeting. A doubled
1539    // ---- operator has no target, so the capture lands on the
1540    // ---- invocation's own args with no disambiguation needed.
1541    if let Some(doubled_self) = doubled_self {
1542        let mut path: Vec<ChordPattern> = op_prefix.to_vec();
1543        path.push(doubled_self);
1544        if post_motion_char {
1545            path.push(ChordPattern::CharLiteral);
1546        }
1547        handle.bind(
1548            layer,
1549            mode,
1550            &path,
1551            CommandInvocation::of(op.0).with_range(lattice_grammar::Range::CurrentLine),
1552            binding_source.clone(),
1553        );
1554    }
1555
1556    // ---- Text-object resolutions. Slice 8.i.4.c: the depth-2
1557    // ---- `[op, i]` / `[op, a]` standalone arms are gone -- the
1558    // ---- trie's natural `Partial` result for those paths
1559    // ---- (because `[op, i, X]` / `[op, a, X]` are bound) drives
1560    // ---- `App::partial_chord` via `AbsorbPartialChord`. The
1561    // ---- depth-3 resolutions stay; they fire when partial_chord
1562    // ---- has accumulated `[op, i / a]` and the user types the
1563    // ---- text-object char.
1564    for around in [false, true] {
1565        let around_chord: ChordPattern = if around { lit_char('a') } else { lit_char('i') };
1566        let mut pending_path: Vec<ChordPattern> = op_prefix.to_vec();
1567        pending_path.push(around_chord.clone());
1568        register_text_object_resolutions(
1569            layer,
1570            handle,
1571            &pending_path,
1572            op,
1573            around,
1574            builtins,
1575            syntax_textobjects,
1576            post_motion_char,
1577            &binding_source,
1578        );
1579    }
1580
1581    // ---- Slice 8.g.v: find-char chained -- the depth-2
1582    // ---- `[op, f/F/t/T]` arms the pending state, and the
1583    // ---- depth-3 `[op, f/F/t/T, CharLiteral]` wildcard
1584    // ---- resolves to a typed `Invoke(op,
1585    // ---- Target::Motion(find_char_*, Args::Char(captured)))`.
1586    register_find_char_paths(
1587        layer,
1588        handle,
1589        op_prefix,
1590        Some(op),
1591        builtins,
1592        &binding_source,
1593    );
1594    register_mark_paths(
1595        layer,
1596        handle,
1597        op_prefix,
1598        Some(op),
1599        builtins,
1600        &binding_source,
1601    );
1602
1603    // ---- Visual mode: an operator acts on the active selection BY
1604    // ---- DESIGN. Pressing the operator's trigger chord in Visual
1605    // ---- dispatches `op.with_range(Range::Selection)` (the same proven
1606    // ---- path `d`/`c`/`y` already use; the dispatcher's `Selection`
1607    // ---- walker resolves it against the live visual region and exits
1608    // ---- Visual). This is intrinsic to *being* an operator, not a
1609    // ---- per-operator Visual binding -- so EVERY operator registered
1610    // ---- through this helper gets selection-operability uniformly:
1611    // ---- builtin `d`/`c`/`y`/`>`/`<`, case `gU`/`gu`/`g~`, AND
1612    // ---- contributed operators (narrow's `zn`). The old hand-rolled
1613    // ---- Visual operator list in `keymap_visual` is gone; only the two
1614    // ---- genuine Visual-only aliases (`x`->delete, `s`->change) remain
1615    // ---- there, because in Normal `x`/`s` mean different commands and
1616    // ---- so are not this operator's trigger chord.
1617    handle.bind(
1618        layer,
1619        BindingMode::Visual,
1620        op_prefix,
1621        CommandInvocation::of(op.0).with_range(lattice_grammar::Range::Selection),
1622        binding_source.clone(),
1623    );
1624}
1625
1626/// Register the four find-char chord paths under `prefix`. When
1627/// `operator` is `None`, registers the standalone `f` / `F` /
1628/// `t` / `T` (Normal-mode cursor motion). When `Some(op)`, the
1629/// paths sit under the operator's prefix (e.g. `[d, f, X]`) and
1630/// the resolved invocation is `Invoke(op,
1631/// Target::Motion(find_char_*, Args::Char(captured)))`.
1632///
1633/// The depth-1 entry (just `[prefix..., f]`) arms
1634/// `Pending::AfterFindChar` so the App's existing
1635/// two-keystroke flow can stay; the depth-2 entry is the
1636/// `CharLiteral` wildcard that captures the char and triggers
1637/// the substituter in `substitute_normal_capture`.
1638fn register_find_char_paths(
1639    // CM.2: the layer every binding this creates lands in — the CALLER's,
1640    // never assumed. Hardcoding `Builtin` here put a plugin operator's
1641    // `gcap` / `gcF{char}` / `gc'{char}` in the universal layer while its
1642    // `gcw` sat in the plugin's mode: they fired in every buffer, outlived
1643    // `:set <id>.enabled=false`, and `:describe-key gc` showed the operator
1644    // registered twice.
1645    layer: KeymapLayer,
1646    handle: &KeymapHandle,
1647    prefix: &[ChordPattern],
1648    operator: Option<lattice_grammar::registry::OperatorId>,
1649    builtins: &Builtins,
1650    // CM.5: the stamp for every binding this creates. An operator's
1651    // CONTINUATIONS are bindings too — `gcF{char}`, `gcT{char}`,
1652    // `gc'{char}` — and stamping them from this file made a plugin's
1653    // chord look half host-owned: `gc` named the plugin while `gcF` named
1654    // keymap_normal.rs.
1655    binding_source: &lattice_grammar::SourceLocation,
1656) {
1657    let mode = BindingMode::Normal;
1658
1659    let table: &[(ChordPattern, FindKind, lattice_grammar::registry::MotionId)] = &[
1660        (lit_char('f'), FindKind::Forward, builtins.find_char_forward),
1661        (
1662            lit_char('F'),
1663            FindKind::Backward,
1664            builtins.find_char_backward,
1665        ),
1666        (
1667            lit_char('t'),
1668            FindKind::TillForward,
1669            builtins.till_char_forward,
1670        ),
1671        (
1672            lit_char('T'),
1673            FindKind::TillBackward,
1674            builtins.till_char_backward,
1675        ),
1676    ];
1677
1678    for (chord, _kind, motion_id) in table {
1679        // Slice 8.i.4.c: the depth-1 (or depth-2 under operator)
1680        // standalone `[..., f / F / t / T]` arms are gone -- the
1681        // trie's natural `Partial` result for those paths drives
1682        // `App::partial_chord` via `AbsorbPartialChord`. Only
1683        // the depth-2 (or depth-3) `CharLiteral` wildcard
1684        // resolution stays; it fires when partial_chord has
1685        // accumulated `[..., f / F / t / T]` and the user types
1686        // the target char.
1687        let mut wild_path: Vec<ChordPattern> = prefix.to_vec();
1688        wild_path.push(chord.clone());
1689        wild_path.push(ChordPattern::CharLiteral);
1690        let invocation = match operator {
1691            None => CommandInvocation::of(motion_id.0),
1692            Some(op) => {
1693                CommandInvocation::of(op.0).with_target(Target::Motion(*motion_id, Args::None))
1694            }
1695        };
1696        handle.bind(layer, mode, &wild_path, invocation, binding_source.clone());
1697    }
1698}
1699
1700/// VM.3e: `'{mark}` / `` `{mark} `` as motions, bare and under an operator
1701/// (`d'a`, `` y`a ``). The same `{char}` wildcard shape as
1702/// [`register_find_char_paths`]: `substitute_invocation_char_arg` puts the
1703/// mark name in the motion's args. Visual gets both from the keymap's motion
1704/// mirror, which carries wildcard paths exactly. They were actions, so none of
1705/// `d'a`, `` c`a `` or `v'a` was bound, though vim composes all of them.
1706fn register_mark_paths(
1707    // CM.2: the layer every binding this creates lands in — the CALLER's,
1708    // never assumed. Hardcoding `Builtin` here put a plugin operator's
1709    // `gcap` / `gcF{char}` / `gc'{char}` in the universal layer while its
1710    // `gcw` sat in the plugin's mode: they fired in every buffer, outlived
1711    // `:set <id>.enabled=false`, and `:describe-key gc` showed the operator
1712    // registered twice.
1713    layer: KeymapLayer,
1714    handle: &KeymapHandle,
1715    prefix: &[ChordPattern],
1716    operator: Option<lattice_grammar::registry::OperatorId>,
1717    builtins: &Builtins,
1718    // CM.5: the stamp for every binding this creates. An operator's
1719    // CONTINUATIONS are bindings too — `gcF{char}`, `gcT{char}`,
1720    // `gc'{char}` — and stamping them from this file made a plugin's
1721    // chord look half host-owned: `gc` named the plugin while `gcF` named
1722    // keymap_normal.rs.
1723    binding_source: &lattice_grammar::SourceLocation,
1724) {
1725    for (key, motion_id) in [('\'', builtins.mark_line), ('`', builtins.mark_exact)] {
1726        let mut path: Vec<ChordPattern> = prefix.to_vec();
1727        path.push(lit_char(key));
1728        path.push(ChordPattern::CharLiteral);
1729        let invocation = match operator {
1730            None => CommandInvocation::of(motion_id.0),
1731            Some(op) => {
1732                CommandInvocation::of(op.0).with_target(Target::Motion(motion_id, Args::None))
1733            }
1734        };
1735        handle.bind(
1736            layer,
1737            BindingMode::Normal,
1738            &path,
1739            invocation,
1740            binding_source.clone(),
1741        );
1742    }
1743}
1744
1745/// The chord -> motion table the Normal binder walks.
1746///
1747/// **This is not the motion catalog, and a motion does not have to be here.**
1748/// It used to claim otherwise — "single source of truth shared by all three
1749/// motion surfaces" — and VM.1 found that false: `gg`, `f` / `F` / `t` / `T`,
1750/// `<C-d>` / `<C-u>` and `<PageUp>` / `<PageDown>` all reach Normal by other
1751/// routes in this file, so a Visual surface built by re-walking this table was
1752/// missing every one of them, and had no route to a plugin's motions at all.
1753///
1754/// What is actually single-source is the DERIVATION, from the command's kind.
1755/// The keymap mirrors every `CommandKind::Motion` binding, however it got there
1756/// (builtin or contributed), into Visual, and into Select when its first chord
1757/// can't be typed (VM.4). [`expand_grammar_rows`] adds its operator-pending
1758/// peers. Adding a row here is one convenient way to bind a motion in Normal;
1759/// it is not what makes the motion work everywhere else.
1760///
1761/// Argument motions (`f` / `F` / `t` / `T` find-char) are NOT here —
1762/// they ride a separate wildcard-capture path
1763/// ([`register_find_char_paths`]). This table is the simple, no-arg
1764/// motions only.
1765///
1766/// Operator targets follow the motion: a linewise motion (`j` / `k` / `gg` /
1767/// `G`) gives whole lines (`dj` deletes two lines, `yj` yanks them linewise),
1768/// and an exclusive motion obeys `:h exclusive-linewise` (VM.3L). Sharing this
1769/// table is what gives `dG` / `cG` / `yG` their operator rows.
1770pub(crate) fn motion_rows(
1771    builtins: &Builtins,
1772) -> Vec<(ChordPattern, lattice_grammar::registry::MotionId)> {
1773    vec![
1774        (lit_char('h'), builtins.char_left),
1775        (lit_special(SpecialKey::Left), builtins.char_left),
1776        (lit_char('j'), builtins.line_down),
1777        (lit_special(SpecialKey::Down), builtins.line_down),
1778        (lit_char('k'), builtins.line_up),
1779        (lit_special(SpecialKey::Up), builtins.line_up),
1780        (lit_char('l'), builtins.char_right),
1781        (lit_special(SpecialKey::Right), builtins.char_right),
1782        (lit_char('0'), builtins.line_start),
1783        (lit_special(SpecialKey::Home), builtins.line_start),
1784        (lit_char('$'), builtins.line_end),
1785        (lit_special(SpecialKey::End), builtins.line_end),
1786        (lit_char('^'), builtins.first_non_blank),
1787        (lit_char('w'), builtins.word_forward),
1788        (lit_char('b'), builtins.word_backward),
1789        (lit_char('e'), builtins.word_end),
1790        (lit_char('W'), builtins.big_word_forward),
1791        (lit_char('B'), builtins.big_word_backward),
1792        (lit_char('E'), builtins.big_word_end),
1793        (lit_char('}'), builtins.paragraph_forward),
1794        (lit_char('{'), builtins.paragraph_backward),
1795        (lit_char(')'), builtins.sentence_forward),
1796        (lit_char('('), builtins.sentence_backward),
1797        (lit_char('G'), builtins.goto_last_line),
1798    ]
1799}
1800
1801/// The sixteen tree-sitter structural motions as full 2-key sequences
1802/// (TSM.4). Walked by the Normal binder ([`register_normal_bindings`]) and by
1803/// the operator-pending resolver ([`register_operator_bindings`]); Visual gets
1804/// them from the keymap's motion mirror like every other motion (Select
1805/// doesn't, since `]` is typed text there), so this table no longer has a third
1806/// and fourth consumer to drift against. Same
1807/// caveat as [`motion_rows`]: convenient, not canonical.
1808///
1809/// Keyed on a full chord sequence instead of a single chord — each entry is
1810/// `]x` / `[x`, a two-key sequence, not one key with aliases.
1811pub(crate) fn syntax_motion_rows(
1812    m: &SyntaxMotionIds,
1813) -> Vec<(Vec<ChordPattern>, lattice_grammar::registry::MotionId)> {
1814    vec![
1815        (vec![lit_char(']'), lit_char('f')], m.next_function_start),
1816        (vec![lit_char('['), lit_char('f')], m.prev_function_start),
1817        (vec![lit_char(']'), lit_char('F')], m.next_function_end),
1818        (vec![lit_char('['), lit_char('F')], m.prev_function_end),
1819        (vec![lit_char(']'), lit_char('c')], m.next_class_start),
1820        (vec![lit_char('['), lit_char('c')], m.prev_class_start),
1821        (vec![lit_char(']'), lit_char('C')], m.next_class_end),
1822        (vec![lit_char('['), lit_char('C')], m.prev_class_end),
1823        (vec![lit_char(']'), lit_char('a')], m.next_parameter_start),
1824        (vec![lit_char('['), lit_char('a')], m.prev_parameter_start),
1825        (vec![lit_char(']'), lit_char('A')], m.next_parameter_end),
1826        (vec![lit_char('['), lit_char('A')], m.prev_parameter_end),
1827        (vec![lit_char(']'), lit_char('l')], m.next_loop_start),
1828        (vec![lit_char('['), lit_char('l')], m.prev_loop_start),
1829        (vec![lit_char(']'), lit_char('L')], m.next_loop_end),
1830        (vec![lit_char('['), lit_char('L')], m.prev_loop_end),
1831    ]
1832}
1833
1834/// The canonical chord -> (inner, around) text-object table.
1835///
1836/// Single source of truth shared by the Normal-mode operator-pending
1837/// resolver ([`register_text_object_resolutions`]) and the Visual-mode
1838/// binder ([`crate::keymap_visual::register_visual_bindings`]) so the
1839/// two surfaces NEVER drift: a new object or alias added here is
1840/// picked up by `daf` / `yiw` AND `vaf` / `viw` alike, with zero
1841/// per-object code on either side.
1842///
1843/// Builtin objects (`w` / `W` / `p` / `s` / `t` / quotes / brackets /
1844/// `C`) and the tree-sitter structural objects (`f` / `c` / `a` / `l`,
1845/// N.1.4c) live in one list. The chord chars never collide: find-char
1846/// (`df<c>`) rides a different post-operator path, so `daf` =
1847/// d -> a(around) -> f(function) never clashes with `dfc`. Ownership
1848/// of the structural ids stays with lattice-syntax (it minted them);
1849/// the host only wires chord -> id, exactly as for the builtin objects.
1850///
1851/// Each row is `(chord aliases, inner id, around id)`.
1852pub(crate) fn text_object_rows(
1853    builtins: &Builtins,
1854    syntax_textobjects: &SyntaxTextObjectIds,
1855) -> Vec<(
1856    Vec<ChordPattern>,
1857    lattice_grammar::registry::TextObjectId,
1858    lattice_grammar::registry::TextObjectId,
1859)> {
1860    vec![
1861        (
1862            vec![lit_char('w')],
1863            builtins.inner_word,
1864            builtins.around_word,
1865        ),
1866        (
1867            vec![lit_char('W')],
1868            builtins.inner_big_word,
1869            builtins.around_big_word,
1870        ),
1871        (
1872            vec![lit_char('p')],
1873            builtins.inner_paragraph,
1874            builtins.around_paragraph,
1875        ),
1876        (
1877            vec![lit_char('s')],
1878            builtins.inner_sentence,
1879            builtins.around_sentence,
1880        ),
1881        (vec![lit_char('t')], builtins.inner_tag, builtins.around_tag),
1882        (
1883            vec![lit_char('"')],
1884            builtins.inner_quote_double,
1885            builtins.around_quote_double,
1886        ),
1887        (
1888            vec![lit_char('\'')],
1889            builtins.inner_quote_single,
1890            builtins.around_quote_single,
1891        ),
1892        (
1893            vec![lit_char('`')],
1894            builtins.inner_quote_backtick,
1895            builtins.around_quote_backtick,
1896        ),
1897        // Paren aliases: `(`, `)`, `b`.
1898        (
1899            vec![lit_char('('), lit_char(')'), lit_char('b')],
1900            builtins.inner_paren,
1901            builtins.around_paren,
1902        ),
1903        // Bracket aliases: `[`, `]`.
1904        (
1905            vec![lit_char('['), lit_char(']')],
1906            builtins.inner_bracket,
1907            builtins.around_bracket,
1908        ),
1909        // Brace aliases: `{`, `}`, `B`.
1910        (
1911            vec![lit_char('{'), lit_char('}'), lit_char('B')],
1912            builtins.inner_brace,
1913            builtins.around_brace,
1914        ),
1915        // Angle aliases: `<`, `>`.
1916        (
1917            vec![lit_char('<'), lit_char('>')],
1918            builtins.inner_angle,
1919            builtins.around_angle,
1920        ),
1921        // N.1.6: comment object -- capital `C` (lowercase `c` is class,
1922        // N.1.4). `aC` = the comment block incl. markers; `iC` = its text.
1923        (
1924            vec![lit_char('C')],
1925            builtins.inner_comment,
1926            builtins.around_comment,
1927        ),
1928        // N.1.4c: tree-sitter structural objects.
1929        (
1930            vec![lit_char('f')],
1931            syntax_textobjects.inner_function,
1932            syntax_textobjects.around_function,
1933        ),
1934        (
1935            vec![lit_char('c')],
1936            syntax_textobjects.inner_class,
1937            syntax_textobjects.around_class,
1938        ),
1939        (
1940            vec![lit_char('a')],
1941            syntax_textobjects.inner_parameter,
1942            syntax_textobjects.around_parameter,
1943        ),
1944        (
1945            vec![lit_char('l')],
1946            syntax_textobjects.inner_loop,
1947            syntax_textobjects.around_loop,
1948        ),
1949    ]
1950}
1951
1952/// Register every text-object resolution path under
1953/// `pending_prefix` (which already ends in `i` or `a`). For each
1954/// text-object chord (with all its aliases), bind to the
1955/// corresponding inner / around `TextObjectId`. Rows come from the
1956/// shared [`text_object_rows`] table -- the same table the Visual-mode
1957/// binder consumes, so operator-pending and Visual never drift.
1958#[allow(clippy::too_many_arguments)]
1959fn register_text_object_resolutions(
1960    // CM.2: the layer every binding this creates lands in — the CALLER's,
1961    // never assumed. Hardcoding `Builtin` here put a plugin operator's
1962    // `gcap` / `gcF{char}` / `gc'{char}` in the universal layer while its
1963    // `gcw` sat in the plugin's mode: they fired in every buffer, outlived
1964    // `:set <id>.enabled=false`, and `:describe-key gc` showed the operator
1965    // registered twice.
1966    layer: KeymapLayer,
1967    handle: &KeymapHandle,
1968    pending_prefix: &[ChordPattern],
1969    op: lattice_grammar::registry::OperatorId,
1970    around: bool,
1971    builtins: &Builtins,
1972    syntax_textobjects: &SyntaxTextObjectIds,
1973    post_motion_char: bool,
1974    // CM.5: the stamp for every binding this creates. An operator's
1975    // CONTINUATIONS are bindings too — `gcF{char}`, `gcT{char}`,
1976    // `gc'{char}` — and stamping them from this file made a plugin's
1977    // chord look half host-owned: `gc` named the plugin while `gcF` named
1978    // keymap_normal.rs.
1979    binding_source: &lattice_grammar::SourceLocation,
1980) {
1981    let mode = BindingMode::Normal;
1982
1983    for (chord_aliases, inner_id, around_id) in text_object_rows(builtins, syntax_textobjects) {
1984        let tobj = if around { around_id } else { inner_id };
1985        for chord in &chord_aliases {
1986            let mut path: Vec<ChordPattern> = pending_prefix.to_vec();
1987            path.push(chord.clone());
1988            if post_motion_char {
1989                path.push(ChordPattern::CharLiteral);
1990            }
1991            handle.bind(
1992                layer,
1993                mode,
1994                &path,
1995                CommandInvocation::of(op.0).with_target(Target::TextObject(tobj, Args::None)),
1996                binding_source.clone(),
1997            );
1998        }
1999    }
2000}
2001
2002/// Look up a single-key event in the Normal-mode catalog.
2003/// Returns `Some(action)` when the chord is bound; `None` to
2004/// signal the caller (`input::translate_normal`) to fall back to
2005/// its legacy match arm for the not-yet-migrated bindings.
2006///
2007/// `event` should arrive *after* the legacy CTRL guard, the
2008/// numeric prefix accumulator, and the recording-macro `q`
2009/// special-case in `translate_normal` -- this helper is the
2010/// last stop before the legacy match. CTRL-bearing chords are
2011/// passed through untouched (the trie has no CTRL bindings
2012/// yet -- they migrate in 8.g.vi).
2013///
2014/// Slice 8.g.ii: `g` and `z` are partial trie nodes (children
2015/// only, no terminal binding). `LookupResult::Partial` on those
2016/// chords surfaces here as `Some(Action::SetPending(AfterG /
2017/// AfterZ))` so the dispatcher arms the second-key resolver.
2018/// Other partial paths still return `None` (no caller produces
2019/// them today; future sub-slices can extend this match arm).
2020pub fn lookup_normal(
2021    handle: &KeymapHandle,
2022    chord: &KeyChord,
2023    active_minor_modes: &[lattice_mode::ModeId],
2024) -> Option<Action> {
2025    let found = lookup_normal_chord(handle, BindingMode::Normal, &[], *chord, active_minor_modes);
2026    let chord = found.resolved;
2027    match found.result {
2028        LookupResult::Bound { command, captured } => {
2029            Some(action_from_bound_with_capture(&command, &captured))
2030        }
2031        LookupResult::Partial => {
2032            // Slice 8.i.4.a: every `Partial` result absorbs into
2033            // `App::partial_chord` via `AbsorbPartialChord`. The
2034            // App's next keystroke runs through
2035            // `dispatch_normal` with this stack as prefix, hitting
2036            // the trie's resolved binding for the full path.
2037            // Replaces the prior `g`/`z` -> `SetPending(After*)`
2038            // synthesis. The 9 simple prefix-only Pending variants
2039            // (`AfterG`, `AfterZ`, `AfterCtrlW`, `AfterSetMark`,
2040            // `AfterJumpMarkLine`, `AfterJumpMarkExact`,
2041            // `AfterRegister`, `AfterMacroStart`,
2042            // `AfterMacroPlay`) all funnel through here now.
2043            // Parameterised pendings (`AfterOperator(_)`,
2044            // `AfterTextObject{_}`, `AfterFindChar{_}`) keep their
2045            // own `SetPending` flow until 8.i.4.b.
2046            Some(Action::AbsorbPartialChord(chord))
2047        }
2048        LookupResult::Unbound => None,
2049    }
2050}
2051
2052/// Resolve the next key of a multi-chord Normal-mode sequence
2053/// via the registry. The caller supplies the prefix chord
2054/// sequence already absorbed; this helper appends the
2055/// normalised current chord and looks the resulting path up in
2056/// the trie. `Bound` -> the bound action; everything else
2057/// (`Partial` / `Unbound`) -> `Action::None`
2058/// to drop the pending state, matching every legacy
2059/// `resolve_after_*`'s catchall.
2060///
2061/// Used by:
2062/// - `Pending::AfterG` / `Pending::AfterZ` (slice 8.g.ii) --
2063///   prefix `[g]` / `[z]`.
2064/// - `Pending::AfterOperator(op)` (slice 8.g.iii) -- prefix
2065///   `[d]` / `[c]` / `[y]` / `[>]` / `[<]` for the single-chord
2066///   operators, or `[g, U]` / `[g, u]` / `[g, ~]` for the case
2067///   operators (mapped via `operator_prefix`).
2068/// - `Pending::AfterTextObject { op, around }` (slice 8.g.iii) --
2069///   prefix `[op_prefix..., 'i' or 'a']`.
2070/// The MODE layer whose binding won for `prefix + chord` under `layers`, or
2071/// `None` when the winner came from the always-on Builtin / User layers (or
2072/// nothing is bound at all).
2073///
2074/// This is what makes AP.0.2's fall-through able to peel ONE layer at a time.
2075/// A declining action has to be re-resolved without *its own* layer, and the
2076/// dispatcher cannot know which layer that was — a chord may be bound in the
2077/// major and in two minors, and the fold in `lookup_with_context` decides the
2078/// winner. Asking the trie which layer produced the binding is cheaper and
2079/// more honest than guessing at the list's tail.
2080///
2081/// Pure lookup against an in-memory trie: no allocation beyond the path, and
2082/// only reached on the decline path, which is off the happy keystroke route.
2083pub fn binding_layer_mode(
2084    handle: &KeymapHandle,
2085    mode: BindingMode,
2086    prefix: &[KeyChord],
2087    chord: &KeyChord,
2088    layers: &[lattice_mode::ModeId],
2089) -> Option<lattice_mode::ModeId> {
2090    match lookup_normal_chord(handle, mode, prefix, *chord, layers).result {
2091        LookupResult::Bound { command, .. } => match command.layer {
2092            KeymapLayer::MajorMode(id) | KeymapLayer::MinorMode(id) => Some(id),
2093            // Builtin / User / anything else is always-on: there is no layer
2094            // left to peel, so the walk ends here.
2095            _ => None,
2096        },
2097        _ => None,
2098    }
2099}
2100
2101pub fn lookup_normal_with_prefix(
2102    handle: &KeymapHandle,
2103    prefix: &[KeyChord],
2104    chord: &KeyChord,
2105    active_minor_modes: &[lattice_mode::ModeId],
2106) -> Action {
2107    let found = lookup_normal_chord(
2108        handle,
2109        BindingMode::Normal,
2110        prefix,
2111        *chord,
2112        active_minor_modes,
2113    );
2114    let chord = found.resolved;
2115    match found.result {
2116        LookupResult::Bound { command, captured } => {
2117            action_from_bound_with_capture(&command, &captured)
2118        }
2119        LookupResult::Partial => {
2120            // Slice 8.i.4.c: nested partial chord. Same shape
2121            // as `lookup_normal`'s `Partial` arm -- absorb the
2122            // current chord into `App::partial_chord` so the
2123            // next keystroke routes through this helper again
2124            // with the extended prefix. Required for chains
2125            // like `di` (d already in partial_chord, `i` is
2126            // partial because `[d, i, w]` etc. are bound) and
2127            // `df` (find-char prefix).
2128            Action::AbsorbPartialChord(chord)
2129        }
2130        LookupResult::Unbound => Action::None,
2131    }
2132}
2133
2134/// Slice 8.g.iv: attach the input-side count accumulator to a
2135/// resolved `Action::Invoke`. Pure function; non-`Invoke`
2136/// actions pass through unchanged.
2137///
2138/// Vim semantics: `<op-count><op><motion-count><motion>` yields
2139/// a final count of `op_count * motion_count`. Either alone
2140/// replaces the default count of `1`. The motion side falls
2141/// back to `inv.count` (any default the binding registered with
2142/// at boot, e.g. `<PageDown>`'s `Count(10)`) when the user
2143/// hasn't typed a digit prefix.
2144///
2145/// Architecture doc §7.1: "Once a non-digit chord arrives,
2146/// lookup runs with the accumulated count attached to the
2147/// resulting `CommandInvocation`'s count field. Dispatch
2148/// unchanged; `execute(invocation_with_count)` works today."
2149/// Before this slice the multiplication lived in App's
2150/// dispatcher (`run_document_invocation` /
2151/// `run_read_only_motion`); now it rides with the action out
2152/// of `translate_normal`. App still resets `pending_count` /
2153/// `op_count` at end-of-dispatch.
2154pub fn attach_count(action: Action, pending_count: u32, op_count: u32) -> Action {
2155    let Action::Invoke(mut inv) = action else {
2156        return action;
2157    };
2158    let motion_count = if pending_count > 0 {
2159        pending_count
2160    } else {
2161        inv.count.map(|c| c.0).unwrap_or(1)
2162    };
2163    let final_count = if op_count > 0 {
2164        op_count.saturating_mul(motion_count)
2165    } else {
2166        motion_count
2167    };
2168    if final_count > 1 {
2169        inv = inv.with_count(lattice_grammar::command::Count(final_count));
2170    }
2171    Action::Invoke(inv)
2172}
2173
2174/// Map an operator id to its primary chord prefix in the
2175/// Normal-mode trie. Used by the `Pending::AfterOperator` and
2176/// `Pending::AfterTextObject` resolvers to compute the lookup
2177/// path.
2178///
2179/// Returns an empty `Vec` for unknown operators -- the caller
2180/// surfaces that as `SetPending(None)`. Slice 8.g.iii covers
2181/// every operator the existing keymap exposes; plugin-defined
2182/// operators (slice 8.h) will register their own prefix at
2183/// binding time.
2184pub fn operator_prefix(
2185    op: lattice_grammar::registry::OperatorId,
2186    builtins: &Builtins,
2187) -> Vec<KeyChord> {
2188    if op == builtins.delete {
2189        vec![KeyChord::char('d')]
2190    } else if op == builtins.change {
2191        vec![KeyChord::char('c')]
2192    } else if op == builtins.yank {
2193        vec![KeyChord::char('y')]
2194    } else if op == builtins.indent_right {
2195        vec![KeyChord::char('>')]
2196    } else if op == builtins.indent_left {
2197        vec![KeyChord::char('<')]
2198    } else if op == builtins.reindent {
2199        vec![KeyChord::char('=')]
2200    } else if op == builtins.upper {
2201        vec![KeyChord::char('g'), KeyChord::char('U')]
2202    } else if op == builtins.lower {
2203        vec![KeyChord::char('g'), KeyChord::char('u')]
2204    } else if op == builtins.toggle_case {
2205        vec![KeyChord::char('g'), KeyChord::char('~')]
2206    } else if op == builtins.search {
2207        vec![KeyChord::char('g'), KeyChord::char('/')]
2208    } else if op == builtins.create_fold {
2209        vec![KeyChord::char('z'), KeyChord::char('f')]
2210    } else {
2211        Vec::new()
2212    }
2213}
2214
2215/// OM.4b: the operators a plugin-contributed motion or text object composes
2216/// with. `search` (`g/`) is absent deliberately — it is an operator in the
2217/// registry but its Normal-mode surface is the search prompt, not a
2218/// composable prefix.
2219fn composable_operators(builtins: &Builtins) -> [lattice_grammar::registry::OperatorId; 10] {
2220    [
2221        builtins.delete,
2222        builtins.change,
2223        builtins.yank,
2224        builtins.indent_right,
2225        builtins.indent_left,
2226        builtins.reindent,
2227        builtins.upper,
2228        builtins.lower,
2229        builtins.toggle_case,
2230        // VM.3h: a plugin motion folds too (`zf]]`).
2231        builtins.create_fold,
2232    ]
2233}
2234
2235/// Derive the rows a grammar binding implies but nobody wrote: a motion's
2236/// Visual / Select / operator-pending peers, and a text object's.
2237///
2238/// ## Why this exists
2239///
2240/// A motion is an `nvo` command — vim's word for "lives in Normal, Visual and
2241/// operator-pending", and the shape paramount-goal #3 asks for when it says the
2242/// grammar IS the public command API. Lattice used to get that by HAND-LISTING
2243/// the same motions in four places: [`motion_rows`] consumed by
2244/// [`register_normal_bindings`], [`register_operator_bindings`],
2245/// `keymap_visual`, and `keymap_select`.
2246///
2247/// Four copies of a list drift, and they did, silently. `gg`, `f` / `F` / `t` /
2248/// `T`, `<C-d>` / `<C-u>` and `<PageUp>` / `<PageDown>` were all registered
2249/// straight into the Normal binder without ever reaching that table, so `vgg`,
2250/// `vf)`, `v<C-d>` and `dgg` were dead — not because Visual could not extend a
2251/// selection (it extends it from `Editor::cursor` at the end of every dispatch,
2252/// so ANY reachable cursor-mover works), but because the chord resolved to
2253/// nothing there. A plugin motion had it worse: `bind_mode_keymap` binds one
2254/// declared `binding-mode` and stops, so org's `[[` moved the cursor in Normal
2255/// and did nothing in Visual.
2256///
2257/// So the mode-set stops being a list and becomes a DERIVATION: whatever the
2258/// command *is* decides where it is live, uniformly for builtin, host-mode and
2259/// plugin bindings. Two mechanisms carry it (keymap-architecture.md §15): the
2260/// keymap mirrors a motion's Visual and Select rows at every write (VM.4), and
2261/// this pass adds the rows that need the operator vocabulary.
2262///
2263/// ## Why the operator half is here and not at bind time
2264///
2265/// Operator rows need `Builtins`, the host-resolved operator ids, which live
2266/// downstream of `lattice-plugin-host`, so `lattice-keymap` can't produce them.
2267/// The host runs the pass instead, once per layer, after that layer's bindings
2268/// exist. That framing is also the honest one: the host applies its UNIVERSAL
2269/// operator vocabulary to a contribution, exactly as it does for builtins,
2270/// while the contributor still declares only chord + command.
2271/// `register_operator_bindings` is `pub` for the same shape of reason (N.1.3,
2272/// the provider-contributed `zn` operator).
2273///
2274/// A motion's Visual and Select rows used to be written here too. They need
2275/// only the command's kind, which the keymap can ask, and a pass that runs at
2276/// two moments missed every write outside them: a re-pushed mode layer,
2277/// `init.rs`, plugin `register-binding`. VM.4 moved them into the keymap.
2278///
2279/// ## What it does per binding
2280///
2281/// Reads the layer's Normal trie, and for each terminal binding whose command
2282/// resolves in `commands` to:
2283///
2284/// * **`Motion`** — keeps the Normal binding (`]]` still moves on its own) and
2285///   adds `<op-prefix><chord>` for every composable operator. Its Visual and
2286///   Select rows come from the keymap itself (VM.4).
2287/// * **`TextObject`** — REPLACES the Normal binding, because a text object
2288///   invoked standalone in Normal means nothing, and adds a Visual row (`var`
2289///   extends the selection) plus `<op-prefix><chord>`. No Select row: a
2290///   text-object path starts with a printable, and in Select a printable
2291///   overtypes (select-mode.md §4).
2292/// * anything else — left alone. Note that a cursor-moving command registered
2293///   as `CommandKind::Action` is NOT a motion as far as this pass is concerned;
2294///   that is a statement about the command, not about the keymap.
2295///
2296/// ## Bind-if-absent
2297///
2298/// A derived row NEVER overwrites one somebody wrote deliberately. Visual's `x`
2299/// / `s` / `r` aliases, the find-char paths' `Args::Char` capture routing under
2300/// an operator, a mode's own Visual override — all of them are explicit
2301/// statements, and a default that clobbers them is worse than no default. The
2302/// pass snapshots each mode's existing paths first and only fills gaps, which
2303/// also makes it idempotent: re-running adds nothing, so a plugin reload cannot
2304/// accumulate rows and boot order stops mattering.
2305pub fn expand_grammar_rows(
2306    handle: &KeymapHandle,
2307    commands: &lattice_grammar::registry::CommandRegistry,
2308    builtins: &Builtins,
2309    layer: KeymapLayer,
2310) -> usize {
2311    let normal = handle.layer_bindings(layer, BindingMode::Normal);
2312    // Snapshot BEFORE any write: "already bound" must mean "bound by someone
2313    // else", not "bound by this loop two iterations ago".
2314    let mut occupied: HashMap<BindingMode, HashSet<Vec<ChordPattern>>> = HashMap::new();
2315    for mode in [BindingMode::Normal, BindingMode::Visual] {
2316        occupied.insert(
2317            mode,
2318            handle
2319                .layer_bindings(layer, mode)
2320                .into_iter()
2321                .map(|(path, _)| path)
2322                .collect(),
2323        );
2324    }
2325
2326    let mut added = 0usize;
2327    // A derived row is stamped with the source of the binding it was derived
2328    // FROM, never this pass's own: `dih` exists because org declared `ih`, and
2329    // `:describe-key dih` naming this file would hide the plugin that owns it.
2330    let mut bind_if_absent = |mode: BindingMode,
2331                              path: &[ChordPattern],
2332                              command: CommandInvocation,
2333                              from: &lattice_grammar::SourceLocation| {
2334        let taken = occupied.entry(mode).or_default();
2335        if !taken.insert(path.to_vec()) {
2336            return 0;
2337        }
2338        handle.bind(layer, mode, path, command, from.clone());
2339        1
2340    };
2341
2342    for (path, bound) in normal {
2343        let Some(spec) = commands.lookup(bound.command.command) else {
2344            continue;
2345        };
2346        let target = match spec.kind {
2347            lattice_grammar::CommandKind::Motion => Target::Motion(
2348                lattice_grammar::registry::MotionId(bound.command.command),
2349                Args::None,
2350            ),
2351            lattice_grammar::CommandKind::TextObject => Target::TextObject(
2352                lattice_grammar::registry::TextObjectId(bound.command.command),
2353                Args::None,
2354            ),
2355            _ => continue,
2356        };
2357        let is_text_object = matches!(target, Target::TextObject(..));
2358
2359        for op in composable_operators(builtins) {
2360            let prefix = operator_prefix(op, builtins);
2361            if prefix.is_empty() {
2362                continue;
2363            }
2364            let mut full: Vec<ChordPattern> =
2365                prefix.into_iter().map(ChordPattern::Literal).collect();
2366            full.extend(path.iter().cloned());
2367            added += bind_if_absent(
2368                BindingMode::Normal,
2369                &full,
2370                CommandInvocation::of(op.0).with_target(target.clone()),
2371                &bound.source,
2372            );
2373        }
2374
2375        if is_text_object {
2376            // A text object's Visual row. A motion's Visual and Select rows
2377            // aren't written here any more (VM.4): the keymap mirrors them at
2378            // every write, because they depend only on the command's kind.
2379            // Text objects stay here because this pass also REMOVES their
2380            // Normal row, and that's host policy, not a property of the kind.
2381            //
2382            // Visual only. A text-object path starts with a printable (`iw`,
2383            // `ar`), so a Select row would take the key meant to overtype the
2384            // selection (select-mode.md §4). The row is the Normal binding's
2385            // `CommandInvocation` verbatim; a bare text object goes through
2386            // `execute_text_object` and yields a `SelectionChange`.
2387            added += bind_if_absent(
2388                BindingMode::Visual,
2389                &path,
2390                bound.command.clone(),
2391                &bound.source,
2392            );
2393
2394            // Drop the Normal terminal binding `bind_mode_keymap` wrote: `ar`
2395            // alone in Normal is not a command a user can mean. Kind-driven,
2396            // not layer-driven — the builtin catalog binds no bare text object
2397            // in Normal, so this reaches nothing there.
2398            handle.unbind(layer, BindingMode::Normal, &path);
2399        }
2400    }
2401    added
2402}
2403
2404/// What [`lookup_normal_chord`] matched with, and the chord that produced it.
2405///
2406/// The chord matters as much as the result: a `Partial` is absorbed into
2407/// `App::partial_chord`, and absorbing a form the next lookup will not
2408/// reproduce strands the rest of the sequence.
2409pub(crate) struct NormalLookup {
2410    pub result: LookupResult,
2411    pub resolved: KeyChord,
2412}
2413
2414/// Look `prefix + chord` up RAW first, falling back to the normalized form.
2415///
2416/// OS.0c, and the exact shape OS.0b gave the Insert path — see
2417/// [`crate::keymap_insert::lookup_insert_chord`], whose rationale this
2418/// mirrors. `normalize_for_normal_lookup` strips ALT and SUPER off every
2419/// incoming chord, which was a true statement about the BUILTIN catalog and
2420/// was falsified by the `modes` WIT seam: a plugin or host mode may declare
2421/// `binding-mode: normal` on an ALT-bearing chord, register it correctly, and
2422/// then never fire, because the keypress was normalized away before the trie
2423/// ever saw it. Found by OS.4, whose `<M-CR>` bound cleanly in Normal and did
2424/// nothing; it equally blocked OS.6's and OS.7's meta-arrows.
2425///
2426/// The fallback runs only when the raw lookup found nothing AND normalizing
2427/// would actually change the chord, so a chord carrying neither ALT nor SUPER
2428/// costs exactly one lookup, as it always did.
2429///
2430/// `Partial` counts as a raw hit: an ALT/SUPER-bearing PREFIX is a deliberate
2431/// registration, and falling back mid-sequence would strand its continuation.
2432pub(crate) fn lookup_normal_chord(
2433    handle: &KeymapHandle,
2434    mode: BindingMode,
2435    prefix: &[KeyChord],
2436    chord: KeyChord,
2437    active_minor_modes: &[lattice_mode::ModeId],
2438) -> NormalLookup {
2439    let mut raw_path: Vec<KeyChord> = prefix.to_vec();
2440    raw_path.push(chord);
2441    let raw = handle.lookup_with_context(mode, &raw_path, active_minor_modes);
2442    if matches!(raw, LookupResult::Bound { .. } | LookupResult::Partial) {
2443        return NormalLookup {
2444            result: raw,
2445            resolved: chord,
2446        };
2447    }
2448    let normalized = normalize_for_normal_lookup(chord);
2449    if normalized == chord {
2450        // Nothing to fall back to -- the raw result (Unbound, since
2451        // Bound/Partial returned above) IS the answer.
2452        return NormalLookup {
2453            result: raw,
2454            resolved: chord,
2455        };
2456    }
2457    let mut normalized_path: Vec<KeyChord> = prefix.to_vec();
2458    normalized_path.push(normalized);
2459    NormalLookup {
2460        result: handle.lookup_with_context(mode, &normalized_path, active_minor_modes),
2461        resolved: normalized,
2462    }
2463}
2464
2465fn normalize_for_normal_lookup(chord: KeyChord) -> KeyChord {
2466    // Strip ALT and SUPER; preserve CTRL and SHIFT. Same
2467    // treatment as Insert mode -- legacy `translate_normal`
2468    // matched on `event.code` after the CTRL guard, so
2469    // non-CONTROL modifiers are transparent below the guard;
2470    // SHIFT on bare letters is already encoded in the case
2471    // by `KeyChord::from_event`.
2472    let mut mods = KeyMods::NONE;
2473    if chord.mods.ctrl() {
2474        mods = mods | KeyMods::CTRL;
2475    }
2476    if chord.mods.shift() {
2477        mods = mods | KeyMods::SHIFT;
2478    }
2479    KeyChord {
2480        key: chord.key,
2481        mods,
2482    }
2483}
2484
2485/// Pull the action out of a bound trie node, folding any
2486/// `CharLiteral` captures into the result. Slice 8.g.v: lookups
2487/// through wildcard children (`'a`, `"a`, `fX`, `mX`, `qX`,
2488/// `@X`, plus the operator-prefixed `dfX` etc.) capture the
2489/// matched char; this helper applies it to the placeholder
2490/// stashed in the bound action.
2491pub(crate) fn action_from_bound_with_capture(
2492    bound: &Arc<BoundCommand>,
2493    captured: &[char],
2494) -> Action {
2495    // Fold captured wildcard chars into the invocation's args.
2496    // Single-char capture (the common case: `fX`, `mX`, `rX`, …)
2497    // routes through `substitute_invocation_char_arg`. Multi-char
2498    // capture (e.g. surround's `cs{char1}{char2}` → two wildcards)
2499    // packs into `Args::List([ArgValue::Char(c), …])`.
2500    let mut inv = bound.command.clone();
2501    match captured.len() {
2502        0 => {}
2503        1 => {
2504            inv = substitute_invocation_char_arg(inv, captured[0]);
2505        }
2506        _ => {
2507            inv = inv.with_args(Args::List(
2508                captured.iter().map(|&c| ArgValue::Char(c)).collect(),
2509            ));
2510        }
2511    }
2512    Action::Invoke(inv)
2513}
2514
2515fn substitute_invocation_char_arg(
2516    mut inv: lattice_grammar::CommandInvocation,
2517    c: char,
2518) -> lattice_grammar::CommandInvocation {
2519    use lattice_grammar::args::Args;
2520    // Operator-targeted form: `df<X>` registers as
2521    // `Invoke(op, Target::Motion(find_char_*, Args::None))`.
2522    // Substitute the target's args.
2523    if let Some(Target::Motion(motion_id, Args::None)) = inv.target {
2524        inv = inv.with_target(Target::Motion(motion_id, Args::Char(c)));
2525        return inv;
2526    }
2527    // Standalone form: `f<X>` registers as
2528    // `Invoke(find_char_*, Args::None)`. Substitute the
2529    // invocation's args.
2530    if matches!(inv.args, Args::None) {
2531        inv = inv.with_args(Args::Char(c));
2532    }
2533    inv
2534}
2535
2536fn lit_char(c: char) -> ChordPattern {
2537    ChordPattern::Literal(KeyChord::char(c))
2538}
2539
2540fn lit_special(s: SpecialKey) -> ChordPattern {
2541    ChordPattern::Literal(KeyChord::special(s))
2542}
2543
2544fn source() -> SourceLocation {
2545    SourceLocation::builtin_file(file!(), line!())
2546}
2547
2548// ── TSM.4: host wiring for the 16 tree-sitter structural motions ──
2549//
2550// Strategy (B) from the task brief: `scope_toward` (the tree walk itself)
2551// is already unit-tested end-to-end at the `lattice-syntax` level
2552// (`syntax.rs`'s `scope_toward_*` tests, TSM.2/3). No existing host test
2553// harness attaches a live `SyntaxSnapshot` to an `Editor` and drives full
2554// multi-key chords through `dispatch_blocking` (that would need a tokio
2555// document actor + the full `TranslateContext`, real machinery this slice
2556// doesn't otherwise touch) -- so these tests verify THIS slice's actual
2557// deliverable directly: that `]f`/`[f`/... resolve through the keymap trie
2558// to the right `CommandInvocation` in Normal, operator-pending, and
2559// Visual. This proves the wiring without needing a live tree; it does NOT
2560// prove `scope_toward` itself walks correctly (already covered elsewhere).
2561#[cfg(test)]
2562mod syntax_motion_tests {
2563    // `panic!` in a test match arm is the idiomatic failure path here (a
2564    // `LookupResult` that isn't `Bound` means the wiring is broken); the
2565    // workspace `clippy::panic = warn` restriction lint targets production
2566    // code, so allow it in this test module (same convention as the
2567    // lattice-ui-tui keymap test modules).
2568    #![allow(clippy::panic)]
2569    use super::*;
2570    use lattice_grammar::CommandRegistry;
2571    use lattice_grammar::builtins::populate as grammar_builtins_populate;
2572    use lattice_syntax::SyntaxMotionIds;
2573
2574    use crate::keymap_trie::LookupResult;
2575
2576    /// The COMPLETE keymap drift test: every command a default binding
2577    /// claims must exist in a registry populated the way boot populates it.
2578    ///
2579    /// `lattice-keymap` has a version of this, but it must skip `action:`
2580    /// names — host actions are registered by `crate::actions::populate`,
2581    /// and that crate sits below this one. This is the half that sees both,
2582    /// so it is the one that can actually catch a binding pointing at
2583    /// nothing.
2584    ///
2585    /// It exists because the split version did not exist: PBH added `<C-6>`
2586    /// and `<C-7>`, the first `action:` entries in the default keymap, and
2587    /// the keymap-crate test failed on them for months. A test that fails
2588    /// for a structural reason stops being read, and then it is not a test.
2589    #[test]
2590    fn every_default_keymap_command_is_registered() {
2591        let mut registry = CommandRegistry::new();
2592        let builtins = grammar_builtins_populate(&mut registry);
2593        let _ = lattice_grammar::ex_commands::populate(&mut registry);
2594        let _ = crate::actions::populate(&mut registry, &builtins);
2595        let _ = lattice_syntax::register_syntax_text_objects(&mut registry);
2596        let _ = lattice_syntax::register_syntax_motions(&mut registry);
2597
2598        let mut missing: Vec<String> = Vec::new();
2599        let mut checked = 0usize;
2600        for entry in lattice_keymap::keymap_entry::default_keymap() {
2601            let Some(name) = entry.command else { continue };
2602            checked += 1;
2603            if registry.id_by_name(name).is_none() {
2604                missing.push(format!(
2605                    "`{}` ({}) claims `{name}`",
2606                    entry.chord,
2607                    entry.modes_label()
2608                ));
2609            }
2610        }
2611        assert!(
2612            missing.is_empty(),
2613            "default keymap bindings point at unregistered commands:\n  {}",
2614            missing.join("\n  ")
2615        );
2616        // Guard the guard: if `default_keymap` ever stopped carrying command
2617        // names, the loop above would pass vacuously.
2618        assert!(
2619            checked > 50,
2620            "expected the default keymap to carry many commands, saw {checked}"
2621        );
2622    }
2623
2624    /// Build a handle with Normal (+ operator-pending, which rides under
2625    /// Normal) and Visual registered from a real, populated command
2626    /// registry -- the same path boot takes (`editor_boot.rs`).
2627    fn populated_handle() -> (KeymapHandle, SyntaxMotionIds) {
2628        let mut registry = CommandRegistry::new();
2629        let builtins = grammar_builtins_populate(&mut registry);
2630        let action_ids = crate::actions::populate(&mut registry, &builtins);
2631        let syntax_textobjects = lattice_syntax::register_syntax_text_objects(&mut registry);
2632        let syntax_motions = lattice_syntax::register_syntax_motions(&mut registry);
2633        let registry = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(registry));
2634        let h = KeymapHandle::new();
2635        // VM.4: as boot does, so Visual and Select get their motions from the
2636        // keymap's mirror at every write below.
2637        h.set_command_registry(registry.clone());
2638        crate::keymap_visual::register_visual_bindings(
2639            &h,
2640            &builtins,
2641            &action_ids,
2642            &syntax_textobjects,
2643        );
2644        register_normal_bindings(
2645            &h,
2646            &builtins,
2647            &action_ids,
2648            &syntax_textobjects,
2649            &syntax_motions,
2650        );
2651        // The operator-pending rows, as boot adds them.
2652        expand_grammar_rows(&h, &registry.load(), &builtins, KeymapLayer::Builtin);
2653        (h, syntax_motions)
2654    }
2655
2656    /// **RF.2: `[g, q]` and `[g, w]` must stay INTERNAL nodes.**
2657    ///
2658    /// A node carrying a terminal binding resolves as `Bound` before the
2659    /// walk descends, so a depth-2 `gq` binding kills every longer chord
2660    /// under it — `gqq`, `gqap`, `gqi(` all become unreachable, and
2661    /// silently: the trie still answers `Bound` for them, because the
2662    /// binding is there; nothing but a real keystroke walks the prefix.
2663    /// That is exactly what the first cut of RF.2 did, and what
2664    /// `a-bound-prefix-kills-its-longer-chords` records.
2665    ///
2666    /// Asserted as a *structural* property rather than by pressing keys
2667    /// because the keystroke tests live a crate away, and this is the
2668    /// invariant they depend on.
2669    ///
2670    /// It doubles as the guard for `magit-blame-mode`'s `gq` (stop
2671    /// blaming). That shadow is deliberate and safe — a MajorMode layer
2672    /// resolves before Builtin, and a blame buffer is read-only — but it
2673    /// is only *clean* while Builtin leaves `[g, q]` un-terminated.
2674    #[test]
2675    fn gq_and_gw_stay_internal_nodes_so_their_longer_chords_survive() {
2676        let (h, _) = populated_handle();
2677        for prefix in ['q', 'w'] {
2678            let two = [KeyChord::char('g'), KeyChord::char(prefix)];
2679            assert!(
2680                matches!(h.lookup(BindingMode::Normal, &two), LookupResult::Partial),
2681                "`g{prefix}` must be PARTIAL — a terminal here shadows every \
2682                 longer reflow chord, and does it silently"
2683            );
2684            // …and the doubled form really is reachable underneath.
2685            let three = [
2686                KeyChord::char('g'),
2687                KeyChord::char(prefix),
2688                KeyChord::char(prefix),
2689            ];
2690            assert!(
2691                matches!(
2692                    h.lookup(BindingMode::Normal, &three),
2693                    LookupResult::Bound { .. }
2694                ),
2695                "`g{prefix}{prefix}` must be bound"
2696            );
2697        }
2698    }
2699
2700    /// RF.6: `g=` is bound, and `[g, =]` is an internal node for the
2701    /// same reason `[g, q]` is — a depth-2 terminal there would kill
2702    /// `g==`, `g=ap` and `g=i{` silently.
2703    #[test]
2704    fn g_equals_is_bound_and_keeps_its_longer_chords() {
2705        let (h, _) = populated_handle();
2706        assert!(
2707            matches!(
2708                h.lookup(
2709                    BindingMode::Normal,
2710                    &[KeyChord::char('g'), KeyChord::char('=')]
2711                ),
2712                LookupResult::Partial
2713            ),
2714            "`g=` must be PARTIAL, not a terminal"
2715        );
2716        assert!(matches!(
2717            h.lookup(
2718                BindingMode::Normal,
2719                &[
2720                    KeyChord::char('g'),
2721                    KeyChord::char('='),
2722                    KeyChord::char('=')
2723                ]
2724            ),
2725            LookupResult::Bound { .. }
2726        ));
2727    }
2728
2729    /// `g=` and `=` are DIFFERENT operators. `=` re-indents; `g=` runs a
2730    /// formatter. Collapsing them is the thing auto-indent.md §7 spends
2731    /// its length refusing.
2732    #[test]
2733    fn g_equals_is_not_the_same_operator_as_equals() {
2734        let (h, _) = populated_handle();
2735        let id = |chords: &[KeyChord]| match h.lookup(BindingMode::Normal, chords) {
2736            LookupResult::Bound { command, .. } => command.command.command,
2737            other => panic!("must be bound, got {other:?}"),
2738        };
2739        let reindent = id(&[KeyChord::char('='), KeyChord::char('=')]);
2740        let reformat = id(&[
2741            KeyChord::char('g'),
2742            KeyChord::char('='),
2743            KeyChord::char('='),
2744        ]);
2745        assert_ne!(reindent, reformat);
2746    }
2747
2748    /// `gq` and `gw` resolve to the SAME operator — one verb, two
2749    /// spellings. If these ever diverge, vim's cursor-placement
2750    /// distinction has crept back in as two operators.
2751    #[test]
2752    fn gq_and_gw_resolve_to_one_operator() {
2753        let (h, _) = populated_handle();
2754        let id = |c: char| match h.lookup(
2755            BindingMode::Normal,
2756            &[KeyChord::char('g'), KeyChord::char(c), KeyChord::char(c)],
2757        ) {
2758            LookupResult::Bound { command, .. } => command.command.command,
2759            other => panic!("g{c}{c} must be bound, got {other:?}"),
2760        };
2761        assert_eq!(id('q'), id('w'));
2762    }
2763
2764    #[test]
2765    fn bracket_f_resolves_to_next_function_start_in_normal() {
2766        let (h, syntax_motions) = populated_handle();
2767        let chords = [KeyChord::char(']'), KeyChord::char('f')];
2768        match h.lookup(BindingMode::Normal, &chords) {
2769            LookupResult::Bound { command, .. } => {
2770                assert_eq!(
2771                    command.command.command, syntax_motions.next_function_start.0,
2772                    "]f must resolve to motion:next-function-start"
2773                );
2774            }
2775            other => panic!("]f must be BOUND in Normal, got {other:?}"),
2776        }
2777    }
2778
2779    #[test]
2780    fn all_sixteen_bracket_chords_resolve_in_normal() {
2781        let (h, syntax_motions) = populated_handle();
2782        for (seq, motion) in syntax_motion_rows(&syntax_motions) {
2783            let chords: Vec<KeyChord> = seq
2784                .into_iter()
2785                .map(|p| match p {
2786                    ChordPattern::Literal(c) => c,
2787                    _ => panic!("syntax_motion_rows must be all literals"),
2788                })
2789                .collect();
2790            match h.lookup(BindingMode::Normal, &chords) {
2791                LookupResult::Bound { command, .. } => {
2792                    assert_eq!(command.command.command, motion.0, "{chords:?} mismatch");
2793                }
2794                other => panic!("{chords:?} must be BOUND in Normal, got {other:?}"),
2795            }
2796        }
2797    }
2798
2799    #[test]
2800    fn g_slash_resolves_to_search_operator_prefix_in_normal() {
2801        // `g/` is armed via the absorb-action mechanism (like gU/gu/g~),
2802        // so the trie resolves the two-key chord to the search-operator
2803        // prefix action; the operator-pending + motion is a runtime step.
2804        let mut registry = CommandRegistry::new();
2805        let builtins = grammar_builtins_populate(&mut registry);
2806        let action_ids = crate::actions::populate(&mut registry, &builtins);
2807        let syntax_textobjects = lattice_syntax::register_syntax_text_objects(&mut registry);
2808        let syntax_motions = lattice_syntax::register_syntax_motions(&mut registry);
2809        let h = KeymapHandle::new();
2810        register_normal_bindings(
2811            &h,
2812            &builtins,
2813            &action_ids,
2814            &syntax_textobjects,
2815            &syntax_motions,
2816        );
2817        let chords = [KeyChord::char('g'), KeyChord::char('/')];
2818        match h.lookup(BindingMode::Normal, &chords) {
2819            LookupResult::Bound { command, .. } => {
2820                assert_eq!(
2821                    command.command.command, action_ids.absorb_operator_search,
2822                    "g/ must resolve to the search-operator prefix action"
2823                );
2824            }
2825            other => panic!("g/ must be BOUND in Normal, got {other:?}"),
2826        }
2827    }
2828
2829    #[test]
2830    fn g_slash_iw_resolves_to_search_operator_over_inner_word() {
2831        // Regression: arming `g/` is not enough. The runtime maps the
2832        // operator id back to its chord prefix (`operator_prefix`) and the
2833        // trie must carry the `[g, /, i, w]` cross-product
2834        // (`register_operator_bindings`). Missing either → `g/{motion}`
2835        // arms with an empty prefix and does nothing.
2836        let mut registry = CommandRegistry::new();
2837        let builtins = grammar_builtins_populate(&mut registry);
2838        let action_ids = crate::actions::populate(&mut registry, &builtins);
2839        let syntax_textobjects = lattice_syntax::register_syntax_text_objects(&mut registry);
2840        let syntax_motions = lattice_syntax::register_syntax_motions(&mut registry);
2841        let h = KeymapHandle::new();
2842        register_normal_bindings(
2843            &h,
2844            &builtins,
2845            &action_ids,
2846            &syntax_textobjects,
2847            &syntax_motions,
2848        );
2849
2850        // 1. operator_prefix knows the search operator's chord prefix.
2851        assert_eq!(
2852            operator_prefix(builtins.search, &builtins),
2853            vec![KeyChord::char('g'), KeyChord::char('/')],
2854            "operator_prefix must map the search operator back to [g, /]"
2855        );
2856
2857        // 2. With the operator armed (partial = [g, /]), `i` is a partial
2858        //    continuation and `w` completes to Invoke(search, iw).
2859        let prefix = [KeyChord::char('g'), KeyChord::char('/')];
2860        match lookup_normal_with_prefix(&h, &prefix, &KeyChord::char('i'), &[]) {
2861            Action::AbsorbPartialChord(c) => assert_eq!(c, KeyChord::char('i')),
2862            other => panic!("g/ + i must be a partial chord, got {other:?}"),
2863        }
2864        let prefix_i = [
2865            KeyChord::char('g'),
2866            KeyChord::char('/'),
2867            KeyChord::char('i'),
2868        ];
2869        match lookup_normal_with_prefix(&h, &prefix_i, &KeyChord::char('w'), &[]) {
2870            Action::Invoke(inv) => {
2871                assert_eq!(
2872                    inv.command, builtins.search.0,
2873                    "g/iw must invoke the search operator"
2874                );
2875                match inv.target {
2876                    Some(Target::TextObject(to, _)) => assert_eq!(
2877                        to, builtins.inner_word,
2878                        "g/iw must target the inner-word text object"
2879                    ),
2880                    other => panic!("g/iw target must be a TextObject, got {other:?}"),
2881                }
2882            }
2883            other => panic!("g/iw must Invoke the search operator, got {other:?}"),
2884        }
2885    }
2886
2887    #[test]
2888    fn d_bracket_f_resolves_operator_pending_to_target_motion() {
2889        let (h, syntax_motions) = populated_handle();
2890        let chords = [
2891            KeyChord::char('d'),
2892            KeyChord::char(']'),
2893            KeyChord::char('f'),
2894        ];
2895        match h.lookup(BindingMode::Normal, &chords) {
2896            LookupResult::Bound { command, .. } => {
2897                assert_eq!(
2898                    command.command.target,
2899                    Some(Target::Motion(
2900                        syntax_motions.next_function_start,
2901                        Args::None
2902                    )),
2903                    "d]f must resolve to Invoke(delete, Target::Motion(next_function_start))"
2904                );
2905            }
2906            other => panic!("d]f must be BOUND in operator-pending, got {other:?}"),
2907        }
2908    }
2909
2910    #[test]
2911    fn bracket_f_resolves_in_visual() {
2912        let (h, syntax_motions) = populated_handle();
2913        let chords = [KeyChord::char(']'), KeyChord::char('f')];
2914        match h.lookup(BindingMode::Visual, &chords) {
2915            LookupResult::Bound { command, .. } => {
2916                assert_eq!(
2917                    command.command.command, syntax_motions.next_function_start.0,
2918                    "]f must resolve to motion:next-function-start in Visual"
2919                );
2920            }
2921            other => panic!("]f must be BOUND in Visual, got {other:?}"),
2922        }
2923    }
2924}
2925
2926/// OS.0c regression tests: `lookup_normal`'s raw-then-fallback fix must reach
2927/// an ALT-bearing Normal binding, and must not change any behaviour that
2928/// worked before it.
2929///
2930/// The sibling of `keymap_insert::os0b_tests`. OS.0b fixed the Insert path and
2931/// `keymap_select`'s reuse of it; `normalize_for_normal_lookup` was a separate
2932/// copy of the same defect and survived, which is why OS.4's `<M-CR>` bound
2933/// cleanly in Normal and did nothing at all.
2934#[cfg(test)]
2935mod os0c_tests {
2936    #![allow(clippy::unwrap_used, clippy::panic)]
2937    use super::*;
2938    use crate::keymap_trie::{ChordPattern, KeymapLayer};
2939    use lattice_mode::ModeId;
2940    use lattice_protocol::ids::CommandId;
2941
2942    fn source() -> SourceLocation {
2943        SourceLocation::builtin_file(file!(), line!())
2944    }
2945
2946    fn lit(chord: KeyChord) -> ChordPattern {
2947        ChordPattern::Literal(chord)
2948    }
2949
2950    fn alt(key: KeyKind) -> KeyChord {
2951        KeyChord::new(key, KeyMods::ALT)
2952    }
2953
2954    fn bound_command(result: &LookupResult) -> CommandId {
2955        match result {
2956            LookupResult::Bound { command, .. } => command.command.command,
2957            other => panic!("expected Bound, got {other:?}"),
2958        }
2959    }
2960
2961    /// The acceptance criterion: a mode declaring an ALT-bearing Normal chord
2962    /// must have it reach the trie AS PRESSED. This is org's `<M-CR>`, and the
2963    /// same shape as every meta-arrow OS.6 and OS.7 add.
2964    #[test]
2965    fn an_alt_bearing_normal_binding_is_reachable() {
2966        let h = KeymapHandle::new();
2967        let mode_id = ModeId::new("os0c-alt-mode");
2968        let command = CommandId::new(u64::MAX - 11);
2969        let chord = alt(KeyKind::Special(SpecialKey::Enter));
2970        h.bind(
2971            KeymapLayer::MajorMode(mode_id),
2972            BindingMode::Normal,
2973            &[lit(chord)],
2974            CommandInvocation::of(command),
2975            source(),
2976        );
2977
2978        let found = lookup_normal_chord(&h, BindingMode::Normal, &[], chord, &[mode_id]);
2979        assert_eq!(
2980            bound_command(&found.result),
2981            command,
2982            "<M-CR> must reach the binding that claims it, not be stripped to <CR> first"
2983        );
2984        assert_eq!(
2985            found.resolved, chord,
2986            "the raw chord won, so that is what a Partial would absorb"
2987        );
2988    }
2989
2990    /// The other half: with NOTHING claiming the ALT form, the fallback must
2991    /// still find the normalized binding. This is what worked before OS.0c and
2992    /// must keep working — it is the reason the strip existed at all.
2993    #[test]
2994    fn an_unclaimed_alt_chord_still_falls_back_to_the_plain_binding() {
2995        let h = KeymapHandle::new();
2996        let mode_id = ModeId::new("os0c-plain-mode");
2997        let command = CommandId::new(u64::MAX - 12);
2998        let plain = KeyChord::special(SpecialKey::Enter);
2999        h.bind(
3000            KeymapLayer::MajorMode(mode_id),
3001            BindingMode::Normal,
3002            &[lit(plain)],
3003            CommandInvocation::of(command),
3004            source(),
3005        );
3006
3007        let found = lookup_normal_chord(
3008            &h,
3009            BindingMode::Normal,
3010            &[],
3011            alt(KeyKind::Special(SpecialKey::Enter)),
3012            &[mode_id],
3013        );
3014        assert_eq!(
3015            bound_command(&found.result),
3016            command,
3017            "<M-CR> with nothing claiming it must still fall back to <CR>"
3018        );
3019        assert_eq!(
3020            found.resolved, plain,
3021            "the NORMALIZED chord won, and that is the form a Partial must absorb"
3022        );
3023    }
3024
3025    /// A chord carrying neither ALT nor SUPER must not pay for the fallback:
3026    /// raw == normalized, so the first lookup is the only lookup and its
3027    /// answer is returned unchanged.
3028    #[test]
3029    fn a_plain_chord_resolves_to_itself() {
3030        let h = KeymapHandle::new();
3031        let chord = KeyChord::new(KeyKind::Char('d'), KeyMods::NONE);
3032        let found = lookup_normal_chord(&h, BindingMode::Normal, &[], chord, &[]);
3033        assert!(matches!(found.result, LookupResult::Unbound));
3034        assert_eq!(found.resolved, chord);
3035    }
3036
3037    /// An ALT-bearing chord as the SECOND key of a sequence must reach its
3038    /// binding too. Before the fix the continuation was normalized before ever
3039    /// being appended to the path, so a two-chord ALT binding died at its own
3040    /// prefix even though it registered correctly.
3041    #[test]
3042    fn an_alt_bearing_continuation_resolves_under_its_prefix() {
3043        let h = KeymapHandle::new();
3044        let mode_id = ModeId::new("os0c-two-chord-mode");
3045        let command = CommandId::new(u64::MAX - 13);
3046        let g = KeyChord::new(KeyKind::Char('g'), KeyMods::NONE);
3047        let alt_o = alt(KeyKind::Char('o'));
3048        h.bind(
3049            KeymapLayer::MajorMode(mode_id),
3050            BindingMode::Normal,
3051            &[lit(g), lit(alt_o)],
3052            CommandInvocation::of(command),
3053            source(),
3054        );
3055
3056        let found = lookup_normal_chord(&h, BindingMode::Normal, &[g], alt_o, &[mode_id]);
3057        assert_eq!(
3058            bound_command(&found.result),
3059            command,
3060            "the ALT-bearing second chord must resolve under its prefix"
3061        );
3062    }
3063}