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#[derive(Debug, Clone)]
125pub struct Keymap {
126    bindings: HashMap<(Mode, Key), Binding>,
127    /// Multi-key sequence bindings (`<leader>ff`, `gg`, `<C-w>h`).
128    /// Keyed by the full key sequence; resolved by the runtime's
129    /// pending-stroke loop ([`lookup_sequence`](Keymap::lookup_sequence)
130    /// + [`is_sequence_prefix`](Keymap::is_sequence_prefix)).
131    sequences: HashMap<(Mode, Vec<Key>), Binding>,
132    /// The prefix `<leader>` resolves to at sequence-apply time.
133    leader: Key,
134    /// Chords the world owns. Consulted on every bind, so a binding that
135    /// cannot fire is known at CONSTRUCTION rather than discovered by an
136    /// operator pressing a dead key.
137    reserved: awase::Reserved,
138    /// Every collision seen while building this keymap, in bind order.
139    collisions: Vec<Collision>,
140}
141
142impl Default for Keymap {
143    fn default() -> Self {
144        Self {
145            bindings: HashMap::new(),
146            sequences: HashMap::new(),
147            reserved: awase::Reserved::fleet_darwin(),
148            collisions: Vec::new(),
149            // blnvim's leader is comma; escriba ships blnvim-parity
150            // defaults, so the prefix users press matches muscle memory.
151            leader: Key::Char(','),
152        }
153    }
154}
155
156impl Keymap {
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    #[must_use]
163    pub fn default_vim() -> Self {
164        let mut m = Self::new();
165        let nm = |m: &mut Keymap, k: Key, a: Action, d: &'static str| m.bind(Mode::Normal, k, a, d);
166        nm(
167            &mut m,
168            Key::Char('h'),
169            Action::Move(Motion::Left),
170            "move left",
171        );
172        nm(
173            &mut m,
174            Key::Char('l'),
175            Action::Move(Motion::Right),
176            "move right",
177        );
178        nm(
179            &mut m,
180            Key::Char('j'),
181            Action::Move(Motion::Down),
182            "move down",
183        );
184        nm(&mut m, Key::Char('k'), Action::Move(Motion::Up), "move up");
185        nm(
186            &mut m,
187            Key::Char('w'),
188            Action::Move(Motion::WordStartNext),
189            "word forward",
190        );
191        nm(
192            &mut m,
193            Key::Char('b'),
194            Action::Move(Motion::WordStartPrev),
195            "word back",
196        );
197        nm(
198            &mut m,
199            Key::Char('0'),
200            Action::Move(Motion::LineStart),
201            "line start",
202        );
203        nm(
204            &mut m,
205            Key::Char('$'),
206            Action::Move(Motion::LineEnd),
207            "line end",
208        );
209        nm(
210            &mut m,
211            Key::Char('G'),
212            Action::Move(Motion::DocEnd),
213            "doc end",
214        );
215        // Operators — `d`/`c`/`y` arm the operator-pending FSM; the next
216        // motion composes (e.g. `dw`, `c$`, `y0`).
217        nm(
218            &mut m,
219            Key::Char('d'),
220            Action::Operator(Operator::Delete),
221            "delete (operator)",
222        );
223        nm(
224            &mut m,
225            Key::Char('c'),
226            Action::Operator(Operator::Change),
227            "change (operator)",
228        );
229        nm(
230            &mut m,
231            Key::Char('y'),
232            Action::Operator(Operator::Yank),
233            "yank (operator)",
234        );
235        // Structural Lisp motions — Alt-prefixed like emacs paredit.
236        nm(
237            &mut m,
238            Key::Alt('f'),
239            Action::Move(Motion::ForwardSexp),
240            "forward sexp",
241        );
242        nm(
243            &mut m,
244            Key::Alt('b'),
245            Action::Move(Motion::BackwardSexp),
246            "backward sexp",
247        );
248        nm(
249            &mut m,
250            Key::Alt('u'),
251            Action::Move(Motion::UpList),
252            "up list",
253        );
254        nm(
255            &mut m,
256            Key::Alt('d'),
257            Action::Move(Motion::DownList),
258            "down list",
259        );
260        // Mode changes.
261        nm(
262            &mut m,
263            Key::Char('i'),
264            Action::ChangeMode(Mode::Insert),
265            "insert",
266        );
267        nm(
268            &mut m,
269            Key::Char('v'),
270            Action::ChangeMode(Mode::Visual),
271            "visual",
272        );
273        nm(
274            &mut m,
275            Key::Char('V'),
276            Action::ChangeMode(Mode::VisualLine),
277            "visual line",
278        );
279        nm(
280            &mut m,
281            Key::Char(':'),
282            Action::ChangeMode(Mode::Command),
283            "command",
284        );
285        nm(&mut m, Key::Char('u'), Action::Undo, "undo");
286        nm(
287            &mut m,
288            Key::Char('.'),
289            Action::RepeatLastChange,
290            "repeat last change",
291        );
292        nm(&mut m, Key::Ctrl('r'), Action::Redo, "redo");
293        // Insert → Normal on Esc.
294        m.bind(
295            Mode::Insert,
296            Key::Esc,
297            Action::ChangeMode(Mode::Normal),
298            "to normal",
299        );
300        // ── Insert-mode editing ───────────────────────────────────────
301        // Until 2026-08-09 `Esc` above was the ONLY Insert-mode binding, and
302        // `dispatch` short-circuits `Key::Char` + `Key::Enter` before the
303        // table is consulted — so every key below fell through to
304        // `Action::Pending` and did nothing. Insert mode could be typed into
305        // and never corrected: no erase, no caret movement. The keys were not
306        // "unimplemented", they were unbound; `Action::Backspace`'s executor
307        // had been waiting for a caller.
308        m.bind(
309            Mode::Insert,
310            Key::Backspace,
311            Action::Backspace,
312            "erase one char",
313        );
314        m.bind(
315            Mode::Insert,
316            Key::Delete,
317            Action::DeleteForward,
318            "delete char at caret",
319        );
320        // Arrows in Insert are vi-compatible (vim's `esckeys`) and are what
321        // "edit it" means to anyone who did not grow up on hjkl. They reuse
322        // the ordinary motions, so the cursor-clamp + viewport-follow
323        // invariants come along unchanged.
324        for (key, motion, label) in [
325            (Key::Left, Motion::Left, "caret left"),
326            (Key::Right, Motion::Right, "caret right"),
327            (Key::Up, Motion::Up, "caret up"),
328            (Key::Down, Motion::Down, "caret down"),
329            (Key::Home, Motion::LineStart, "caret to line start"),
330            (Key::End, Motion::LineEnd, "caret to line end"),
331        ] {
332            m.bind(Mode::Insert, key, Action::Move(motion), label);
333        }
334        m.bind(
335            Mode::Command,
336            Key::Esc,
337            Action::ChangeMode(Mode::Normal),
338            "abort",
339        );
340        m.bind(Mode::Command, Key::Enter, Action::SubmitCommand, "submit");
341        m.bind(
342            Mode::Command,
343            Key::Up,
344            Action::PromptHistory { back: true },
345            "older search",
346        );
347        m.bind(
348            Mode::Command,
349            Key::Down,
350            Action::PromptHistory { back: false },
351            "newer search",
352        );
353        m.bind(
354            Mode::Command,
355            Key::Backspace,
356            Action::Backspace,
357            "erase one char",
358        );
359        m.bind(
360            Mode::Command,
361            Key::Delete,
362            Action::DeleteForward,
363            "delete char at caret",
364        );
365        // Caret editing inside the prompt. Without these the prompt is
366        // append-only, so a typo in the middle of a pattern can only be fixed
367        // by deleting everything back to it.
368        m.bind(
369            Mode::Command,
370            Key::Left,
371            Action::PromptCaret {
372                to: CaretMove::Left,
373            },
374            "caret left",
375        );
376        m.bind(
377            Mode::Command,
378            Key::Right,
379            Action::PromptCaret {
380                to: CaretMove::Right,
381            },
382            "caret right",
383        );
384        m.bind(
385            Mode::Command,
386            Key::Home,
387            Action::PromptCaret {
388                to: CaretMove::Start,
389            },
390            "caret to start",
391        );
392        m.bind(
393            Mode::Command,
394            Key::End,
395            Action::PromptCaret { to: CaretMove::End },
396            "caret to end",
397        );
398        m.bind(
399            Mode::Command,
400            Key::Ctrl('w'),
401            Action::PromptDeleteWord,
402            "delete word before caret",
403        );
404        // Walk the preview without committing — `/pat` then `<C-g><C-g>` is
405        // `/pat<CR>nn`, except Escape still takes you home.
406        m.bind(
407            Mode::Command,
408            Key::Ctrl('g'),
409            Action::SearchPreviewStep { forward: true },
410            "preview next match",
411        );
412        m.bind(
413            Mode::Command,
414            Key::Ctrl('t'),
415            Action::SearchPreviewStep { forward: false },
416            "preview previous match",
417        );
418        m.bind(
419            Mode::Command,
420            Key::Ctrl('u'),
421            Action::PromptClearToStart,
422            "clear to start",
423        );
424
425        // ── search ────────────────────────────────────────────────────
426        // `/` and `?` open the prompt; `<CR>` is the existing SubmitCommand,
427        // which the runtime routes to the search when a search prompt is open.
428        // That routing is typed (Option<Prompt>), not a mode flag to forget.
429        nm(
430            &mut m,
431            Key::Char('/'),
432            Action::SearchOpen(SearchDirection::Forward),
433            "search forward",
434        );
435        nm(
436            &mut m,
437            Key::Char('?'),
438            Action::SearchOpen(SearchDirection::Backward),
439            "search backward",
440        );
441        // `n`/`N` are MOTIONS, not standalone jumps. Binding them to
442        // `Action::Move` is what makes `dn` / `yN` compose — the
443        // operator-pending machine only recognises `Action::Move` as an
444        // operand. `Action::SearchRepeat` remains a valid action (a user rc or
445        // the tatara-lisp binding table may name it) and the runtime routes it
446        // through the same executor, so there is exactly one code path.
447        nm(
448            &mut m,
449            Key::Char('n'),
450            Action::Move(Motion::SearchNext),
451            "next match",
452        );
453        nm(
454            &mut m,
455            Key::Char('N'),
456            Action::Move(Motion::SearchPrev),
457            "previous match",
458        );
459
460        // `gn` / `gN` — the match as an OBJECT, so `cgn` changes the whole
461        // match and `.` repeats that on the next one.
462        m.bind_sequence(
463            Mode::Normal,
464            vec![Key::Char('g'), Key::Char('n')],
465            Action::TextObject(TextObject::NextMatch),
466            "next match (object)",
467        );
468        m.bind_sequence(
469            Mode::Normal,
470            vec![Key::Char('g'), Key::Char('N')],
471            Action::TextObject(TextObject::PrevMatch),
472            "previous match (object)",
473        );
474
475        // ── jumplist ──────────────────────────────────────────────────
476        // The return ticket for every far jump above. Without it a committed
477        // search is a one-way door.
478        nm(&mut m, Key::Ctrl('o'), Action::JumpBack, "jump back");
479        nm(&mut m, Key::Ctrl('i'), Action::JumpForward, "jump forward");
480        nm(
481            &mut m,
482            Key::Char('*'),
483            Action::SearchWord { reverse: false },
484            "search word forward",
485        );
486        nm(
487            &mut m,
488            Key::Char('#'),
489            Action::SearchWord { reverse: true },
490            "search word backward",
491        );
492        m.bind(
493            Mode::Visual,
494            Key::Esc,
495            Action::ChangeMode(Mode::Normal),
496            "to normal",
497        );
498        m.bind(
499            Mode::VisualLine,
500            Key::Esc,
501            Action::ChangeMode(Mode::Normal),
502            "to normal",
503        );
504        m
505    }
506
507    pub fn bind(&mut self, mode: Mode, key: Key, action: Action, desc: impl Into<String>) {
508        let binding = Binding::new(action, desc);
509        self.note_collisions(mode, std::slice::from_ref(&key), &binding);
510        self.bindings.insert((mode, key), binding);
511    }
512
513    /// Record anything about this bind that will surprise its author.
514    ///
515    /// Called on every bind, single or sequence. Detection is DEFAULT-ON and
516    /// costs one hash lookup plus one conversion — a keymap that only tells
517    /// you about collisions when asked is a keymap nobody asks.
518    fn note_collisions(&mut self, mode: Mode, keys: &[Key], binding: &Binding) {
519        let Some(first) = keys.first() else { return };
520        let spelled = format!("{keys:?}");
521
522        // (1) Does the world own it? For a sequence this is its OPENER —
523        // a sequence whose first key never arrives can never begin.
524        if let Some(hk) = to_hotkey(first) {
525            if let Some(why) = self.reserved.refuse(&hk) {
526                self.collisions.push(Collision::Reserved {
527                    mode,
528                    key: spelled.clone(),
529                    description: binding.description.clone(),
530                    why,
531                });
532            }
533        }
534
535        // (2) Is something already here? `HashMap::insert` returns the old
536        // value and every caller dropped it, so a displaced binding left no
537        // trace at all.
538        let existing = if keys.len() == 1 {
539            self.bindings
540                .get(&(mode, first.clone()))
541                .map(|b| &b.description)
542        } else {
543            self.sequences
544                .get(&(mode, keys.to_vec()))
545                .map(|b| &b.description)
546        };
547        if let Some(replaced) = existing {
548            self.collisions.push(Collision::Displaced {
549                mode,
550                key: spelled,
551                replaced: replaced.clone(),
552                with: binding.description.clone(),
553            });
554        }
555    }
556
557    /// Every collision recorded while this keymap was built.
558    ///
559    /// Read by `--list-rc` and reported at boot. An empty slice is the
560    /// claim "every binding escriba ships can actually fire".
561    #[must_use]
562    pub fn collisions(&self) -> &[Collision] {
563        &self.collisions
564    }
565
566    /// Collisions that mean a key can NEVER fire, as opposed to one that was
567    /// deliberately overridden.
568    pub fn fatal_collisions(&self) -> impl Iterator<Item = &Collision> {
569        self.collisions.iter().filter(|c| c.is_fatal())
570    }
571
572    #[must_use]
573    pub fn lookup(&self, mode: Mode, key: &Key) -> Option<&Binding> {
574        self.bindings.get(&(mode, key.clone()))
575    }
576
577    /// The leader key — what `<leader>` resolves to when a sequence
578    /// binding is applied. Defaults to `,` (blnvim parity).
579    #[must_use]
580    pub fn leader(&self) -> &Key {
581        &self.leader
582    }
583
584    /// Override the leader key. Applied before sequence bindings so
585    /// `<leader>`-prefixed specs resolve against the chosen prefix.
586    pub fn set_leader(&mut self, key: Key) {
587        self.leader = key;
588    }
589
590    /// Bind a multi-key SEQUENCE — `<leader>ff` →
591    /// `[Char(','), Char('f'), Char('f')]`, `gg` →
592    /// `[Char('g'), Char('g')]`. A length-1 sequence delegates to
593    /// [`bind`](Keymap::bind) so callers never special-case it; an
594    /// empty sequence is a no-op.
595    pub fn bind_sequence(
596        &mut self,
597        mode: Mode,
598        keys: Vec<Key>,
599        action: Action,
600        desc: impl Into<String>,
601    ) {
602        match keys.as_slice() {
603            [] => {}
604            [single] => self.bind(mode, single.clone(), action, desc),
605            _ => {
606                let binding = Binding::new(action, desc);
607                self.note_collisions(mode, &keys, &binding);
608                self.sequences.insert((mode, keys), binding);
609            }
610        }
611    }
612
613    /// Exact-match lookup for a full key sequence.
614    #[must_use]
615    pub fn lookup_sequence(&self, mode: Mode, keys: &[Key]) -> Option<&Binding> {
616        self.sequences.get(&(mode, keys.to_vec()))
617    }
618
619    /// Does any bound sequence in `mode` STRICTLY extend `prefix`
620    /// (i.e. `prefix` is a proper prefix of a longer bound sequence)?
621    /// Drives the runtime's pending-stroke state: a partial sequence
622    /// that is still a live prefix is held pending rather than
623    /// dispatched. Linear scan — fine at fleet sequence counts; a
624    /// trie is a later optimization if profiling ever asks for it.
625    #[must_use]
626    pub fn is_sequence_prefix(&self, mode: Mode, prefix: &[Key]) -> bool {
627        self.sequences
628            .keys()
629            .any(|(m, seq)| *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix))
630    }
631
632    /// Every bound sequence in `mode` that STRICTLY extends `prefix`.
633    ///
634    /// [`is_sequence_prefix`](Self::is_sequence_prefix) answers the same
635    /// question with a `bool` and throws away the matches it just found. Two
636    /// consumers need those matches:
637    ///
638    /// - a **which-key popup**, which must show what continues `<leader>`;
639    /// - a **reserved-chord audit**, which cannot check bindings it cannot
640    ///   enumerate — and `sequences` is private, so from outside this crate
641    ///   the multi-key half of the keymap was invisible.
642    ///
643    /// Same linear scan as `is_sequence_prefix`, so this costs nothing extra;
644    /// a trie is the same later optimization for both.
645    ///
646    /// Pass an empty `prefix` for every sequence in the mode.
647    #[must_use]
648    pub fn sequences_extending(&self, mode: Mode, prefix: &[Key]) -> Vec<(&[Key], &Binding)> {
649        let mut v: Vec<(&[Key], &Binding)> = self
650            .sequences
651            .iter()
652            .filter(|((m, seq), _)| {
653                *m == mode && seq.len() > prefix.len() && seq.starts_with(prefix)
654            })
655            .map(|((_, seq), b)| (seq.as_slice(), b))
656            .collect();
657        // Sorted, because a which-key popup in HashMap order is a popup that
658        // reorders itself between presses.
659        v.sort_by(|a, b| format!("{:?}", a.0).cmp(&format!("{:?}", b.0)));
660        v
661    }
662
663    /// Count of bound multi-key sequences — for `--keymap` / doctor.
664    #[must_use]
665    pub fn sequence_len(&self) -> usize {
666        self.sequences.len()
667    }
668
669    #[must_use]
670    pub fn dispatch(&self, state: &ModalState, key: &Key) -> CountedAction {
671        let mode = state.mode();
672        if mode == Mode::Normal {
673            if let Key::Char(c) = key {
674                if c.is_ascii_digit() && *c != '0' {
675                    return CountedAction::once(Action::Pending);
676                }
677                if *c == '0' && state.pending_count().is_some() {
678                    return CountedAction::once(Action::Pending);
679                }
680            }
681        }
682        if mode == Mode::Insert {
683            if let Key::Char(c) = key {
684                return CountedAction::once(Action::InsertChar(*c));
685            }
686            if matches!(key, Key::Enter) {
687                return CountedAction::once(Action::InsertChar('\n'));
688            }
689        }
690        if mode == Mode::Command {
691            if let Key::Char(c) = key {
692                return CountedAction::once(Action::InsertChar(*c));
693            }
694        }
695        if let Some(b) = self.lookup(mode, key) {
696            return CountedAction::repeated(state.pending_count().unwrap_or(1), b.action.clone());
697        }
698        CountedAction::once(Action::Pending)
699    }
700
701    #[must_use]
702    pub fn len(&self) -> usize {
703        self.bindings.len()
704    }
705
706    #[must_use]
707    pub fn is_empty(&self) -> bool {
708        self.bindings.is_empty()
709    }
710
711    /// Sorted view over every binding — for `escriba --keymap` and palettes.
712    #[must_use]
713    pub fn entries_sorted(&self) -> Vec<(&Mode, &Key, &Binding)> {
714        let mut v: Vec<_> = self.bindings.iter().map(|((m, k), b)| (m, k, b)).collect();
715        v.sort_by(|a, b| {
716            (a.0.as_str(), format!("{:?}", a.1)).cmp(&(b.0.as_str(), format!("{:?}", b.1)))
717        });
718        v
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn default_vim_has_bindings() {
728        let k = Keymap::default_vim();
729        assert!(k.len() > 10);
730        assert!(k.lookup(Mode::Normal, &Key::Char('h')).is_some());
731        assert!(k.lookup(Mode::Insert, &Key::Esc).is_some());
732        assert!(k.lookup(Mode::Normal, &Key::Alt('f')).is_some());
733    }
734
735    #[test]
736    fn dispatch_normal_motion() {
737        let k = Keymap::default_vim();
738        let s = ModalState::new();
739        let a = k.dispatch(&s, &Key::Char('h'));
740        assert_eq!(a.count, 1);
741        assert_eq!(a.action, Action::Move(Motion::Left));
742    }
743
744    #[test]
745    fn dispatch_count_prefix_pends() {
746        let k = Keymap::default_vim();
747        let s = ModalState::new();
748        assert!(matches!(
749            k.dispatch(&s, &Key::Char('5')).action,
750            Action::Pending
751        ));
752    }
753
754    #[test]
755    fn dispatch_insert_char() {
756        let k = Keymap::default_vim();
757        let mut s = ModalState::new();
758        s.enter(Mode::Insert);
759        let a = k.dispatch(&s, &Key::Char('a'));
760        assert_eq!(a.action, Action::InsertChar('a'));
761    }
762
763    #[test]
764    fn lisp_structural_motions_bound() {
765        let k = Keymap::default_vim();
766        assert_eq!(
767            k.lookup(Mode::Normal, &Key::Alt('f')).unwrap().action,
768            Action::Move(Motion::ForwardSexp)
769        );
770    }
771
772    #[test]
773    fn default_leader_is_comma() {
774        assert_eq!(Keymap::new().leader(), &Key::Char(','));
775    }
776
777    #[test]
778    fn bind_sequence_stores_multikey_and_resolves() {
779        let mut k = Keymap::new();
780        let seq = vec![Key::Char(','), Key::Char('f'), Key::Char('f')];
781        k.bind_sequence(
782            Mode::Normal,
783            seq.clone(),
784            Action::Command {
785                name: "picker.files".into(),
786                args: vec![],
787            },
788            "find files",
789        );
790        // Exact match resolves.
791        let b = k.lookup_sequence(Mode::Normal, &seq).expect("seq bound");
792        assert!(matches!(&b.action, Action::Command { name, .. } if name == "picker.files"));
793        // Proper prefixes are live; the full sequence is NOT a prefix
794        // of itself.
795        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(',')]));
796        assert!(k.is_sequence_prefix(Mode::Normal, &[Key::Char(','), Key::Char('f')]));
797        assert!(!k.is_sequence_prefix(Mode::Normal, &seq));
798        // Wrong mode → not a prefix.
799        assert!(!k.is_sequence_prefix(Mode::Insert, &[Key::Char(',')]));
800        assert_eq!(k.sequence_len(), 1);
801    }
802
803    #[test]
804    fn bind_sequence_length_one_delegates_to_single() {
805        let mut k = Keymap::new();
806        k.bind_sequence(
807            Mode::Normal,
808            vec![Key::Char('x')],
809            Action::Undo,
810            "x is undo",
811        );
812        // Lands in the single-key table, not the sequence table.
813        assert_eq!(k.sequence_len(), 0);
814        assert!(k.lookup(Mode::Normal, &Key::Char('x')).is_some());
815    }
816}
817
818/// escriba's `Key` as the fleet's chord vocabulary.
819///
820/// # Why a conversion rather than a migration (yet)
821///
822/// `escriba_keymap::Key` folds the modifier INTO the key — `Ctrl(char)`,
823/// `Alt(char)` — over 16 variants. `awase::Hotkey` carries modifiers as a
824/// bitflag SET over 116 key variants. The escriba shape therefore cannot
825/// express `Ctrl+Shift+P`, cannot carry `Super`, and has no F-keys at all
826/// (`escriba-input` discards `KeyCode::F(_)` at the door).
827///
828/// Migrating the whole keymap is the destination and it touches 52 call
829/// sites. This conversion is what lets the **reserved-chord audit** run
830/// today, before that lands: a binding escriba cannot even ask about is a
831/// binding that silently dies when the window manager takes its chord.
832///
833/// Returns `None` for a key with no fleet spelling — today the shifted
834/// digits and punctuation (`#`, `$`, `*`), which awase's `Key` does not
835/// carry. That is the honest answer, and an audit must treat an unmappable
836/// key as UNAUDITED rather than as available: silently counting it as clean
837/// is how `Ctrl+Space` stayed hidden.
838#[must_use]
839pub fn to_hotkey(key: &Key) -> Option<awase::Hotkey> {
840    use awase::{Hotkey, Key as AK, Modifiers as M};
841    // `from_name` takes NAMES ("space"), not literal characters. Spelling a
842    // space as " " returns None — which is how `Ctrl+Space` slipped past the
843    // reserved audit while being bound in Insert mode AND owned by the OS.
844    let named = |c: char| match c {
845        ' ' => Some(AK::Space),
846        c => AK::from_name(&c.to_ascii_lowercase().to_string()),
847    };
848    Some(match key {
849        Key::Char(c) => Hotkey::new(M::NONE, named(*c)?),
850        Key::Ctrl(c) => Hotkey::new(M::CTRL, named(*c)?),
851        Key::Alt(c) => Hotkey::new(M::ALT, named(*c)?),
852        Key::F(n) => Hotkey::new(M::NONE, AK::from_name(&format!("f{n}"))?),
853        // Already a fleet chord — nothing to convert.
854        Key::Chord(h) => *h,
855        Key::Esc => Hotkey::new(M::NONE, AK::Escape),
856        Key::Enter => Hotkey::new(M::NONE, AK::Return),
857        Key::Tab => Hotkey::new(M::NONE, AK::Tab),
858        Key::Backspace => Hotkey::new(M::NONE, AK::Backspace),
859        Key::Delete => Hotkey::new(M::NONE, AK::Delete),
860        Key::Left => Hotkey::new(M::NONE, AK::Left),
861        Key::Right => Hotkey::new(M::NONE, AK::Right),
862        Key::Up => Hotkey::new(M::NONE, AK::Up),
863        Key::Down => Hotkey::new(M::NONE, AK::Down),
864        Key::PageUp => Hotkey::new(M::NONE, AK::PageUp),
865        Key::PageDown => Hotkey::new(M::NONE, AK::PageDown),
866        Key::Home => Hotkey::new(M::NONE, AK::Home),
867        Key::End => Hotkey::new(M::NONE, AK::End),
868    })
869}
870
871#[cfg(test)]
872mod fleet_vocabulary {
873    use super::*;
874
875    #[test]
876    fn modifiers_survive_the_conversion() {
877        let h = to_hotkey(&Key::Ctrl('w')).expect("ctrl+w maps");
878        assert!(h.modifiers.contains(awase::Modifiers::CTRL));
879        assert_eq!(h.key, awase::Key::W);
880    }
881
882    #[test]
883    fn named_keys_map_to_their_fleet_spelling() {
884        // escriba says `Esc`/`Enter`; awase says `Escape`/`Return`. The
885        // fleet atlas already warns that these two spellings diverge across
886        // consumers, which is exactly what a shared vocabulary settles.
887        assert_eq!(
888            to_hotkey(&Key::Esc).map(|h| h.key),
889            Some(awase::Key::Escape)
890        );
891        assert_eq!(
892            to_hotkey(&Key::Enter).map(|h| h.key),
893            Some(awase::Key::Return)
894        );
895    }
896
897    #[test]
898    fn every_variant_of_escribas_key_has_a_fleet_spelling() {
899        // If one did not, the reserved audit would have a blind spot exactly
900        // where escriba's vocabulary is unusual — which is where a collision
901        // is most likely.
902        let all = [
903            Key::Char('a'),
904            Key::Ctrl('a'),
905            Key::Alt('a'),
906            Key::Esc,
907            Key::Enter,
908            Key::Tab,
909            Key::Backspace,
910            Key::Delete,
911            Key::Left,
912            Key::Right,
913            Key::Up,
914            Key::Down,
915            Key::PageUp,
916            Key::PageDown,
917            Key::Home,
918            Key::End,
919        ];
920        for k in all {
921            assert!(to_hotkey(&k).is_some(), "{k:?} has no fleet spelling");
922        }
923    }
924
925    #[test]
926    fn sequences_can_now_be_enumerated() {
927        // `sequences` was private with no accessor, so the multi-key half of
928        // the keymap was invisible from outside this crate — unauditable and
929        // un-displayable.
930        let k = Keymap::default_vim();
931        let all = k.sequences_extending(Mode::Normal, &[]);
932        assert!(!all.is_empty(), "the default keymap binds sequences");
933        let g = k.sequences_extending(Mode::Normal, &[Key::Char('g')]);
934        assert!(
935            g.iter()
936                .all(|(seq, _)| seq.first() == Some(&Key::Char('g'))),
937            "a prefix query returns only its own continuations",
938        );
939    }
940}
941
942#[cfg(test)]
943mod collision_detection {
944    use super::*;
945
946    #[test]
947    fn a_reserved_chord_is_recorded_at_bind_time() {
948        // Not discovered later by an audit — known the moment it is written.
949        let mut m = Keymap::new();
950        m.bind(Mode::Normal, Key::Alt('j'), Action::Undo, "focus down?");
951        let c = m.collisions();
952        assert_eq!(c.len(), 1, "{c:?}");
953        assert!(c[0].is_fatal(), "a chord the world owns can never fire");
954        assert!(
955            c[0].report().contains("window manager"),
956            "{}",
957            c[0].report()
958        );
959    }
960
961    #[test]
962    fn a_displaced_binding_is_recorded_but_not_fatal() {
963        // Overriding is sometimes intended — the shipped rc deliberately
964        // overrides defaults — so it is REPORTED, never refused. But "my
965        // plugin's key stopped working" has no other explanation available.
966        let mut m = Keymap::new();
967        m.bind(Mode::Normal, Key::Char('x'), Action::Undo, "first");
968        m.bind(Mode::Normal, Key::Char('x'), Action::Redo, "second");
969        let c = m.collisions();
970        assert_eq!(c.len(), 1);
971        assert!(!c[0].is_fatal());
972        let r = c[0].report();
973        assert!(r.contains("first") && r.contains("second"), "{r}");
974    }
975
976    #[test]
977    // OPENER is shouted because which key is checked IS the point.
978    #[allow(non_snake_case)]
979    fn a_sequence_whose_OPENER_is_reserved_is_caught() {
980        // `alt-j` then anything can never begin, because the first key never
981        // arrives. Checking only single keys would miss the whole sequence.
982        let mut m = Keymap::new();
983        m.bind_sequence(
984            Mode::Normal,
985            vec![Key::Alt('j'), Key::Char('x')],
986            Action::Undo,
987            "dead sequence",
988        );
989        assert!(m.fatal_collisions().count() == 1, "{:?}", m.collisions(),);
990    }
991
992    #[test]
993    fn an_ordinary_keymap_records_nothing() {
994        // The detector must be quiet when there is nothing to say, or it
995        // becomes noise an operator learns to skip.
996        let mut m = Keymap::new();
997        m.bind(Mode::Normal, Key::Char('h'), Action::Undo, "left");
998        m.bind(Mode::Normal, Key::Ctrl('w'), Action::Redo, "window prefix");
999        assert!(m.collisions().is_empty(), "{:?}", m.collisions());
1000    }
1001
1002    #[test]
1003    fn the_shipped_default_keymap_is_clean() {
1004        let m = Keymap::default_vim();
1005        assert!(
1006            m.collisions().is_empty(),
1007            "escriba's own defaults must not collide:\n  {}",
1008            m.collisions()
1009                .iter()
1010                .map(Collision::report)
1011                .collect::<Vec<_>>()
1012                .join("\n  "),
1013        );
1014    }
1015}