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