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, ®istry.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}