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, InsertAt, 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        // ── Insert entry — the whole vim family, one row each ──────────
343        //
344        // Until 2026-08-12 this was ONE row: `i`. `a`, `A`, `I`, `o` and `O`
345        // were unbound, so `A` on a line resolved to `Action::Pending` and did
346        // nothing at all — no move, no mode change, no message.
347        //
348        // Binding bare `a` and `i` is safe DESPITE the text objects (`daw`,
349        // `di(`) that also begin with them, and the reason is worth stating
350        // because it is the one thing that makes this table correct: the
351        // runtime's `consume_object_key` runs BEFORE the sequence stepper and
352        // before this table, and claims `i`/`a` only while an operator is
353        // armed (`escriba-runtime`, `OpState::Awaiting`). With nothing pending
354        // they fall through to here. `escriba-keymap`'s own "single bindings
355        // win over sequence prefixes" rule would otherwise have shadowed every
356        // text object the moment `a` got a binding.
357        //
358        // Every entry is `EnterInsert`, never `ChangeMode(Insert)`: the caret
359        // placement IS the difference between the six, and a mode change
360        // cannot carry it.
361        for (key, at, label) in [
362            (Key::Char('i'), InsertAt::Caret, "insert"),
363            (Key::Char('I'), InsertAt::FirstNonBlank, "first non-blank"),
364            (Key::Char('a'), InsertAt::AfterCaret, "append"),
365            (Key::Char('A'), InsertAt::LineEnd, "append at end of line"),
366            (Key::Char('o'), InsertAt::OpenBelow, "open line below"),
367            (Key::Char('O'), InsertAt::OpenAbove, "open line above"),
368        ] {
369            nm(&mut m, key, Action::EnterInsert(at), label);
370        }
371        nm(
372            &mut m,
373            Key::Char('v'),
374            Action::ChangeMode(Mode::Visual),
375            "visual",
376        );
377        nm(
378            &mut m,
379            Key::Char('V'),
380            Action::ChangeMode(Mode::VisualLine),
381            "visual line",
382        );
383        nm(
384            &mut m,
385            Key::Char(':'),
386            Action::ChangeMode(Mode::Command),
387            "command",
388        );
389        nm(&mut m, Key::Char('u'), Action::Undo, "undo");
390        nm(
391            &mut m,
392            Key::Char('.'),
393            Action::RepeatLastChange,
394            "repeat last change",
395        );
396        nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
397        // Insert → Normal on Esc.
398        m.bind(
399            Mode::Insert,
400            Key::Esc,
401            Action::ChangeMode(Mode::Normal),
402            "to normal",
403        );
404        // ── Insert-mode editing ───────────────────────────────────────
405        // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
406        // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
407        // table is consulted — so every key below fell through to
408        // `Action::Pending` and did nothing. Insert mode could be typed into
409        // and never corrected: no erase, no caret movement. The keys were not
410        // "unimplemented", they were unbound; `Action::Backspace`'s executor
411        // had been waiting for a caller.
412        m.bind(
413            Mode::Insert,
414            Key::Backspace,
415            Action::Backspace,
416            "erase one char",
417        );
418        m.bind(
419            Mode::Insert,
420            Key::Delete,
421            Action::DeleteForward,
422            "delete char at caret",
423        );
424        // The bigger erases. `<BS>`/`<Del>` landed first and these were left
425        // behind for a day, which made Insert mode able to erase one character
426        // at a time and nothing larger — a mis-typed word had to be dismantled
427        // letter by letter. They share the erase family's routing, so the
428        // binding is the whole change: the runtime already knows whether a
429        // prompt is open.
430        m.bind(
431            Mode::Insert,
432            Key::Ctrl('w'),
433            Action::DeleteWordBefore,
434            "erase word before caret",
435        );
436        m.bind(
437            Mode::Insert,
438            Key::Ctrl('u'),
439            Action::DeleteToLineStart,
440            "erase to line start",
441        );
442        // `<C-h>` IS backspace: terminals send 0x08 for it, and vim treats the
443        // two as one key in Insert. Whether a given terminal reports the
444        // physical Backspace as `Backspace` or as `Ctrl('h')` is the
445        // terminal's business, not the operator's — binding both is what makes
446        // the answer stop mattering.
447        m.bind(
448            Mode::Insert,
449            Key::Ctrl('h'),
450            Action::Backspace,
451            "erase one char",
452        );
453        // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
454        // "edit it" means to anyone who did not grow up on hjkl. They reuse
455        // the ordinary motions, so the cursor-clamp + viewport-follow
456        // invariants come along unchanged.
457        for (key, motion, label) in [
458            (Key::Left, Motion::Left, "caret left"),
459            (Key::Right, Motion::Right, "caret right"),
460            (Key::Up, Motion::Up, "caret up"),
461            (Key::Down, Motion::Down, "caret down"),
462            (Key::Home, Motion::LineStart, "caret to line start"),
463            (Key::End, Motion::LineEnd, "caret to line end"),
464        ] {
465            m.bind(Mode::Insert, key, Action::Move(motion), label);
466        }
467        m.bind(
468            Mode::Command,
469            Key::Esc,
470            Action::ChangeMode(Mode::Normal),
471            "abort",
472        );
473        m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
474        m.bind(
475            Mode::Command,
476            Key::Up,
477            Action::PromptHistory { back: true },
478            "older search",
479        );
480        m.bind(
481            Mode::Command,
482            Key::Down,
483            Action::PromptHistory { back: false },
484            "newer search",
485        );
486        m.bind(
487            Mode::Command,
488            Key::Backspace,
489            Action::Backspace,
490            "erase one char",
491        );
492        m.bind(
493            Mode::Command,
494            Key::Delete,
495            Action::DeleteForward,
496            "delete char at caret",
497        );
498        // Caret editing inside the prompt. Without these the prompt is
499        // append-only, so a typo in the middle of a pattern can only be fixed
500        // by deleting everything back to it.
501        m.bind(
502            Mode::Command,
503            Key::Left,
504            Action::PromptCaret {
505                to: CaretMove::Left,
506            },
507            "caret left",
508        );
509        m.bind(
510            Mode::Command,
511            Key::Right,
512            Action::PromptCaret {
513                to: CaretMove::Right,
514            },
515            "caret right",
516        );
517        m.bind(
518            Mode::Command,
519            Key::Home,
520            Action::PromptCaret {
521                to: CaretMove::Start,
522            },
523            "caret to start",
524        );
525        m.bind(
526            Mode::Command,
527            Key::End,
528            Action::PromptCaret { to: CaretMove::End },
529            "caret to end",
530        );
531        m.bind(
532            Mode::Command,
533            Key::Ctrl('w'),
534            Action::DeleteWordBefore,
535            "delete word before caret",
536        );
537        // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
538        // `/pat<CR>nn`, except Escape still takes you home.
539        m.bind(
540            Mode::Command,
541            Key::Ctrl('g'),
542            Action::SearchPreviewStep { forward: true },
543            "preview next match",
544        );
545        m.bind(
546            Mode::Command,
547            Key::Ctrl('t'),
548            Action::SearchPreviewStep { forward: false },
549            "preview previous match",
550        );
551        m.bind(
552            Mode::Command,
553            Key::Ctrl('u'),
554            Action::DeleteToLineStart,
555            "clear to start",
556        );
557
558        // ── search ────────────────────────────────────────────────────
559        // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
560        // which the runtime routes to the search when a search prompt is open.
561        // That routing is typed (Option<Prompt>), not a mode flag to forget.
562        nm(
563            &mut m,
564            Key::Char('/'),
565            Action::SearchOpen(SearchDirection::Forward),
566            "search forward",
567        );
568        nm(
569            &mut m,
570            Key::Char('?'),
571            Action::SearchOpen(SearchDirection::Backward),
572            "search backward",
573        );
574        // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
575        // `Action::Move` is what makes `dn` / `yN` compose — the
576        // operator-pending machine only recognises `Action::Move` as an
577        // operand. `Action::SearchRepeat` remains a valid action (a user rc or
578        // the tatara-lisp binding table may name it) and the runtime routes it
579        // through the same executor, so there is exactly one code path.
580        nm(
581            &mut m,
582            Key::Char('n'),
583            Action::Move(Motion::SearchNext),
584            "next match",
585        );
586        nm(
587            &mut m,
588            Key::Char('N'),
589            Action::Move(Motion::SearchPrev),
590            "previous match",
591        );
592
593        // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
594        // match and `.` repeats that on the next one.
595        m.bind_sequence(
596            Mode::Normal,
597            vec![Key::Char('g'), Key::Char('n')],
598            Action::TextObject(TextObject::NextMatch),
599            "next match (object)",
600        );
601        m.bind_sequence(
602            Mode::Normal,
603            vec![Key::Char('g'), Key::Char('N')],
604            Action::TextObject(TextObject::PrevMatch),
605            "previous match (object)",
606        );
607
608        // ── `ff` — format the buffer ───────────────────────────────────
609        //
610        // The operator's blnvim binding, which escriba ships parity with:
611        // `blackmatter-nvim`'s `groups/keybindings/init.lua` maps a bare `ff`
612        // in normal mode to conform's format. Same keys here so the muscle
613        // memory transfers between the two editors.
614        //
615        // A SEQUENCE, and it costs nothing today: `f` (find-char) is not
616        // implemented in escriba, so there is no single binding for `ff` to
617        // shadow. When `f` lands it will need deciding — keymap's rule is that
618        // a single binding WINS over a sequence prefix, so binding `f` would
619        // silently kill `ff` rather than conflict with it. nvim pays the mirror
620        // cost of that today: `ff` there shadows `f` followed by `f`.
621        //
622        // `Action::Command` rather than a dedicated action: `lsp.format` is the
623        // name the catalog already binds from `<leader>lf`, `:Format` and a
624        // `BufWritePre` hook, and a second route to the same verb is how two
625        // spellings of one thing drift apart.
626        m.bind_sequence(
627            Mode::Normal,
628            vec![Key::Char('f'), Key::Char('f')],
629            Action::Command {
630                name: "lsp.format".to_string(),
631                args: Vec::new(),
632            },
633            "format buffer",
634        );
635
636        // ── jumplist ──────────────────────────────────────────────────
637        // The return ticket for every far jump above. Without it a committed
638        // search is a one-way door.
639        nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
640        nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
641        nm(
642            &mut m,
643            Key::Char('*'),
644            Action::SearchWord { reverse: false },
645            "search word forward",
646        );
647        nm(
648            &mut m,
649            Key::Char('#'),
650            Action::SearchWord { reverse: true },
651            "search word backward",
652        );
653        m.bind(
654            Mode::Visual,
655            Key::Esc,
656            Action::ChangeMode(Mode::Normal),
657            "to normal",
658        );
659        m.bind(
660            Mode::VisualLine,
661            Key::Esc,
662            Action::ChangeMode(Mode::Normal),
663            "to normal",
664        );
665        m
666    }
667
668    pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
669        let binding = Binding::new(action, desc);
670        self.note_collisions(mode, std::slice::from_ref(&key), &binding);
671        self.bindings.insert((mode, key), binding);
672    }
673
674    /// Record anything about this bind that will surprise its author.
675    ///
676    /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
677    /// costs one hash lookup plus one conversion — a keymap that only tells
678    /// you about collisions when asked is a keymap nobody asks.
679    fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
680        let Some(first) = keys.first() else { return };
681        let spelled = format!("{keys:?}");
682
683        // (1) Does the world own it? For a sequence this is its OPENER —
684        // a sequence whose first key never arrives can never begin.
685        if let Some(hk) = to_hotkey(first) {
686            if let Some(why) = self.reserved.refuse(&hk) {
687                self.collisions.push(Collision::Reserved {
688                    mode,
689                    key: spelled.clone(),
690                    description: binding.description.clone(),
691                    why,
692                });
693            }
694        }
695
696        // (2) Is something already here? `HashMap::insert` returns the old
697        // value and every caller dropped it, so a displaced binding left no
698        // trace at all.
699        let existing = if keys.len() == 1 {
700            self.bindings
701                .get(&(mode, first.clone()))
702                .map(|b| &b.description)
703        } else {
704            self.sequences
705                .get(&(mode, keys.to_vec()))
706                .map(|b| &b.description)
707        };
708        if let Some(replaced) = existing {
709            self.collisions.push(Collision::Displaced {
710                mode,
711                key: spelled,
712                replaced: replaced.clone(),
713                with: binding.description.clone(),
714            });
715        }
716    }
717
718    /// Every collision recorded while this keymap was built.
719    ///
720    /// Read by `--list-rc` and reported at boot. An empty slice is the
721    /// claim "every binding escriba ships can actually fire".
722    #[must_use]
723    pub fn collisions(&self) -> &[Collision] {
724        &self.collisions
725    }
726
727    /// Collisions that mean a key can NEVER fire, as opposed to one that was
728    /// deliberately overridden.
729    pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
730        self.collisions.iter().filter(|c| c.is_fatal())
731    }
732
733    #[must_use]
734    pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
735        self.bindings.get(&(mode, key.clone()))
736    }
737
738    /// The leader key — what `<leader>` resolves to when a sequence
739    /// binding is applied. Defaults to `,` (blnvim parity).
740    #[must_use]
741    pub fn leader(&self) -> &Key {
742        &self.leader
743    }
744
745    /// Override the leader key. Applied before sequence bindings so
746    /// `<leader>`-prefixed specs resolve against the chosen prefix.
747    pub fn set_leader(&mut self, key: Key) {
748        self.leader = key;
749    }
750
751    /// Bind a multi-key SEQUENCE — `<leader>ff` →
752    /// `[Char(','), Char('f'), Char('f')]`, `gg` →
753    /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
754    /// [`bind`](Keymap::bind) so callers never special-case it; an
755    /// empty sequence is a no-op.
756    pub fn bind_sequence(
757        &mut self,
758        mode: Mode,
759        keys: Vec<Key>,
760        action: Action,
761        desc: impl Into<String>,
762    ) {
763        match keys.as_slice() {
764            [] => {}
765            [single] => self.bind(mode, single.clone(), action, desc),
766            _ => {
767                let binding = Binding::new(action, desc);
768                self.note_collisions(mode, &keys, &binding);
769                self.sequences.insert((mode, keys), binding);
770            }
771        }
772    }
773
774    /// Exact-match lookup for a full key sequence.
775    #[must_use]
776    pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
777        self.sequences.get(&(mode, keys.to_vec()))
778    }
779
780    /// Does any bound sequence in `mode` STRICTLY extend `prefix`
781    /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
782    /// Drives the runtime's pending-stroke state: a partial sequence
783    /// that is still a live prefix is held pending rather than
784    /// dispatched. Linear scan — fine at fleet sequence counts; a
785    /// trie is a later optimization if profiling ever asks for it.
786    #[must_use]
787    pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
788        self.sequences
789            .keys()
790            .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
791    }
792
793    /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
794    ///
795    /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
796    /// question with a `bool` and throws away the matches it just found. Two
797    /// consumers need those matches:
798    ///
799    /// - a **which-key popup**, which must show what continues `<leader>`;
800    /// - a **reserved-chord audit**, which cannot check bindings it cannot
801    ///   enumerate — and `sequences` is private, so from outside this crate
802    ///   the multi-key half of the keymap was invisible.
803    ///
804    /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
805    /// a trie is the same later optimization for both.
806    ///
807    /// Pass an empty `prefix` for every sequence in the mode.
808    #[must_use]
809    pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
810        let mut v: Vec<(&[Key], &Binding)> = self
811            .sequences
812            .iter()
813            .filter(|((m, seq), _)| {
814                *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
815            })
816            .map(|((_, seq), b)| (seq.as_slice(), b))
817            .collect();
818        // Sorted, because a which-key popup in HashMap order is a popup that
819        // reorders itself between presses.
820        v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
821        v
822    }
823
824    /// Count of bound multi-key sequences — for `--keymap` / doctor.
825    #[must_use]
826    pub fn sequence_len(&self) -> usize {
827        self.sequences.len()
828    }
829
830    #[must_use]
831    pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
832        let mode = state.mode();
833        // The preemption rule is stated ONCE, in `preemption` below, and read
834        // twice: here, to answer the key, and by `escriba-banzuke`, to decide
835        // whether a DECLARATION for this pair could ever fire. It used to be
836        // three inline `if mode ==` blocks that only this function could see,
837        // so an rc binding a bare `j` in Insert parsed, applied, reported as
838        // applied — and was dead. Nothing could say so, because the fact that
839        // made it dead lived inside the dispatcher.
840        match preemption(mode, key) {
841            Some(Preemption::Always(p)) => return p.resolved(key),
842            Some(Preemption::WhenCounting(p)) if state.pending_count().is_some() => {
843                return p.resolved(key);
844            }
845            _ => {}
846        }
847        if let Some(b) = self.lookup(mode, key) {
848            return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
849        }
850        CountedAction::once(Action::Pending)
851    }
852
853    #[must_use]
854    pub fn len(&self) -> usize {
855        self.bindings.len()
856    }
857
858    #[must_use]
859    pub fn is_empty(&self) -> bool {
860        self.bindings.is_empty()
861    }
862
863    /// Sorted view over every binding — for `escriba --keymap` and palettes.
864    #[must_use]
865    pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
866        let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
867        v.sort_by(|a, b| {
868            (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
869        });
870        v
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    #[test]
879    fn default_vim_has_bindings() {
880        let k = Keymap::default_vim();
881        assert!(k.len() > 10);
882        assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
883        assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
884        assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
885    }
886
887    #[test]
888    fn dispatch_normal_motion() {
889        let k = Keymap::default_vim();
890        let s = ModalState::new();
891        let a = k.dispatch(&s, &Key::Char('h'));
892        assert_eq!(a.count, 1);
893        assert_eq!(a.action, Action::Move(Motion::Left));
894    }
895
896    #[test]
897    fn dispatch_count_prefix_pends() {
898        let k = Keymap::default_vim();
899        let s = ModalState::new();
900        assert!(matches!(
901            k.dispatch(&s, &Key::Char('5')).action,
902            Action::Pending
903        ));
904    }
905
906    #[test]
907    fn dispatch_insert_char() {
908        let k = Keymap::default_vim();
909        let mut s = ModalState::new();
910        s.enter(Mode::Insert);
911        let a = k.dispatch(&s, &Key::Char('a'));
912        assert_eq!(a.action, Action::InsertChar('a'));
913    }
914
915    #[test]
916    fn lisp_structural_motions_bound() {
917        let k = Keymap::default_vim();
918        assert_eq!(
919            k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
920            Action::Move(Motion::ForwardSexp)
921        );
922    }
923
924    #[test]
925    fn default_leader_is_comma() {
926        assert_eq!(Keymap::new().leader(), &Key::Char(','));
927    }
928
929    #[test]
930    fn bind_sequence_stores_multikey_and_resolves() {
931        let mut k = Keymap::new();
932        let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
933        k.bind_sequence(
934            Mode::Normal,
935            seq.clone(),
936            Action::Command {
937                name: "picker.files".into(),
938                args: vec![],
939            },
940            "find files",
941        );
942        // Exact match resolves.
943        let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
944        assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
945        // Proper prefixes are live; the full sequence is NOT a prefix
946        // of itself.
947        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
948        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
949        assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
950        // Wrong mode → not a prefix.
951        assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
952        assert_eq!(k.sequence_len(), 1);
953    }
954
955    #[test]
956    fn bind_sequence_length_one_delegates_to_single() {
957        let mut k = Keymap::new();
958        k.bind_sequence(
959            Mode::Normal,
960            vec![Key::Char('x')],
961            Action::Undo,
962            "x is undo",
963        );
964        // Lands in the single-key table, not the sequence table.
965        assert_eq!(k.sequence_len(), 0);
966        assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
967    }
968}
969
970/// escriba's `Key` as the fleet's chord vocabulary.
971///
972/// # Why a conversion rather than a migration (yet)
973///
974/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
975/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
976/// bitflag SET over 116 key variants. The escriba shape therefore cannot
977/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
978/// (`escriba-input` discards `KeyCode::F(_)` at the door).
979///
980/// Migrating the whole keymap is the destination and it touches 52 call
981/// sites. This conversion is what lets the **reserved-chord audit** run
982/// today, before that lands: a binding escriba cannot even ask about is a
983/// binding that silently dies when the window manager takes its chord.
984///
985/// Returns `None` for a key with no fleet spelling — today the shifted
986/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
987/// carry. That is the honest answer, and an audit must treat an unmappable
988/// key as UNAUDITED rather than as available: silently counting it as clean
989/// is how `Ctrl+Space` stayed hidden.
990#[must_use]
991pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
992    use awase::{Hotkey, Key as AK, Modifiers as M};
993    // `from_name` takes NAMES ("space"), not literal characters. Spelling a
994    // space as " " returns None — which is how `Ctrl+Space` slipped past the
995    // reserved audit while being bound in Insert mode AND owned by the OS.
996    let named = |c: char| match c {
997        ' ' => Some(AK::Space),
998        c => AK::from_name(&c.to_ascii_lowercase().to_string()),
999    };
1000    Some(match key {
1001        Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
1002        Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
1003        Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
1004        Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
1005        // Already a fleet chord — nothing to convert.
1006        Key::Chord(h) => *h,
1007        Key::Esc => Hotkey::new(M::NONE, AK::Escape),
1008        Key::Enter => Hotkey::new(M::NONE, AK::Return),
1009        Key::Tab => Hotkey::new(M::NONE, AK::Tab),
1010        Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
1011        Key::Delete => Hotkey::new(M::NONE, AK::Delete),
1012        Key::Left => Hotkey::new(M::NONE, AK::Left),
1013        Key::Right => Hotkey::new(M::NONE, AK::Right),
1014        Key::Up => Hotkey::new(M::NONE, AK::Up),
1015        Key::Down => Hotkey::new(M::NONE, AK::Down),
1016        Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
1017        Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
1018        Key::Home => Hotkey::new(M::NONE, AK::Home),
1019        Key::End => Hotkey::new(M::NONE, AK::End),
1020    })
1021}
1022
1023#[cfg(test)]
1024mod fleet_vocabulary {
1025    use super::*;
1026
1027    #[test]
1028    fn modifiers_survive_the_conversion() {
1029        let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
1030        assert!(h.modifiers.contains(awase::Modifiers::CTRL));
1031        assert_eq!(h.key, awase::Key::W);
1032    }
1033
1034    #[test]
1035    fn named_keys_map_to_their_fleet_spelling() {
1036        // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
1037        // fleet atlas already warns that these two spellings diverge across
1038        // consumers, which is exactly what a shared vocabulary settles.
1039        assert_eq!(
1040            to_hotkey(&Key::Esc).map(|h| h.key),
1041            Some(awase::Key::Escape)
1042        );
1043        assert_eq!(
1044            to_hotkey(&Key::Enter).map(|h| h.key),
1045            Some(awase::Key::Return)
1046        );
1047    }
1048
1049    #[test]
1050    fn every_variant_of_escribas_key_has_a_fleet_spelling() {
1051        // If one did not, the reserved audit would have a blind spot exactly
1052        // where escriba's vocabulary is unusual — which is where a collision
1053        // is most likely.
1054        let all = [
1055            Key::Char('a'),
1056            Key::Ctrl('a'),
1057            Key::Alt('a'),
1058            Key::Esc,
1059            Key::Enter,
1060            Key::Tab,
1061            Key::Backspace,
1062            Key::Delete,
1063            Key::Left,
1064            Key::Right,
1065            Key::Up,
1066            Key::Down,
1067            Key::PageUp,
1068            Key::PageDown,
1069            Key::Home,
1070            Key::End,
1071        ];
1072        for k in all {
1073            assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
1074        }
1075    }
1076
1077    #[test]
1078    fn sequences_can_now_be_enumerated() {
1079        // `sequences` was private with no accessor, so the multi-key half of
1080        // the keymap was invisible from outside this crate — unauditable and
1081        // un-displayable.
1082        let k = Keymap::default_vim();
1083        let all = k.sequences_extending(Mode::Normal, &[]);
1084        assert!(!all.is_empty(), "the default keymap binds sequences");
1085        let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
1086        assert!(
1087            g.iter()
1088                .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
1089            "a prefix query returns only its own continuations",
1090        );
1091    }
1092}
1093
1094#[cfg(test)]
1095mod collision_detection {
1096    use super::*;
1097
1098    #[test]
1099    fn a_reserved_chord_is_recorded_at_bind_time() {
1100        // Not discovered later by an audit — known the moment it is written.
1101        let mut m = Keymap::new();
1102        m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
1103        let c = m.collisions();
1104        assert_eq!(c.len(), 1, "{c:?}");
1105        assert!(c[0].is_fatal(), "a chord the world owns can never fire");
1106        assert!(
1107            c[0].report().contains("window manager"),
1108            "{}",
1109            c[0].report()
1110        );
1111    }
1112
1113    #[test]
1114    fn a_displaced_binding_is_recorded_but_not_fatal() {
1115        // Overriding is sometimes intended — the shipped rc deliberately
1116        // overrides defaults — so it is REPORTED, never refused. But "my
1117        // plugin's key stopped working" has no other explanation available.
1118        let mut m = Keymap::new();
1119        m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
1120        m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
1121        let c = m.collisions();
1122        assert_eq!(c.len(), 1);
1123        assert!(!c[0].is_fatal());
1124        let r = c[0].report();
1125        assert!(r.contains("first") && r.contains("second"), "{r}");
1126    }
1127
1128    #[test]
1129    // OPENER is shouted because which key is checked IS the point.
1130    #[allow(non_snake_case)]
1131    fn a_sequence_whose_OPENER_is_reserved_is_caught() {
1132        // `alt-j` then anything can never begin, because the first key never
1133        // arrives. Checking only single keys would miss the whole sequence.
1134        let mut m = Keymap::new();
1135        m.bind_sequence(
1136            Mode::Normal,
1137            vec![Key::Alt('j'), Key::Char('x')],
1138            Action::Undo,
1139            "dead sequence",
1140        );
1141        assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
1142    }
1143
1144    #[test]
1145    fn an_ordinary_keymap_records_nothing() {
1146        // The detector must be quiet when there is nothing to say, or it
1147        // becomes noise an operator learns to skip.
1148        let mut m = Keymap::new();
1149        m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
1150        m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
1151        assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1152    }
1153
1154    #[test]
1155    fn the_shipped_default_keymap_is_clean() {
1156        let m = Keymap::default_vim();
1157        assert!(
1158            m.collisions().is_empty(),
1159            "escriba's own defaults must not collide:\n  {}",
1160            m.collisions()
1161                .iter()
1162                .map(Collision::report)
1163                .collect::<Vec<_>>()
1164                .join("\n  "),
1165        );
1166    }
1167}