Skip to main content

escriba_keymap/
lib.rs

1//! `escriba-keymap` — mode-aware keybinding dispatch.
2
3extern crate self as escriba_keymap;
4
5use escriba_search::{CaretMove, Direction as SearchDirection};
6use std::collections::HashMap;
7
8use escriba_core::{Action, CountedAction, Mode, Motion, Operator, TextObject};
9use escriba_mode::ModalState;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum Key {
14    Char(char),
15    Esc,
16    Enter,
17    Tab,
18    Backspace,
19    Delete,
20    Left,
21    Right,
22    Up,
23    Down,
24    PageUp,
25    PageDown,
26    Home,
27    End,
28    Ctrl(char),
29    Alt(char),
30    /// A function key. Discarded at the door until now
31    /// (`escriba-input`: `KeyCode::F(_) => return None`), so `<F5>` could be
32    /// declared and never arrive.
33    F(u8),
34    /// Anything the shorthands above cannot say: more than one modifier,
35    /// `Shift` as a modifier rather than a capital letter, `Super`/`Cmd`.
36    ///
37    /// `Ctrl(char)` and `Alt(char)` fold the modifier INTO the key, so they
38    /// can carry exactly one. The translator has always computed all four
39    /// modifier flags and then thrown `shift` and `meta` away for want of
40    /// somewhere to put them; this is that somewhere.
41    ///
42    /// Carries `awase::Hotkey` directly rather than a parallel spelling —
43    /// escriba's vocabulary widens by consuming the fleet's, not by growing
44    /// a fifth one.
45    Chord(awase::Hotkey),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Binding {
50    pub action: Action,
51    pub description: String,
52}
53
54impl Binding {
55    #[must_use]
56    pub fn new(action: Action, description: impl Into<String>) -> Self {
57        Self {
58            action,
59            description: description.into(),
60        }
61    }
62}
63
64/// A binding that will not do what its author intended.
65///
66/// Recorded at BIND time rather than discovered later, because both kinds are
67/// silent by construction: a reserved chord never receives its event, and an
68/// overwritten binding simply stops existing. Neither produces an error at
69/// the moment it happens, and both present as "that key is broken".
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Collision {
72    /// Something outside escriba owns this chord — the OS, the window
73    /// manager, the terminal. The binding can never fire.
74    ///
75    /// Not arbitrable: no amount of reordering inside escriba changes it.
76    Reserved {
77        mode: Mode,
78        key: String,
79        description: String,
80        /// Who took it and what for.
81        why: String,
82    },
83    /// A later binding displaced an earlier one for the same chord.
84    ///
85    /// Sometimes intended — the shipped rc deliberately overrides defaults —
86    /// which is why this is REPORTED rather than refused. But it is reported,
87    /// because "my plugin's key stopped working" has no other explanation
88    /// available to an operator.
89    Displaced {
90        mode: Mode,
91        key: String,
92        replaced: String,
93        with: String,
94    },
95}
96
97impl Collision {
98    /// One line an operator can act on.
99    #[must_use]
100    pub fn report(&self) -> String {
101        match self {
102            Self::Reserved {
103                mode,
104                key,
105                description,
106                why,
107            } => format!("{mode:?} {key} ({description}) — {why}"),
108            Self::Displaced {
109                mode,
110                key,
111                replaced,
112                with,
113            } => format!("{mode:?} {key} — \"{replaced}\" was replaced by \"{with}\""),
114        }
115    }
116
117    /// Can this binding ever fire?
118    #[must_use]
119    pub const fn is_fatal(&self) -> bool {
120        matches!(self, Self::Reserved { .. })
121    }
122}
123
124/// What `dispatch` does with a key INSTEAD of consulting the binding table.
125///
126/// Named so a reader can tell the three apart: they look alike from the
127/// dispatcher's side (all three return before `lookup`) and are completely
128/// different to an operator trying to bind the key.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Preempted {
131    /// Normal mode: the key is composing a count (`5` of `5dd`).
132    Count,
133    /// Insert mode: a printable character types itself.
134    SelfInsert,
135    /// Insert mode: `<CR>` opens a line.
136    Newline,
137    /// Command mode: a printable character types into the prompt.
138    PromptInsert,
139}
140
141impl Preempted {
142    /// What `dispatch` answers. Kept beside the classification so the two
143    /// cannot drift — the whole point of hoisting this out of `dispatch`.
144    fn resolved(self, key: &Key) -> CountedAction {
145        match self {
146            Self::Count => CountedAction::once(Action::Pending),
147            Self::SelfInsert | Self::PromptInsert => match key {
148                Key::Char(c) => CountedAction::once(Action::InsertChar(*c)),
149                // Unreachable via `preemption`, which only returns these two
150                // for `Key::Char`. Answered rather than `unreachable!()`: a
151                // dispatcher that panics on an unexpected key is a worse
152                // failure than one that declines it.
153                _ => CountedAction::once(Action::Pending),
154            },
155            Self::Newline => CountedAction::once(Action::InsertChar('\n')),
156        }
157    }
158}
159
160/// WHETHER `dispatch` preempts a key, and whether it always does.
161///
162/// The distinction is load-bearing for reachability: `1`–`9` in Normal are
163/// preempted unconditionally, so a binding for one can never fire. `0` is
164/// preempted only while a count is being typed — which is exactly why `0` is
165/// bindable, and IS bound, to `Motion::LineStart`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum Preemption {
168    /// The key never reaches the binding table. A declaration is dead.
169    Always(Preempted),
170    /// The key reaches the table unless a count is pending. Bindable.
171    WhenCounting(Preempted),
172}
173
174/// Does `dispatch` answer `(mode, key)` before the binding table is consulted?
175///
176/// **The single statement of the rule.** [`Keymap::dispatch`] reads it to
177/// answer keys; anything auditing whether a DECLARATION could ever fire reads
178/// it to say so. Before it existed the rule lived in three inline `if mode ==`
179/// blocks inside `dispatch`, visible to nothing else, so a binding for a bare
180/// printable character in Insert mode parsed, applied, reported as applied —
181/// and was dead on arrival with nowhere to learn that from.
182///
183/// Total over the preempting cases; everything else falls through to `None`,
184/// which is the safe direction (a key wrongly called reachable shows up as a
185/// binding that does not work, a key wrongly called dead hides a working one).
186#[must_use]
187pub fn preemption(mode: Mode, key: &Key) -> Option<Preemption> {
188    match (mode, key) {
189        (Mode::Normal, Key::Char('0')) => Some(Preemption::WhenCounting(Preempted::Count)),
190        (Mode::Normal, Key::Char(c)) if c.is_ascii_digit() => {
191            Some(Preemption::Always(Preempted::Count))
192        }
193        (Mode::Insert, Key::Char(_)) => Some(Preemption::Always(Preempted::SelfInsert)),
194        (Mode::Insert, Key::Enter) => Some(Preemption::Always(Preempted::Newline)),
195        (Mode::Command, Key::Char(_)) => Some(Preemption::Always(Preempted::PromptInsert)),
196        _ => None,
197    }
198}
199
200#[derive(Debug, Clone)]
201pub struct Keymap {
202    bindings: HashMap<(Mode, Key), Binding>,
203    /// Multi-key sequence bindings (`<leader>ff`, `gg`, `<C-w>h`).
204    /// Keyed by the full key sequence; resolved by the runtime's
205    /// pending-stroke loop ([`lookup_sequence`](Keymap::lookup_sequence)
206    /// + [`is_sequence_prefix`](Keymap::is_sequence_prefix)).
207    sequences: HashMap<(Mode, Vec<Key>), Binding>,
208    /// The prefix `<leader>` resolves to at sequence-apply time.
209    leader: Key,
210    /// Chords the world owns. Consulted on every bind, so a binding that
211    /// cannot fire is known at CONSTRUCTION rather than discovered by an
212    /// operator pressing a dead key.
213    reserved: awase::Reserved,
214    /// Every collision seen while building this keymap, in bind order.
215    collisions: Vec<Collision>,
216}
217
218impl Default for Keymap {
219    fn default() -> Self {
220        Self {
221            bindings: HashMap::new(),
222            sequences: HashMap::new(),
223            reserved: awase::Reserved::fleet_darwin(),
224            collisions: Vec::new(),
225            // blnvim's leader is comma; escriba ships blnvim-parity
226            // defaults, so the prefix users press matches muscle memory.
227            leader: Key::Char(','),
228        }
229    }
230}
231
232impl Keymap {
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    #[must_use]
239    pub fn default_vim() -> Self {
240        let mut m = Self::new();
241        let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
242        nm(
243            &mut m,
244            Key::Char('h'),
245            Action::Move(Motion::Left),
246            "move left",
247        );
248        nm(
249            &mut m,
250            Key::Char('l'),
251            Action::Move(Motion::Right),
252            "move right",
253        );
254        nm(
255            &mut m,
256            Key::Char('j'),
257            Action::Move(Motion::Down),
258            "move down",
259        );
260        nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
261        nm(
262            &mut m,
263            Key::Char('w'),
264            Action::Move(Motion::WordStartNext),
265            "word forward",
266        );
267        nm(
268            &mut m,
269            Key::Char('b'),
270            Action::Move(Motion::WordStartPrev),
271            "word back",
272        );
273        nm(
274            &mut m,
275            Key::Char('e'),
276            Action::Move(Motion::WordEndNext),
277            "word end",
278        );
279        nm(
280            &mut m,
281            Key::Char('0'),
282            Action::Move(Motion::LineStart),
283            "line start",
284        );
285        nm(
286            &mut m,
287            Key::Char('$'),
288            Action::Move(Motion::LineEnd),
289            "line end",
290        );
291        nm(
292            &mut m,
293            Key::Char('G'),
294            Action::Move(Motion::DocEnd),
295            "doc end",
296        );
297        // Operators — `d`/`c`/`y` arm the operator-pending FSM; the next
298        // motion composes (e.g. `dw`, `c$`, `y0`).
299        nm(
300            &mut m,
301            Key::Char('d'),
302            Action::Operator(Operator::Delete),
303            "delete (operator)",
304        );
305        nm(
306            &mut m,
307            Key::Char('c'),
308            Action::Operator(Operator::Change),
309            "change (operator)",
310        );
311        nm(
312            &mut m,
313            Key::Char('y'),
314            Action::Operator(Operator::Yank),
315            "yank (operator)",
316        );
317        // Structural Lisp motions — Alt-prefixed like emacs paredit.
318        nm(
319            &mut m,
320            Key::Alt('f'),
321            Action::Move(Motion::ForwardSexp),
322            "forward sexp",
323        );
324        nm(
325            &mut m,
326            Key::Alt('b'),
327            Action::Move(Motion::BackwardSexp),
328            "backward sexp",
329        );
330        nm(
331            &mut m,
332            Key::Alt('u'),
333            Action::Move(Motion::UpList),
334            "up list",
335        );
336        nm(
337            &mut m,
338            Key::Alt('d'),
339            Action::Move(Motion::DownList),
340            "down list",
341        );
342        // Mode changes.
343        nm(
344            &mut m,
345            Key::Char('i'),
346            Action::ChangeMode(Mode::Insert),
347            "insert",
348        );
349        nm(
350            &mut m,
351            Key::Char('v'),
352            Action::ChangeMode(Mode::Visual),
353            "visual",
354        );
355        nm(
356            &mut m,
357            Key::Char('V'),
358            Action::ChangeMode(Mode::VisualLine),
359            "visual line",
360        );
361        nm(
362            &mut m,
363            Key::Char(':'),
364            Action::ChangeMode(Mode::Command),
365            "command",
366        );
367        nm(&mut m, Key::Char('u'), Action::Undo, "undo");
368        nm(
369            &mut m,
370            Key::Char('.'),
371            Action::RepeatLastChange,
372            "repeat last change",
373        );
374        nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
375        // Insert → Normal on Esc.
376        m.bind(
377            Mode::Insert,
378            Key::Esc,
379            Action::ChangeMode(Mode::Normal),
380            "to normal",
381        );
382        // ── Insert-mode editing ───────────────────────────────────────
383        // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
384        // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
385        // table is consulted — so every key below fell through to
386        // `Action::Pending` and did nothing. Insert mode could be typed into
387        // and never corrected: no erase, no caret movement. The keys were not
388        // "unimplemented", they were unbound; `Action::Backspace`'s executor
389        // had been waiting for a caller.
390        m.bind(
391            Mode::Insert,
392            Key::Backspace,
393            Action::Backspace,
394            "erase one char",
395        );
396        m.bind(
397            Mode::Insert,
398            Key::Delete,
399            Action::DeleteForward,
400            "delete char at caret",
401        );
402        // The bigger erases. `<BS>`/`<Del>` landed first and these were left
403        // behind for a day, which made Insert mode able to erase one character
404        // at a time and nothing larger — a mis-typed word had to be dismantled
405        // letter by letter. They share the erase family's routing, so the
406        // binding is the whole change: the runtime already knows whether a
407        // prompt is open.
408        m.bind(
409            Mode::Insert,
410            Key::Ctrl('w'),
411            Action::DeleteWordBefore,
412            "erase word before caret",
413        );
414        m.bind(
415            Mode::Insert,
416            Key::Ctrl('u'),
417            Action::DeleteToLineStart,
418            "erase to line start",
419        );
420        // `<C-h>` IS backspace: terminals send 0x08 for it, and vim treats the
421        // two as one key in Insert. Whether a given terminal reports the
422        // physical Backspace as `Backspace` or as `Ctrl('h')` is the
423        // terminal's business, not the operator's — binding both is what makes
424        // the answer stop mattering.
425        m.bind(
426            Mode::Insert,
427            Key::Ctrl('h'),
428            Action::Backspace,
429            "erase one char",
430        );
431        // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
432        // "edit it" means to anyone who did not grow up on hjkl. They reuse
433        // the ordinary motions, so the cursor-clamp + viewport-follow
434        // invariants come along unchanged.
435        for (key, motion, label) in [
436            (Key::Left, Motion::Left, "caret left"),
437            (Key::Right, Motion::Right, "caret right"),
438            (Key::Up, Motion::Up, "caret up"),
439            (Key::Down, Motion::Down, "caret down"),
440            (Key::Home, Motion::LineStart, "caret to line start"),
441            (Key::End, Motion::LineEnd, "caret to line end"),
442        ] {
443            m.bind(Mode::Insert, key, Action::Move(motion), label);
444        }
445        m.bind(
446            Mode::Command,
447            Key::Esc,
448            Action::ChangeMode(Mode::Normal),
449            "abort",
450        );
451        m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
452        m.bind(
453            Mode::Command,
454            Key::Up,
455            Action::PromptHistory { back: true },
456            "older search",
457        );
458        m.bind(
459            Mode::Command,
460            Key::Down,
461            Action::PromptHistory { back: false },
462            "newer search",
463        );
464        m.bind(
465            Mode::Command,
466            Key::Backspace,
467            Action::Backspace,
468            "erase one char",
469        );
470        m.bind(
471            Mode::Command,
472            Key::Delete,
473            Action::DeleteForward,
474            "delete char at caret",
475        );
476        // Caret editing inside the prompt. Without these the prompt is
477        // append-only, so a typo in the middle of a pattern can only be fixed
478        // by deleting everything back to it.
479        m.bind(
480            Mode::Command,
481            Key::Left,
482            Action::PromptCaret {
483                to: CaretMove::Left,
484            },
485            "caret left",
486        );
487        m.bind(
488            Mode::Command,
489            Key::Right,
490            Action::PromptCaret {
491                to: CaretMove::Right,
492            },
493            "caret right",
494        );
495        m.bind(
496            Mode::Command,
497            Key::Home,
498            Action::PromptCaret {
499                to: CaretMove::Start,
500            },
501            "caret to start",
502        );
503        m.bind(
504            Mode::Command,
505            Key::End,
506            Action::PromptCaret { to: CaretMove::End },
507            "caret to end",
508        );
509        m.bind(
510            Mode::Command,
511            Key::Ctrl('w'),
512            Action::DeleteWordBefore,
513            "delete word before caret",
514        );
515        // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
516        // `/pat<CR>nn`, except Escape still takes you home.
517        m.bind(
518            Mode::Command,
519            Key::Ctrl('g'),
520            Action::SearchPreviewStep { forward: true },
521            "preview next match",
522        );
523        m.bind(
524            Mode::Command,
525            Key::Ctrl('t'),
526            Action::SearchPreviewStep { forward: false },
527            "preview previous match",
528        );
529        m.bind(
530            Mode::Command,
531            Key::Ctrl('u'),
532            Action::DeleteToLineStart,
533            "clear to start",
534        );
535
536        // ── search ────────────────────────────────────────────────────
537        // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
538        // which the runtime routes to the search when a search prompt is open.
539        // That routing is typed (Option<Prompt>), not a mode flag to forget.
540        nm(
541            &mut m,
542            Key::Char('/'),
543            Action::SearchOpen(SearchDirection::Forward),
544            "search forward",
545        );
546        nm(
547            &mut m,
548            Key::Char('?'),
549            Action::SearchOpen(SearchDirection::Backward),
550            "search backward",
551        );
552        // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
553        // `Action::Move` is what makes `dn` / `yN` compose — the
554        // operator-pending machine only recognises `Action::Move` as an
555        // operand. `Action::SearchRepeat` remains a valid action (a user rc or
556        // the tatara-lisp binding table may name it) and the runtime routes it
557        // through the same executor, so there is exactly one code path.
558        nm(
559            &mut m,
560            Key::Char('n'),
561            Action::Move(Motion::SearchNext),
562            "next match",
563        );
564        nm(
565            &mut m,
566            Key::Char('N'),
567            Action::Move(Motion::SearchPrev),
568            "previous match",
569        );
570
571        // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
572        // match and `.` repeats that on the next one.
573        m.bind_sequence(
574            Mode::Normal,
575            vec![Key::Char('g'), Key::Char('n')],
576            Action::TextObject(TextObject::NextMatch),
577            "next match (object)",
578        );
579        m.bind_sequence(
580            Mode::Normal,
581            vec![Key::Char('g'), Key::Char('N')],
582            Action::TextObject(TextObject::PrevMatch),
583            "previous match (object)",
584        );
585
586        // ── jumplist ──────────────────────────────────────────────────
587        // The return ticket for every far jump above. Without it a committed
588        // search is a one-way door.
589        nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
590        nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
591        nm(
592            &mut m,
593            Key::Char('*'),
594            Action::SearchWord { reverse: false },
595            "search word forward",
596        );
597        nm(
598            &mut m,
599            Key::Char('#'),
600            Action::SearchWord { reverse: true },
601            "search word backward",
602        );
603        m.bind(
604            Mode::Visual,
605            Key::Esc,
606            Action::ChangeMode(Mode::Normal),
607            "to normal",
608        );
609        m.bind(
610            Mode::VisualLine,
611            Key::Esc,
612            Action::ChangeMode(Mode::Normal),
613            "to normal",
614        );
615        m
616    }
617
618    pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
619        let binding = Binding::new(action, desc);
620        self.note_collisions(mode, std::slice::from_ref(&key), &binding);
621        self.bindings.insert((mode, key), binding);
622    }
623
624    /// Record anything about this bind that will surprise its author.
625    ///
626    /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
627    /// costs one hash lookup plus one conversion — a keymap that only tells
628    /// you about collisions when asked is a keymap nobody asks.
629    fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
630        let Some(first) = keys.first() else { return };
631        let spelled = format!("{keys:?}");
632
633        // (1) Does the world own it? For a sequence this is its OPENER —
634        // a sequence whose first key never arrives can never begin.
635        if let Some(hk) = to_hotkey(first) {
636            if let Some(why) = self.reserved.refuse(&hk) {
637                self.collisions.push(Collision::Reserved {
638                    mode,
639                    key: spelled.clone(),
640                    description: binding.description.clone(),
641                    why,
642                });
643            }
644        }
645
646        // (2) Is something already here? `HashMap::insert` returns the old
647        // value and every caller dropped it, so a displaced binding left no
648        // trace at all.
649        let existing = if keys.len() == 1 {
650            self.bindings
651                .get(&(mode, first.clone()))
652                .map(|b| &b.description)
653        } else {
654            self.sequences
655                .get(&(mode, keys.to_vec()))
656                .map(|b| &b.description)
657        };
658        if let Some(replaced) = existing {
659            self.collisions.push(Collision::Displaced {
660                mode,
661                key: spelled,
662                replaced: replaced.clone(),
663                with: binding.description.clone(),
664            });
665        }
666    }
667
668    /// Every collision recorded while this keymap was built.
669    ///
670    /// Read by `--list-rc` and reported at boot. An empty slice is the
671    /// claim "every binding escriba ships can actually fire".
672    #[must_use]
673    pub fn collisions(&self) -> &[Collision] {
674        &self.collisions
675    }
676
677    /// Collisions that mean a key can NEVER fire, as opposed to one that was
678    /// deliberately overridden.
679    pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
680        self.collisions.iter().filter(|c| c.is_fatal())
681    }
682
683    #[must_use]
684    pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
685        self.bindings.get(&(mode, key.clone()))
686    }
687
688    /// The leader key — what `<leader>` resolves to when a sequence
689    /// binding is applied. Defaults to `,` (blnvim parity).
690    #[must_use]
691    pub fn leader(&self) -> &Key {
692        &self.leader
693    }
694
695    /// Override the leader key. Applied before sequence bindings so
696    /// `<leader>`-prefixed specs resolve against the chosen prefix.
697    pub fn set_leader(&mut self, key: Key) {
698        self.leader = key;
699    }
700
701    /// Bind a multi-key SEQUENCE — `<leader>ff` →
702    /// `[Char(','), Char('f'), Char('f')]`, `gg` →
703    /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
704    /// [`bind`](Keymap::bind) so callers never special-case it; an
705    /// empty sequence is a no-op.
706    pub fn bind_sequence(
707        &mut self,
708        mode: Mode,
709        keys: Vec<Key>,
710        action: Action,
711        desc: impl Into<String>,
712    ) {
713        match keys.as_slice() {
714            [] => {}
715            [single] => self.bind(mode, single.clone(), action, desc),
716            _ => {
717                let binding = Binding::new(action, desc);
718                self.note_collisions(mode, &keys, &binding);
719                self.sequences.insert((mode, keys), binding);
720            }
721        }
722    }
723
724    /// Exact-match lookup for a full key sequence.
725    #[must_use]
726    pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
727        self.sequences.get(&(mode, keys.to_vec()))
728    }
729
730    /// Does any bound sequence in `mode` STRICTLY extend `prefix`
731    /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
732    /// Drives the runtime's pending-stroke state: a partial sequence
733    /// that is still a live prefix is held pending rather than
734    /// dispatched. Linear scan — fine at fleet sequence counts; a
735    /// trie is a later optimization if profiling ever asks for it.
736    #[must_use]
737    pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
738        self.sequences
739            .keys()
740            .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
741    }
742
743    /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
744    ///
745    /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
746    /// question with a `bool` and throws away the matches it just found. Two
747    /// consumers need those matches:
748    ///
749    /// - a **which-key popup**, which must show what continues `<leader>`;
750    /// - a **reserved-chord audit**, which cannot check bindings it cannot
751    ///   enumerate — and `sequences` is private, so from outside this crate
752    ///   the multi-key half of the keymap was invisible.
753    ///
754    /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
755    /// a trie is the same later optimization for both.
756    ///
757    /// Pass an empty `prefix` for every sequence in the mode.
758    #[must_use]
759    pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
760        let mut v: Vec<(&[Key], &Binding)> = self
761            .sequences
762            .iter()
763            .filter(|((m, seq), _)| {
764                *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
765            })
766            .map(|((_, seq), b)| (seq.as_slice(), b))
767            .collect();
768        // Sorted, because a which-key popup in HashMap order is a popup that
769        // reorders itself between presses.
770        v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
771        v
772    }
773
774    /// Count of bound multi-key sequences — for `--keymap` / doctor.
775    #[must_use]
776    pub fn sequence_len(&self) -> usize {
777        self.sequences.len()
778    }
779
780    #[must_use]
781    pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
782        let mode = state.mode();
783        // The preemption rule is stated ONCE, in `preemption` below, and read
784        // twice: here, to answer the key, and by `escriba-banzuke`, to decide
785        // whether a DECLARATION for this pair could ever fire. It used to be
786        // three inline `if mode ==` blocks that only this function could see,
787        // so an rc binding a bare `j` in Insert parsed, applied, reported as
788        // applied — and was dead. Nothing could say so, because the fact that
789        // made it dead lived inside the dispatcher.
790        match preemption(mode, key) {
791            Some(Preemption::Always(p)) => return p.resolved(key),
792            Some(Preemption::WhenCounting(p)) if state.pending_count().is_some() => {
793                return p.resolved(key);
794            }
795            _ => {}
796        }
797        if let Some(b) = self.lookup(mode, key) {
798            return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
799        }
800        CountedAction::once(Action::Pending)
801    }
802
803    #[must_use]
804    pub fn len(&self) -> usize {
805        self.bindings.len()
806    }
807
808    #[must_use]
809    pub fn is_empty(&self) -> bool {
810        self.bindings.is_empty()
811    }
812
813    /// Sorted view over every binding — for `escriba --keymap` and palettes.
814    #[must_use]
815    pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
816        let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
817        v.sort_by(|a, b| {
818            (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
819        });
820        v
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    #[test]
829    fn default_vim_has_bindings() {
830        let k = Keymap::default_vim();
831        assert!(k.len() > 10);
832        assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
833        assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
834        assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
835    }
836
837    #[test]
838    fn dispatch_normal_motion() {
839        let k = Keymap::default_vim();
840        let s = ModalState::new();
841        let a = k.dispatch(&s, &Key::Char('h'));
842        assert_eq!(a.count, 1);
843        assert_eq!(a.action, Action::Move(Motion::Left));
844    }
845
846    #[test]
847    fn dispatch_count_prefix_pends() {
848        let k = Keymap::default_vim();
849        let s = ModalState::new();
850        assert!(matches!(
851            k.dispatch(&s, &Key::Char('5')).action,
852            Action::Pending
853        ));
854    }
855
856    #[test]
857    fn dispatch_insert_char() {
858        let k = Keymap::default_vim();
859        let mut s = ModalState::new();
860        s.enter(Mode::Insert);
861        let a = k.dispatch(&s, &Key::Char('a'));
862        assert_eq!(a.action, Action::InsertChar('a'));
863    }
864
865    #[test]
866    fn lisp_structural_motions_bound() {
867        let k = Keymap::default_vim();
868        assert_eq!(
869            k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
870            Action::Move(Motion::ForwardSexp)
871        );
872    }
873
874    #[test]
875    fn default_leader_is_comma() {
876        assert_eq!(Keymap::new().leader(), &Key::Char(','));
877    }
878
879    #[test]
880    fn bind_sequence_stores_multikey_and_resolves() {
881        let mut k = Keymap::new();
882        let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
883        k.bind_sequence(
884            Mode::Normal,
885            seq.clone(),
886            Action::Command {
887                name: "picker.files".into(),
888                args: vec![],
889            },
890            "find files",
891        );
892        // Exact match resolves.
893        let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
894        assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
895        // Proper prefixes are live; the full sequence is NOT a prefix
896        // of itself.
897        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
898        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
899        assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
900        // Wrong mode → not a prefix.
901        assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
902        assert_eq!(k.sequence_len(), 1);
903    }
904
905    #[test]
906    fn bind_sequence_length_one_delegates_to_single() {
907        let mut k = Keymap::new();
908        k.bind_sequence(
909            Mode::Normal,
910            vec![Key::Char('x')],
911            Action::Undo,
912            "x is undo",
913        );
914        // Lands in the single-key table, not the sequence table.
915        assert_eq!(k.sequence_len(), 0);
916        assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
917    }
918}
919
920/// escriba's `Key` as the fleet's chord vocabulary.
921///
922/// # Why a conversion rather than a migration (yet)
923///
924/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
925/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
926/// bitflag SET over 116 key variants. The escriba shape therefore cannot
927/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
928/// (`escriba-input` discards `KeyCode::F(_)` at the door).
929///
930/// Migrating the whole keymap is the destination and it touches 52 call
931/// sites. This conversion is what lets the **reserved-chord audit** run
932/// today, before that lands: a binding escriba cannot even ask about is a
933/// binding that silently dies when the window manager takes its chord.
934///
935/// Returns `None` for a key with no fleet spelling — today the shifted
936/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
937/// carry. That is the honest answer, and an audit must treat an unmappable
938/// key as UNAUDITED rather than as available: silently counting it as clean
939/// is how `Ctrl+Space` stayed hidden.
940#[must_use]
941pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
942    use awase::{Hotkey, Key as AK, Modifiers as M};
943    // `from_name` takes NAMES ("space"), not literal characters. Spelling a
944    // space as " " returns None — which is how `Ctrl+Space` slipped past the
945    // reserved audit while being bound in Insert mode AND owned by the OS.
946    let named = |c: char| match c {
947        ' ' => Some(AK::Space),
948        c => AK::from_name(&c.to_ascii_lowercase().to_string()),
949    };
950    Some(match key {
951        Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
952        Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
953        Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
954        Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
955        // Already a fleet chord — nothing to convert.
956        Key::Chord(h) => *h,
957        Key::Esc => Hotkey::new(M::NONE, AK::Escape),
958        Key::Enter => Hotkey::new(M::NONE, AK::Return),
959        Key::Tab => Hotkey::new(M::NONE, AK::Tab),
960        Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
961        Key::Delete => Hotkey::new(M::NONE, AK::Delete),
962        Key::Left => Hotkey::new(M::NONE, AK::Left),
963        Key::Right => Hotkey::new(M::NONE, AK::Right),
964        Key::Up => Hotkey::new(M::NONE, AK::Up),
965        Key::Down => Hotkey::new(M::NONE, AK::Down),
966        Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
967        Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
968        Key::Home => Hotkey::new(M::NONE, AK::Home),
969        Key::End => Hotkey::new(M::NONE, AK::End),
970    })
971}
972
973#[cfg(test)]
974mod fleet_vocabulary {
975    use super::*;
976
977    #[test]
978    fn modifiers_survive_the_conversion() {
979        let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
980        assert!(h.modifiers.contains(awase::Modifiers::CTRL));
981        assert_eq!(h.key, awase::Key::W);
982    }
983
984    #[test]
985    fn named_keys_map_to_their_fleet_spelling() {
986        // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
987        // fleet atlas already warns that these two spellings diverge across
988        // consumers, which is exactly what a shared vocabulary settles.
989        assert_eq!(
990            to_hotkey(&Key::Esc).map(|h| h.key),
991            Some(awase::Key::Escape)
992        );
993        assert_eq!(
994            to_hotkey(&Key::Enter).map(|h| h.key),
995            Some(awase::Key::Return)
996        );
997    }
998
999    #[test]
1000    fn every_variant_of_escribas_key_has_a_fleet_spelling() {
1001        // If one did not, the reserved audit would have a blind spot exactly
1002        // where escriba's vocabulary is unusual — which is where a collision
1003        // is most likely.
1004        let all = [
1005            Key::Char('a'),
1006            Key::Ctrl('a'),
1007            Key::Alt('a'),
1008            Key::Esc,
1009            Key::Enter,
1010            Key::Tab,
1011            Key::Backspace,
1012            Key::Delete,
1013            Key::Left,
1014            Key::Right,
1015            Key::Up,
1016            Key::Down,
1017            Key::PageUp,
1018            Key::PageDown,
1019            Key::Home,
1020            Key::End,
1021        ];
1022        for k in all {
1023            assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
1024        }
1025    }
1026
1027    #[test]
1028    fn sequences_can_now_be_enumerated() {
1029        // `sequences` was private with no accessor, so the multi-key half of
1030        // the keymap was invisible from outside this crate — unauditable and
1031        // un-displayable.
1032        let k = Keymap::default_vim();
1033        let all = k.sequences_extending(Mode::Normal, &[]);
1034        assert!(!all.is_empty(), "the default keymap binds sequences");
1035        let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
1036        assert!(
1037            g.iter()
1038                .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
1039            "a prefix query returns only its own continuations",
1040        );
1041    }
1042}
1043
1044#[cfg(test)]
1045mod collision_detection {
1046    use super::*;
1047
1048    #[test]
1049    fn a_reserved_chord_is_recorded_at_bind_time() {
1050        // Not discovered later by an audit — known the moment it is written.
1051        let mut m = Keymap::new();
1052        m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
1053        let c = m.collisions();
1054        assert_eq!(c.len(), 1, "{c:?}");
1055        assert!(c[0].is_fatal(), "a chord the world owns can never fire");
1056        assert!(
1057            c[0].report().contains("window manager"),
1058            "{}",
1059            c[0].report()
1060        );
1061    }
1062
1063    #[test]
1064    fn a_displaced_binding_is_recorded_but_not_fatal() {
1065        // Overriding is sometimes intended — the shipped rc deliberately
1066        // overrides defaults — so it is REPORTED, never refused. But "my
1067        // plugin's key stopped working" has no other explanation available.
1068        let mut m = Keymap::new();
1069        m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
1070        m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
1071        let c = m.collisions();
1072        assert_eq!(c.len(), 1);
1073        assert!(!c[0].is_fatal());
1074        let r = c[0].report();
1075        assert!(r.contains("first") && r.contains("second"), "{r}");
1076    }
1077
1078    #[test]
1079    // OPENER is shouted because which key is checked IS the point.
1080    #[allow(non_snake_case)]
1081    fn a_sequence_whose_OPENER_is_reserved_is_caught() {
1082        // `alt-j` then anything can never begin, because the first key never
1083        // arrives. Checking only single keys would miss the whole sequence.
1084        let mut m = Keymap::new();
1085        m.bind_sequence(
1086            Mode::Normal,
1087            vec![Key::Alt('j'), Key::Char('x')],
1088            Action::Undo,
1089            "dead sequence",
1090        );
1091        assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
1092    }
1093
1094    #[test]
1095    fn an_ordinary_keymap_records_nothing() {
1096        // The detector must be quiet when there is nothing to say, or it
1097        // becomes noise an operator learns to skip.
1098        let mut m = Keymap::new();
1099        m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
1100        m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
1101        assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1102    }
1103
1104    #[test]
1105    fn the_shipped_default_keymap_is_clean() {
1106        let m = Keymap::default_vim();
1107        assert!(
1108            m.collisions().is_empty(),
1109            "escriba's own defaults must not collide:\n  {}",
1110            m.collisions()
1111                .iter()
1112                .map(Collision::report)
1113                .collect::<Vec<_>>()
1114                .join("\n  "),
1115        );
1116    }
1117}