Skip to main content

lattice_host/
keymap_select.rs

1//! Select-mode dispatch (SN.3d.1).
2//!
3//! Select mode (`ModalState::Select(VisualKind)`) is Visual's sibling:
4//! the same selection *geometry*, inverted *typing* semantics. A bare
5//! printable key **replaces the whole selection with that char and
6//! drops into Insert** ([`Action::SelectOvertype`]); motions that can't be
7//! typed extend the selection exactly as in Visual. See
8//! `docs/dev/architecture/select-mode.md`.
9//!
10//! ## Why this is genuinely new dispatch, not "`dispatch_visual` + a flag"
11//!
12//! [`crate::keymap_visual::dispatch_visual`] has **no** printable
13//! fallthrough — an unbound printable in Visual is a no-op. The defining
14//! Select behaviour is exactly that fallthrough: an unbound printable
15//! overtypes. The reference for the fallthrough is
16//! [`crate::keymap_insert`]'s `literal_text_fallback` (CTRL → `None`,
17//! `Char(c)` → an edit), mapped here to the replace-and-insert edit
18//! (select-mode.md §3) rather than a plain insert.
19//!
20//! ## Dispatch order
21//!
22//! 1. **Mode-control chords** (fire regardless of the binding table):
23//!    `<Esc>` → [`Action::ExitSelect`]; `<C-g>` →
24//!    [`Action::ToggleVisualSelect`] (toggle back to Visual, selection
25//!    preserved); `<C-o>` → one-shot Normal — *recognised but post-MVP*
26//!    per select-mode.md §3, swallowed (`Action::None`) so a stray
27//!    `<C-o>` never overtypes a literal char.
28//! 2. **Mid-sequence** (the prefix of a multi-key Select binding a mode
29//!    contributed, already absorbed into `partial_chord`) → resolve
30//!    `[partial..., chord]` against the `BindingMode::Select` table — the
31//!    same partial-chord machinery Normal/Visual use.
32//! 3. **Fresh chord** → `BindingMode::Select` lookup. `Bound` →
33//!    its action (motion extends / exit); `Partial` → absorb;
34//!    `Unbound` → the overtype fallthrough.
35//!
36//! A bound key wins over the fallthrough, so what the Select table binds
37//! decides what can be typed. The table holds only keys that can't be:
38//! the keymap's motion mirror (VM.4) admits a motion only when its first
39//! chord wouldn't overtype, using [`lattice_keymap::overtypes_in_select`],
40//! the same predicate the fallthrough calls. Nothing else binds a bare
41//! printable here (VM.5): vim's Select has no swap-ends `o` and no text
42//! objects either, because those keys are typed text. `<C-g>` flips to
43//! Visual for both.
44
45use lattice_grammar::VisualKind;
46
47use lattice_mode::mode::ModeId;
48
49use crate::action::Action;
50use crate::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
51use crate::keymap::BindingMode;
52use crate::keymap_registry::KeymapHandle;
53use crate::keymap_trie::{KeymapLayer, LookupResult};
54
55/// Dispatch a Select-mode key event. See the module docs for the
56/// ordering contract. `partial_chord` is the host's running multi-key
57/// prefix (empty on a fresh chord; holds an absorbed prefix mid-sequence),
58/// identical to the Visual path.
59pub fn translate_select(
60    handle: &KeymapHandle,
61    chord: &KeyChord,
62    _kind: VisualKind,
63    partial_chord: &[KeyChord],
64    active_minor_modes: &[ModeId],
65) -> Action {
66    // 0. SN.3d.4: active minor-mode bindings own the chord first —
67    //    the same `KeymapLayer::MinorMode` consultation Insert mode
68    //    does (`dispatch_insert`), now wired for Select. A snippet
69    //    placeholder focused in Select keeps `<Tab>` / `<S-Tab>`
70    //    (navigate, keeping the default) and `<Esc>` (leave the
71    //    snippet — a `fall_through` binding that then runs the native
72    //    `<Esc>` = `ExitSelect`) live. Without this, those bindings
73    //    were dead the moment a default-bearing placeholder selected,
74    //    because Select dispatch never consulted minor layers and its
75    //    `<Esc>` was hardcoded below. We intercept ONLY a winner that
76    //    lives on a minor layer; a base-table `Bound` is a motion /
77    //    text-object that `native_select_action` resolves.
78    if let Some(action) = minor_select_action(handle, chord, partial_chord, active_minor_modes) {
79        return action;
80    }
81    native_select_action(handle, chord, partial_chord)
82}
83
84/// SN.3d.4: the native (minor-free) Select dispatch — the original
85/// `translate_select` body. Resolves the hardcoded mode-control chords,
86/// the base `BindingMode::Select` motion / text-object table, and the
87/// overtype fallthrough. Used both as the normal path (when
88/// no active minor binding claims the chord) AND as the `fall_through`
89/// continuation for a minor `<Esc>` (mode action THEN `ExitSelect`).
90/// Being minor-free, it cannot re-enter `minor_select_action`, so the
91/// fall-through never loops or fires the mode action twice.
92fn native_select_action(
93    handle: &KeymapHandle,
94    chord: &KeyChord,
95    partial_chord: &[KeyChord],
96) -> Action {
97    // 1. Mode-control chords. `<Esc>` exits to Normal even mid-
98    //    text-object (abandons any absorbed prefix — there are no
99    //    Select multi-key chords yet, so this is a no-op in practice).
100    if matches!(chord.key, KeyKind::Special(SpecialKey::Esc)) {
101        return Action::ExitSelect;
102    }
103    if chord.mods.ctrl() {
104        match chord.key {
105            // `<C-g>` is reserved in both Visual and Select for the
106            // toggle (select-mode.md §4). One handler flips whichever
107            // is active, preserving the selection geometry.
108            KeyKind::Char('g') => return Action::ToggleVisualSelect,
109            // `<C-o>` one-shot Normal — vim parity, post-MVP
110            // (select-mode.md §3). Swallow so it never overtypes.
111            KeyKind::Char('o') => return Action::None,
112            // CG.1 (2026-08-07): the `_ => return Action::None` catch-all
113            // that used to close this match is GONE, mirroring the same
114            // removal in `dispatch_visual` — see the comment there for
115            // why (it made every CTRL binding in this mode, including
116            // any a plugin registers over WIT, structurally unreachable).
117            //
118            // The two arms above stay hardcoded because both are mode
119            // *control*, not command lookup. Everything else falls
120            // through to the trie. A bare CTRL chord with no binding
121            // still ends at `Action::None` — via the lookup below, which
122            // is the difference that matters.
123            _ => {}
124        }
125    }
126
127    // 2. Mid-sequence resolution against the Select table.
128    if !partial_chord.is_empty() {
129        let chord = normalize_for_select_lookup(*chord);
130        let mut path: Vec<KeyChord> = partial_chord.to_vec();
131        path.push(chord);
132        return match handle.lookup(BindingMode::Select, &path) {
133            LookupResult::Bound { command, captured } => {
134                crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
135            }
136            LookupResult::Partial => Action::AbsorbPartialChord(chord),
137            LookupResult::Unbound => Action::None,
138        };
139    }
140
141    // 3. Fresh chord. A bound motion / exit / text-object prefix wins;
142    //    an UNBOUND key that overtypes falls through to overtype.
143    let looked_up = normalize_for_select_lookup(*chord);
144    match handle.lookup(BindingMode::Select, &[looked_up]) {
145        LookupResult::Bound { command, captured } => {
146            crate::keymap_normal::action_from_bound_with_capture(&command, &captured)
147        }
148        LookupResult::Partial => Action::AbsorbPartialChord(looked_up),
149        LookupResult::Unbound => printable_overtype_fallback(chord),
150    }
151}
152
153/// SN.3d.4: resolve an active minor-mode binding for the chord in
154/// Select mode, or `None` to defer to `native_select_action`.
155///
156/// Mirrors `dispatch_insert`'s minor-layer consultation: look the chord
157/// up WITH the active minor set, but act only when the winner lives on
158/// a `KeymapLayer::MinorMode` layer — a `Bound` on the base Select
159/// table is a motion / text-object the native path owns. A
160/// `fall_through` minor binding (the snippet `<Esc>`) runs its mode
161/// action and then chains the native Select action for the same chord.
162fn minor_select_action(
163    handle: &KeymapHandle,
164    chord: &KeyChord,
165    partial_chord: &[KeyChord],
166    active_minor_modes: &[ModeId],
167) -> Option<Action> {
168    if active_minor_modes.is_empty() {
169        return None;
170    }
171    // Minor bindings are keyed like Insert's (keep CTRL + SHIFT) so
172    // `<S-Tab>` stays distinct from `<Tab>`; the base-Select normalize
173    // strips SHIFT and would collapse the two.
174    //
175    // OS.0b: raw-then-fallback, same as `dispatch_insert` — try the
176    // chord AS PRESSED first so a mode that deliberately binds an
177    // ALT/SUPER-bearing chord in Select is reachable, falling back to
178    // the normalized form only when the raw lookup finds nothing.
179    let lookup = crate::keymap_insert::lookup_insert_chord(
180        handle,
181        BindingMode::Select,
182        partial_chord,
183        *chord,
184        active_minor_modes,
185    );
186    let LookupResult::Bound { command, captured } = lookup.result else {
187        return None;
188    };
189    // Only a minor-layer winner is mode-owned; a base-table `Bound`
190    // defers to `native_select_action`.
191    if !matches!(command.layer, KeymapLayer::MinorMode(_)) {
192        return None;
193    }
194    let action = crate::keymap_normal::action_from_bound_with_capture(&command, &captured);
195    if !command.fall_through {
196        return Some(action);
197    }
198    // `fall_through`: mode action, then the NATIVE continuation for the
199    // chord (`<Esc>` → `ExitSelect`). Native is minor-free, so no loop.
200    Some(crate::keymap_insert::chain_actions(
201        action,
202        native_select_action(handle, chord, partial_chord),
203    ))
204}
205
206/// The Select fallthrough: a key that overtypes replaces the selection.
207/// Mirrors [`crate::keymap_insert`]'s `literal_text_fallback`, but maps
208/// the key to [`Action::SelectOvertype`] (replace-and-insert) instead of a
209/// plain insert.
210///
211/// Which keys overtype is [`lattice_keymap::overtypes_in_select`], the ONE
212/// definition the keymap's motion mirror also calls (VM.4), so the Select
213/// table and Select typing can't disagree about it. Vim's rule: "Printable
214/// characters, <NL> and <CR> cause the selection to be deleted, and Vim
215/// enters Insert mode." Both `<CR>` and `<NL>` (Ctrl-J) type a newline.
216fn printable_overtype_fallback(chord: &KeyChord) -> Action {
217    // CG.1: the modifier check lives in the predicate, not in the caller.
218    // `<C-w>` is a chord, not typing, and must never replace the user's
219    // selection with a `w`. That used to be a blanket `return Action::None`
220    // for every CTRL chord at the top of `native_select_action`, which also
221    // made the Select trie unreachable for CTRL bindings (see the comment
222    // there). Checking here keeps the guarantee and lets a real binding win
223    // first — same shape as Replace mode, whose wildcard only matches bare
224    // printable chars.
225    if !lattice_keymap::overtypes_in_select(chord) {
226        return Action::None;
227    }
228    match chord.key {
229        KeyKind::Special(SpecialKey::Enter) => Action::SelectOvertype('\n'),
230        // `<NL>`: the predicate admits `j` with Ctrl only as Ctrl-J.
231        KeyKind::Char('j') if chord.mods.ctrl() => Action::SelectOvertype('\n'),
232        KeyKind::Char(c) => Action::SelectOvertype(c),
233        _ => Action::None,
234    }
235}
236
237/// Strip SHIFT / ALT / SUPER for the Select trie lookup — same
238/// treatment as the Visual path (`keymap_visual::normalize_for_visual_lookup`):
239/// the catalog binds bare chords only; CONTROL is filtered by the
240/// caller before this runs.
241fn normalize_for_select_lookup(chord: KeyChord) -> KeyChord {
242    KeyChord {
243        key: chord.key,
244        mods: chord
245            .mods
246            .without(KeyMods::SHIFT)
247            .without(KeyMods::ALT)
248            .without(KeyMods::SUPER),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::keymap_trie::ChordPattern;
256
257    fn empty_handle() -> KeymapHandle {
258        // The dispatch tests below run against an EMPTY Select table, so
259        // every lookup is `Unbound` — a fresh printable overtypes and the
260        // control chords fire. The parity tests use a fully POPULATED
261        // handle (`populated_handle`).
262        KeymapHandle::new()
263    }
264
265    /// Build a handle from a real, populated command registry, the same path
266    /// boot takes (`editor_boot.rs`). Select has no binder of its own: its
267    /// table is whatever the keymap's motion mirror writes.
268    fn populated_handle() -> KeymapHandle {
269        use lattice_grammar::CommandRegistry;
270        use lattice_grammar::builtins::populate as grammar_builtins_populate;
271        let mut registry = CommandRegistry::new();
272        let builtins = grammar_builtins_populate(&mut registry);
273        let action_ids = crate::actions::populate(&mut registry, &builtins);
274        let syntax_textobjects = lattice_syntax::register_syntax_text_objects(&mut registry);
275        let syntax_motions = lattice_syntax::register_syntax_motions(&mut registry);
276        let registry = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(registry));
277        let h = KeymapHandle::new();
278        // VM.4: as boot does. With a registry the keymap mirrors motions into
279        // Visual and Select at every write. Without one, Select would hold no
280        // motions and the sweep below would pass vacuously.
281        h.set_command_registry(registry.clone());
282        crate::keymap_visual::register_visual_bindings(
283            &h,
284            &builtins,
285            &action_ids,
286            &syntax_textobjects,
287        );
288        // Operators bind into Visual via `register_operator_bindings` (called by
289        // `register_normal_bindings`), not `register_visual_bindings` --
290        // an operator acts on the selection by design. The parity test
291        // below (`operators_bind_in_visual_but_never_in_select`) reads
292        // those Visual operator binds, so the full Normal catalog must
293        // be registered here too.
294        crate::keymap_normal::register_normal_bindings(
295            &h,
296            &builtins,
297            &action_ids,
298            &syntax_textobjects,
299            &syntax_motions,
300        );
301        // The operator-pending rows, as boot adds them.
302        crate::keymap_normal::expand_grammar_rows(
303            &h,
304            &registry.load(),
305            &builtins,
306            KeymapLayer::Builtin,
307        );
308        h
309    }
310
311    fn bound_command_id(
312        h: &KeymapHandle,
313        mode: BindingMode,
314        chords: &[KeyChord],
315    ) -> Option<lattice_protocol::ids::CommandId> {
316        match h.lookup(mode, chords) {
317            LookupResult::Bound { command, .. } => Some(command.command.command),
318            _ => None,
319        }
320    }
321
322    // `Action` derives only `Debug, Clone` (no `PartialEq`), so the
323    // assertions match on the variant rather than `assert_eq!`.
324
325    #[test]
326    fn bare_printable_overtypes() {
327        let h = empty_handle();
328        assert!(matches!(
329            translate_select(&h, &KeyChord::char('x'), VisualKind::Charwise, &[], &[]),
330            Action::SelectOvertype('x')
331        ));
332        // A letter that is a Visual *operator* (`d`) still overtypes in
333        // Select — operators are NOT registered in the Select table, so
334        // it falls through. This is the inverted-semantics core.
335        assert!(matches!(
336            translate_select(&h, &KeyChord::char('d'), VisualKind::Charwise, &[], &[]),
337            Action::SelectOvertype('d')
338        ));
339    }
340
341    /// Vim's Select rule names `<NL>` and `<CR>` alongside printables. Both
342    /// type a newline over the selection. A modified `<CR>` is a chord.
343    #[test]
344    fn enter_and_ctrl_j_overtype_with_a_newline() {
345        let h = empty_handle();
346        for (label, chord) in [
347            ("<CR>", KeyChord::special(SpecialKey::Enter)),
348            ("<C-j>", KeyChord::ctrl('j')),
349        ] {
350            assert!(
351                matches!(
352                    translate_select(&h, &chord, VisualKind::Charwise, &[], &[]),
353                    Action::SelectOvertype('\n')
354                ),
355                "{label} must overtype with a newline"
356            );
357        }
358        let ctrl_enter = KeyChord::new(KeyKind::Special(SpecialKey::Enter), KeyMods::CTRL);
359        assert!(matches!(
360            translate_select(&h, &ctrl_enter, VisualKind::Charwise, &[], &[]),
361            Action::None
362        ));
363    }
364
365    #[test]
366    fn esc_exits_select() {
367        let h = empty_handle();
368        assert!(matches!(
369            translate_select(
370                &h,
371                &KeyChord::special(SpecialKey::Esc),
372                VisualKind::Linewise,
373                &[],
374                &[]
375            ),
376            Action::ExitSelect
377        ));
378    }
379
380    #[test]
381    fn ctrl_g_toggles_to_visual() {
382        let h = empty_handle();
383        assert!(matches!(
384            translate_select(&h, &KeyChord::ctrl('g'), VisualKind::Charwise, &[], &[]),
385            Action::ToggleVisualSelect
386        ));
387    }
388
389    #[test]
390    fn ctrl_o_is_swallowed_post_mvp() {
391        let h = empty_handle();
392        assert!(matches!(
393            translate_select(&h, &KeyChord::ctrl('o'), VisualKind::Charwise, &[], &[]),
394            Action::None
395        ));
396    }
397
398    #[test]
399    fn other_control_chords_are_noops() {
400        let h = empty_handle();
401        assert!(matches!(
402            translate_select(&h, &KeyChord::ctrl('w'), VisualKind::Charwise, &[], &[]),
403            Action::None
404        ));
405    }
406
407    #[test]
408    fn special_keys_do_not_overtype() {
409        let h = empty_handle();
410        // A special (non-Char) key with no binding is a no-op, never a
411        // spurious overtype.
412        assert!(matches!(
413            translate_select(
414                &h,
415                &KeyChord::special(SpecialKey::Tab),
416                VisualKind::Charwise,
417                &[],
418                &[]
419            ),
420            Action::None
421        ));
422    }
423
424    // ── Visual / Select parity, as select-mode.md §4 now states it ──
425
426    /// VM.4: Visual takes every motion; Select takes only the ones that can't
427    /// be typed.
428    ///
429    /// This replaces `visual_and_select_share_every_motion`, which asserted
430    /// the opposite for printable motions and so held a Select bug in place:
431    /// a bound printable takes the keystroke before the overtype fallback
432    /// runs. `motion_rows` mixes printables (`w`, `0`, `$`) with
433    /// non-printables (arrows, Home, End), so one walk exercises both halves
434    /// of the rule. The tree-sitter structural motions (`]f`, …) start with a
435    /// printable and are Visual-only; the all-layers drift test in
436    /// `tests/a_motion_is_live_in_visual.rs` covers them.
437    #[test]
438    fn select_takes_only_motions_that_cannot_be_typed() {
439        use lattice_grammar::CommandRegistry;
440        use lattice_grammar::builtins::populate as grammar_builtins_populate;
441        // A throwaway registry yields the motion CHORD lists; the chords are
442        // literal keys, independent of any registry's ids.
443        let mut throwaway = CommandRegistry::new();
444        let builtins = grammar_builtins_populate(&mut throwaway);
445        let h = populated_handle();
446        let (mut printable, mut non_printable) = (0usize, 0usize);
447        for (chord, _motion) in crate::keymap_normal::motion_rows(&builtins) {
448            let ChordPattern::Literal(c) = chord else {
449                continue;
450            };
451            let path = [c];
452            assert!(
453                bound_command_id(&h, BindingMode::Visual, &path).is_some(),
454                "Visual must bind motion {c:?}"
455            );
456            let in_select = bound_command_id(&h, BindingMode::Select, &path).is_some();
457            if lattice_keymap::overtypes_in_select(&c) {
458                printable += 1;
459                assert!(
460                    !in_select,
461                    "printable motion {c:?} is bound in Select, so it would take typed text"
462                );
463            } else {
464                non_printable += 1;
465                assert!(
466                    in_select,
467                    "non-printable motion {c:?} must extend in Select"
468                );
469            }
470        }
471        assert!(
472            printable >= 10 && non_printable >= 4,
473            "test premise: both halves exercised ({printable} printable / {non_printable} not)"
474        );
475    }
476
477    /// Every printable character overtypes a Select selection when run
478    /// against the POPULATED table, as boot builds it.
479    ///
480    /// `bare_printable_overtypes` uses an EMPTY table, so it could never
481    /// notice a printable being bound. `visual_and_select_share_every_motion`
482    /// went further and ASSERTED the printable motions were bound in Select,
483    /// which locked the bug in. This sweep is the replacement.
484    #[test]
485    fn every_printable_overtypes_against_the_populated_table() {
486        let h = populated_handle();
487        assert!(
488            bound_command_id(
489                &h,
490                BindingMode::Select,
491                &[KeyChord::special(SpecialKey::PageDown)]
492            )
493            .is_some(),
494            "test premise: the mirror populated Select, so an empty table isn't passing this"
495        );
496
497        let mut stolen = Vec::new();
498        for c in ' '..='~' {
499            match translate_select(&h, &KeyChord::char(c), VisualKind::Charwise, &[], &[]) {
500                Action::SelectOvertype(got) if got == c => {}
501                other => stolen.push(format!("{c:?} -> {other:?}")),
502            }
503        }
504        for (label, chord) in [
505            ("<CR>", KeyChord::special(SpecialKey::Enter)),
506            ("<C-j>", KeyChord::ctrl('j')),
507        ] {
508            match translate_select(&h, &chord, VisualKind::Charwise, &[], &[]) {
509                Action::SelectOvertype('\n') => {}
510                other => stolen.push(format!("{label} -> {other:?}")),
511            }
512        }
513        assert!(
514            stolen.is_empty(),
515            "keys that don't overtype in Select:\n{}",
516            stolen.join("\n")
517        );
518    }
519
520    /// VM.5: `o` swaps the selection's ends in Visual, and types an `o` in
521    /// Select. Vim's Select has no swap-ends; `<C-g>` to Visual, then `o`.
522    ///
523    /// Replaces `visual_and_select_share_swap_ends`, which asserted that
524    /// Select bound `o`, so a placeholder couldn't be replaced by text
525    /// starting with it.
526    #[test]
527    fn o_overtypes_in_select_but_swaps_ends_in_visual() {
528        let h = populated_handle();
529        let o = KeyChord::char('o');
530        assert!(bound_command_id(&h, BindingMode::Visual, &[o]).is_some());
531        assert!(matches!(
532            translate_select(&h, &o, VisualKind::Charwise, &[], &[]),
533            Action::SelectOvertype('o')
534        ));
535    }
536
537    /// VM.5: `iw` selects a word in Visual; in Select `i` and `a` are typed
538    /// text, so they must not be a text-object prefix waiting for a second
539    /// key.
540    ///
541    /// Replaces `visual_and_select_share_text_objects`, which asserted that
542    /// Select bound `iw`. A bound prefix returns `Partial` and absorbs the key,
543    /// so `info` typed over a placeholder lost its `i`.
544    #[test]
545    fn a_text_object_prefix_overtypes_in_select() {
546        let h = populated_handle();
547        assert!(
548            bound_command_id(
549                &h,
550                BindingMode::Visual,
551                &[KeyChord::char('i'), KeyChord::char('w')]
552            )
553            .is_some(),
554            "Visual must bind `iw`"
555        );
556        for c in ['i', 'a'] {
557            match translate_select(&h, &KeyChord::char(c), VisualKind::Charwise, &[], &[]) {
558                Action::SelectOvertype(got) if got == c => {}
559                other => panic!("`{c}` must overtype in Select, got {other:?}"),
560            }
561        }
562    }
563
564    /// **Operators are Visual-ONLY.** In Select a printable overtypes, so
565    /// `d` / `x` / `c` / `s` / `y` / `>` / `<` must stay UNBOUND in the
566    /// Select table — the dispatcher's fallthrough turns them into
567    /// overtypes. This pins the inverted-semantics contract.
568    #[test]
569    fn operators_bind_in_visual_but_never_in_select() {
570        let h = populated_handle();
571        for op in ['d', 'x', 'c', 's', 'y', '>', '<'] {
572            let path = [KeyChord::char(op)];
573            assert!(
574                bound_command_id(&h, BindingMode::Visual, &path).is_some(),
575                "Visual must bind operator `{op}`"
576            );
577            assert_eq!(
578                bound_command_id(&h, BindingMode::Select, &path),
579                None,
580                "Select must NOT bind operator `{op}` — it overtypes instead"
581            );
582        }
583    }
584}