Skip to main content

lattice_protocol/
chord.rs

1//! Renderer-neutral chord representation -- the typed canonical
2//! form the keymap trie indexes by.
3//!
4//! One [`KeyChord`] is one keypress: a [`KeyKind`] (a character or a named
5//! [`SpecialKey`]) plus [`KeyMods`]. A binding path is a sequence of them,
6//! written in vim notation and parsed by [`parse_chord_sequence`]; `Display`
7//! prints a chord back in the same notation, so parse → display → parse is
8//! the identity for every chord the parser can produce (except `<F13>`..
9//! `<F24>`; see [`special_label`]).
10//!
11//! # Examples
12//!
13//! ```
14//! use lattice_protocol::{KeyChord, KeyKind, KeyMods, SpecialKey, parse_chord_sequence};
15//!
16//! let seq = parse_chord_sequence("<C-w>j<Esc>").unwrap();
17//! assert_eq!(
18//!     seq,
19//!     vec![
20//!         KeyChord::ctrl('w'),
21//!         KeyChord::char('j'),
22//!         KeyChord::special(SpecialKey::Esc),
23//!     ]
24//! );
25//!
26//! // Round-trip through the canonical spelling.
27//! let text: String = seq.iter().map(ToString::to_string).collect();
28//! assert_eq!(text, "<C-w>j<Esc>");
29//! assert_eq!(parse_chord_sequence(&text).unwrap(), seq);
30//!
31//! // Shift on a letter folds into its case; on a named key it is kept.
32//! assert_eq!(parse_chord_sequence("<S-a>").unwrap(), vec![KeyChord::char('A')]);
33//! let shift_tab = KeyChord::new(KeyKind::Special(SpecialKey::Tab), KeyMods::SHIFT);
34//! assert_eq!(shift_tab.to_string(), "<S-Tab>");
35//! ```
36//!
37//! K.2.1 (2026-06-01): moved from `lattice-host::chord` into
38//! `lattice-protocol`, alongside the other renderer-neutral wire
39//! types (`Position`, `Edit`, `Selection`, …). The substrate
40//! sits at the dependency floor so any crate that constructs a
41//! binding -- including mode crates like `lattice-multibuffer`
42//! that contribute keymaps via `Mode::keymap()` -- can do so
43//! without depending on `lattice-host`. `lattice-host::chord` is
44//! retained as a re-export shim for one release cycle to avoid
45//! downstream churn.
46//!
47//! Phase 5.4 split: every type + parser + formatter here is pure
48//! data. The crossterm-coupled side (`KeyEvent → KeyChord`
49//! conversion, `format_chord` for ratatui-driven describe-key
50//! output) lives in `lattice-ui-tui::chord` and reaches into the
51//! neutral types defined here. The future `lattice-ui-gpui` ships
52//! its own adapter from GPUI's key event type into the same
53//! [`KeyChord`] without coordinating with the TUI's adapter.
54//!
55//! ## Notation conventions
56//!
57//! Match the strings the keymap registry catalog uses:
58//!
59//! - Bare printable char: `"a"`, `"$"`, `"0"`. No angles.
60//! - Modifier-only-Shift on a printable char: folded into the char
61//!   (`"A"`, not `"<S-a>"`). Shift on a non-character key keeps the
62//!   prefix: `"<S-Tab>"`, `"<S-F1>"`.
63//! - Ctrl: `"<C-x>"` -- always lowercase letter, even if the
64//!   keyboard reports it uppercase.
65//! - Alt / Meta: `"<M-x>"` (`"<A-x>"` is accepted on input).
66//! - Super / Cmd: `"<D-x>"`.
67//! - Combined modifiers in canonical order `C, S, M, D`: `"<C-S-x>"`,
68//!   `"<C-M-x>"`. Input accepts them in any order; each at most once.
69//! - Named special keys: `<Esc>`, `<Tab>`, `<CR>`, `<BS>`, `<Up>`,
70//!   `<Down>`, `<Left>`, `<Right>`, `<Home>`, `<End>`, `<PageUp>`,
71//!   `<PageDown>`, `<Insert>`, `<Delete>`, `<F1>`-`<F12>`, `<Space>`.
72//!   Input also accepts the aliases `<Escape>`, `<Enter>`, `<Return>`,
73//!   `<Backspace>`, `<Ins>`, `<Del>` and `<F13>`-`<F24>`.
74//! - An unmodified `<Space>` is the space *character* (`Char(' ')`), and
75//!   prints as `<Space>`; a modified one (`<C-Space>`) is the special key.
76//! - Literal `<` types as `<lt>` (vim convention) so the parser
77//!   reading these strings can disambiguate.
78
79use std::fmt;
80use std::str::FromStr;
81
82/// Canonical, stack-allocated representation of one chord.
83///
84/// `Copy` so call sites can pass it freely without lifetime
85/// gymnastics; `Hash` + `Eq` so it works as a `HashMap` key in
86/// the keymap trie. Memory: 8 bytes (1-byte mod bitfield + 1-byte
87/// discriminant + a 4-byte char or 1-byte SpecialKey, padded).
88///
89/// Parse one chord with `str::parse` (`FromStr`), a sequence with
90/// [`parse_chord_sequence`]; `Display` writes the canonical notation.
91///
92/// # Examples
93///
94/// ```
95/// use lattice_protocol::{ChordParseError, KeyChord, KeyKind, KeyMods, SpecialKey};
96///
97/// let chord: KeyChord = "<C-S-Tab>".parse().unwrap();
98/// assert_eq!(chord.key, KeyKind::Special(SpecialKey::Tab));
99/// assert_eq!(chord.mods, KeyMods::CTRL | KeyMods::SHIFT);
100/// assert_eq!(chord.to_string(), "<C-S-Tab>");
101///
102/// // `<` and a bare space are escaped when printed.
103/// assert_eq!(KeyChord::char('<').to_string(), "<lt>");
104/// assert_eq!(KeyChord::char(' ').to_string(), "<Space>");
105///
106/// // `FromStr` wants exactly one chord.
107/// assert!(matches!("ab".parse::<KeyChord>(), Err(ChordParseError::BodyTooLong { .. })));
108/// ```
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub struct KeyChord {
111    /// Which key was pressed.
112    pub key: KeyKind,
113    /// Modifiers held with it. For a letter, Shift is encoded in the case of
114    /// [`KeyKind::Char`] instead, so this carries no SHIFT bit.
115    pub mods: KeyMods,
116}
117
118/// What kind of key the chord represents.
119///
120/// `Char` covers printable characters and Ctrl/Alt-modified chars
121/// (the modifier lives in `mods`). For *letters* the case
122/// encodes shift (vim convention: `A` is shift+a; we don't carry
123/// `KeyMods::SHIFT` for plain letters). For non-letter chars
124/// (`$`, `0`, `<`, ...) the case is the only valid form.
125///
126/// `Special` covers named keys (`Esc`, `Enter`, `Tab`, `Up`, ...)
127/// where shift IS carried separately because `<S-Tab>` is a
128/// genuinely different chord from `<Tab>`.
129///
130/// Function keys live in `Special::F(u8)` rather than as their
131/// own enum variant to keep the type compact; `1..=24` covers
132/// every reasonable terminal.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134pub enum KeyKind {
135    /// A character key: the character it produces (already shifted, so `A`
136    /// or `$`, never `a`+Shift or `4`+Shift).
137    Char(char),
138    /// A named, non-character key.
139    Special(SpecialKey),
140}
141
142/// Named special keys. Renderer-neutral; crossterm's
143/// `KeyCode::BackTab` is normalised away by the TUI adapter
144/// (`from_crossterm`) into `Special(Tab) + KeyMods::SHIFT` so the
145/// trie has one entry for "shift-tab" rather than two ambiguous
146/// ones.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub enum SpecialKey {
149    /// Escape — `<Esc>`.
150    Esc,
151    /// Return / Enter — `<CR>`.
152    Enter,
153    /// Tab — `<Tab>`; Shift-Tab is this plus [`KeyMods::SHIFT`].
154    Tab,
155    /// Backspace — `<BS>`.
156    Backspace,
157    /// Space *with a modifier* (`<C-Space>`). A bare space is
158    /// `KeyKind::Char(' ')`; see the module notes.
159    Space,
160    /// Arrow up — `<Up>`.
161    Up,
162    /// Arrow down — `<Down>`.
163    Down,
164    /// Arrow left — `<Left>`.
165    Left,
166    /// Arrow right — `<Right>`.
167    Right,
168    /// Home — `<Home>`.
169    Home,
170    /// End — `<End>`.
171    End,
172    /// Page Up — `<PageUp>`.
173    PageUp,
174    /// Page Down — `<PageDown>`.
175    PageDown,
176    /// Insert — `<Insert>`.
177    Insert,
178    /// Forward delete — `<Delete>`.
179    Delete,
180    /// Function keys F1..=F24. `F(0)` is reserved (invalid).
181    F(u8),
182}
183
184/// Modifier bitfield. `Copy + Eq + Hash` so the whole `KeyChord`
185/// fits in a CPU register.
186///
187/// The raw `u8` is public for adapters; prefer the named constants, which
188/// combine with `|` (or [`Self::with`]).
189///
190/// # Examples
191///
192/// ```
193/// use lattice_protocol::KeyMods;
194///
195/// let mods = KeyMods::CTRL | KeyMods::ALT;
196/// assert!(mods.ctrl() && mods.alt() && !mods.shift());
197/// assert_eq!(mods.without(KeyMods::ALT), KeyMods::CTRL);
198/// assert!(KeyMods::NONE.is_empty());
199/// ```
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
201pub struct KeyMods(pub u8);
202
203impl KeyMods {
204    /// No modifiers.
205    pub const NONE: Self = Self(0);
206    /// Control — `C-` in notation.
207    pub const CTRL: Self = Self(1 << 0);
208    /// Shift — `S-` in notation (only carried for non-letter keys).
209    pub const SHIFT: Self = Self(1 << 1);
210    /// Alt / Meta — `M-` in notation.
211    pub const ALT: Self = Self(1 << 2);
212    /// Super / Cmd / Windows — `D-` in notation.
213    pub const SUPER: Self = Self(1 << 3);
214
215    /// Whether Control is held.
216    #[inline]
217    pub const fn ctrl(self) -> bool {
218        self.0 & Self::CTRL.0 != 0
219    }
220    /// Whether Shift is held.
221    #[inline]
222    pub const fn shift(self) -> bool {
223        self.0 & Self::SHIFT.0 != 0
224    }
225    /// Whether Alt / Meta is held.
226    #[inline]
227    pub const fn alt(self) -> bool {
228        self.0 & Self::ALT.0 != 0
229    }
230    /// Whether Super is held. (Trailing underscore: `super` is a keyword.)
231    #[inline]
232    pub const fn super_(self) -> bool {
233        self.0 & Self::SUPER.0 != 0
234    }
235    /// Whether no modifier is held.
236    #[inline]
237    pub const fn is_empty(self) -> bool {
238        self.0 == 0
239    }
240
241    /// Add `other`'s modifiers (a `const` spelling of `self | other`).
242    #[inline]
243    pub const fn with(self, other: Self) -> Self {
244        Self(self.0 | other.0)
245    }
246
247    /// Strip a modifier (used by renderer adapters to clear the
248    /// redundant SHIFT bit on bare printable chars where the
249    /// terminal already encoded shift in the case / shifted
250    /// symbol).
251    #[inline]
252    pub const fn without(self, other: Self) -> Self {
253        Self(self.0 & !other.0)
254    }
255}
256
257impl std::ops::BitOr for KeyMods {
258    type Output = Self;
259    #[inline]
260    fn bitor(self, rhs: Self) -> Self {
261        Self(self.0 | rhs.0)
262    }
263}
264
265/// Parse-side error variants. Detail-level so `:bind`-style error
266/// messages can surface what was wrong.
267///
268/// Every `at` is the byte offset, in the string handed to the parser, of the
269/// token that failed — the `<` of an angle token, or the character itself.
270///
271/// # Examples
272///
273/// ```
274/// use lattice_protocol::{ChordParseError, parse_chord_sequence};
275///
276/// assert_eq!(parse_chord_sequence(""), Err(ChordParseError::Empty));
277/// assert_eq!(
278///     parse_chord_sequence("g<C-w"),
279///     Err(ChordParseError::UnterminatedAngle { at: 1 })
280/// );
281/// assert!(matches!(
282///     parse_chord_sequence("<Foo>"),
283///     Err(ChordParseError::UnknownName { .. })
284/// ));
285/// ```
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum ChordParseError {
288    /// String was empty.
289    Empty,
290    /// `<...>` token had no closing `>`.
291    UnterminatedAngle {
292        /// Byte offset of the unclosed `<`.
293        at: usize,
294    },
295    /// `<...>` token body was empty (`<>`).
296    EmptyAngle {
297        /// Byte offset of the `<`.
298        at: usize,
299    },
300    /// `<...>` body referenced an unknown name (`<Foo>`, `<F99>`,
301    /// `<C-S-X>` where the body chunk after modifiers is
302    /// unrecognised).
303    UnknownName {
304        /// The unrecognised name, modifiers stripped (`Foo`).
305        name: String,
306        /// Byte offset of the token's `<`.
307        at: usize,
308    },
309    /// Modifier prefix (`C-`, `S-`, `M-`) without a body (`<C->`).
310    DanglingModifier {
311        /// Byte offset of the token's `<`.
312        at: usize,
313    },
314    /// The same modifier appeared twice in one token (`<C-C-x>`).
315    DuplicateModifier {
316        /// Byte offset of the token's `<`.
317        at: usize,
318    },
319    /// Input that should be exactly one chord was longer. Raised only by
320    /// `KeyChord::from_str`, for trailing input after the first chord
321    /// (`"ab"`, `"<Esc>x"`); a multi-character body such as `<C-foo>`
322    /// reports [`Self::UnknownName`] instead.
323    BodyTooLong {
324        /// The whole input string.
325        name: String,
326        /// Byte offset of the first chord (always 0).
327        at: usize,
328    },
329    /// Sequence parser saw a stray `>` (no matching `<`).
330    StrayClose {
331        /// Byte offset of the `>`.
332        at: usize,
333    },
334}
335
336impl fmt::Display for ChordParseError {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        match self {
339            Self::Empty => write!(f, "empty chord string"),
340            Self::UnterminatedAngle { at } => {
341                write!(f, "unterminated `<...>` at byte {at}")
342            }
343            Self::EmptyAngle { at } => {
344                write!(f, "empty `<>` at byte {at}")
345            }
346            Self::UnknownName { name, at } => {
347                write!(f, "unknown chord name `{name}` at byte {at}")
348            }
349            Self::DanglingModifier { at } => {
350                write!(f, "dangling modifier prefix at byte {at}")
351            }
352            Self::DuplicateModifier { at } => {
353                write!(f, "duplicate modifier at byte {at}")
354            }
355            Self::BodyTooLong { name, at } => {
356                write!(
357                    f,
358                    "body `{name}` after modifiers is not a single chord at byte {at}"
359                )
360            }
361            Self::StrayClose { at } => {
362                write!(f, "stray `>` at byte {at}")
363            }
364        }
365    }
366}
367
368impl std::error::Error for ChordParseError {}
369
370impl KeyChord {
371    /// Build directly from kind + mods. Useful for tests +
372    /// internal callers; production callers go through the
373    /// renderer-specific adapter (`lattice_ui_tui::chord::from_event`
374    /// for the TUI, the analogous function in the future GPUI
375    /// adapter).
376    #[inline]
377    pub const fn new(key: KeyKind, mods: KeyMods) -> Self {
378        Self { key, mods }
379    }
380
381    /// Plain printable character with no modifiers.
382    #[inline]
383    pub const fn char(c: char) -> Self {
384        Self {
385            key: KeyKind::Char(c),
386            mods: KeyMods::NONE,
387        }
388    }
389
390    /// Ctrl-modified character. Letter case is normalised by the
391    /// caller; convention is lowercase (`<C-c>`, not `<C-C>`).
392    #[inline]
393    pub const fn ctrl(c: char) -> Self {
394        Self {
395            key: KeyKind::Char(c),
396            mods: KeyMods::CTRL,
397        }
398    }
399
400    /// Special key with no modifiers.
401    #[inline]
402    pub const fn special(k: SpecialKey) -> Self {
403        Self {
404            key: KeyKind::Special(k),
405            mods: KeyMods::NONE,
406        }
407    }
408}
409
410/// One element of a keymap registration path.
411///
412/// `Literal` matches a specific chord; `CharLiteral` matches any
413/// single bare-char chord (no modifiers). The wildcard captures
414/// the matched char so the resulting [`crate::ids::CommandId`]
415/// invocation can receive it (mark / register / find-char
416/// targets all flow through this).
417///
418/// Lives in `lattice-protocol` alongside [`KeyChord`] because
419/// mode crates (which contribute keymap bindings via
420/// `Mode::keymap()`) need to construct paths without depending
421/// on `lattice-host`. The matcher engine
422/// (`KeymapTrie` / `KeymapLayer` / `BoundCommand`) stays in host:
423/// it owns the lookup hot path, not the wire shape.
424#[derive(Debug, Clone, PartialEq, Eq, Hash)]
425pub enum ChordPattern {
426    /// Matches exactly this chord.
427    Literal(KeyChord),
428    /// Matches any single unmodified character chord, capturing the char
429    /// (the `{char}` of `m{char}`, `"{char}`, `f{char}`).
430    CharLiteral,
431}
432
433impl fmt::Display for KeyChord {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        // Plain printable chars with no modifiers render bare, except the two
436        // that cannot survive it: `<` escapes as `<lt>` so the parser can
437        // round-trip without ambiguity, and a literal space escapes as
438        // `<Space>` for the same reason plus a second one.
439        //
440        // The parser round-trip alone does hold for a bare `" "` —
441        // `parse_chord_sequence(" ")` reads it back as `Char(' ')`. What does
442        // NOT hold is every context that delimits on whitespace or asks a
443        // human to read the result:
444        //
445        // - the `:` line. `:describe-key` captures chords by appending
446        //   `Display` tokens to the command line, so a captured space
447        //   appended `" "` and the ex-parser split the argument on it — the
448        //   user pressed Space and got `describe-key` with no argument.
449        // - every listing. `:map`, `:keymap`, which-key and describe-key's own
450        //   "… is not bound" line rendered the leader as an INVISIBLE column.
451        //   `<Space>ff` reads; ` ff` does not.
452        //
453        // The chord itself is unchanged — `Char(' ')`, what both decoders
454        // produce for an unmodified space (see `parse_angle_body`). This is
455        // only how it is spelled back out.
456        if self.mods.is_empty()
457            && let KeyKind::Char(c) = self.key
458        {
459            if c == '<' {
460                return f.write_str("<lt>");
461            }
462            if c == ' ' {
463                return f.write_str("<Space>");
464            }
465            return write!(f, "{c}");
466        }
467
468        f.write_str("<")?;
469        if self.mods.ctrl() {
470            f.write_str("C-")?;
471        }
472        if self.mods.shift() {
473            f.write_str("S-")?;
474        }
475        if self.mods.alt() {
476            f.write_str("M-")?;
477        }
478        if self.mods.super_() {
479            f.write_str("D-")?;
480        }
481        match self.key {
482            KeyKind::Char(c) => {
483                // Ctrl/Alt-letter is rendered lowercase
484                // (normalised by the renderer adapter); `<C-S-c>`
485                // is distinct from `<C-c>` only by the explicit
486                // S- prefix.
487                write!(f, "{c}")?;
488            }
489            KeyKind::Special(s) => {
490                f.write_str(special_label(s))?;
491            }
492        }
493        f.write_str(">")
494    }
495}
496
497/// Canonical name for a `SpecialKey`. Round-trips through
498/// `parse_special`. Renderer-neutral text; both the TUI's
499/// `format_chord` and any future GPUI describe-key renderer use
500/// this label.
501///
502/// The one exception to the round-trip: `F13`..=`F24` (and the invalid
503/// `F(0)`) all render as `"F?"`, although the parser accepts `<F13>`..`<F24>`.
504///
505/// # Examples
506///
507/// ```
508/// use lattice_protocol::{SpecialKey, special_label};
509///
510/// assert_eq!(special_label(SpecialKey::Enter), "CR");
511/// assert_eq!(special_label(SpecialKey::F(5)), "F5");
512/// ```
513pub fn special_label(k: SpecialKey) -> &'static str {
514    match k {
515        SpecialKey::Esc => "Esc",
516        SpecialKey::Enter => "CR",
517        SpecialKey::Tab => "Tab",
518        SpecialKey::Backspace => "BS",
519        SpecialKey::Space => "Space",
520        SpecialKey::Up => "Up",
521        SpecialKey::Down => "Down",
522        SpecialKey::Left => "Left",
523        SpecialKey::Right => "Right",
524        SpecialKey::Home => "Home",
525        SpecialKey::End => "End",
526        SpecialKey::PageUp => "PageUp",
527        SpecialKey::PageDown => "PageDown",
528        SpecialKey::Insert => "Insert",
529        SpecialKey::Delete => "Delete",
530        SpecialKey::F(1) => "F1",
531        SpecialKey::F(2) => "F2",
532        SpecialKey::F(3) => "F3",
533        SpecialKey::F(4) => "F4",
534        SpecialKey::F(5) => "F5",
535        SpecialKey::F(6) => "F6",
536        SpecialKey::F(7) => "F7",
537        SpecialKey::F(8) => "F8",
538        SpecialKey::F(9) => "F9",
539        SpecialKey::F(10) => "F10",
540        SpecialKey::F(11) => "F11",
541        SpecialKey::F(12) => "F12",
542        SpecialKey::F(_) => "F?", // 13..24; renderer-only fallback
543    }
544}
545
546/// Inverse of `special_label`. Used by the parser when an
547/// `<...>` body's modifier-stripped chunk is more than one
548/// char (`<Esc>`, `<F12>`, etc.).
549fn parse_special(name: &str) -> Option<SpecialKey> {
550    Some(match name {
551        "Esc" | "Escape" => SpecialKey::Esc,
552        "CR" | "Enter" | "Return" => SpecialKey::Enter,
553        "Tab" => SpecialKey::Tab,
554        "BS" | "Backspace" => SpecialKey::Backspace,
555        "Space" => SpecialKey::Space,
556        "Up" => SpecialKey::Up,
557        "Down" => SpecialKey::Down,
558        "Left" => SpecialKey::Left,
559        "Right" => SpecialKey::Right,
560        "Home" => SpecialKey::Home,
561        "End" => SpecialKey::End,
562        "PageUp" => SpecialKey::PageUp,
563        "PageDown" => SpecialKey::PageDown,
564        "Insert" | "Ins" => SpecialKey::Insert,
565        "Delete" | "Del" => SpecialKey::Delete,
566        n if n.starts_with('F') => {
567            let num: u8 = n[1..].parse().ok()?;
568            if (1..=24).contains(&num) {
569                SpecialKey::F(num)
570            } else {
571                return None;
572            }
573        }
574        _ => return None,
575    })
576}
577
578impl FromStr for KeyChord {
579    type Err = ChordParseError;
580
581    fn from_str(s: &str) -> Result<Self, Self::Err> {
582        // Single-chord parse: either one bare char or one
583        // `<...>` token.
584        let mut iter = s.char_indices();
585        let (start, first) = iter.next().ok_or(ChordParseError::Empty)?;
586        if first == '<' {
587            let close_rel = s[start + 1..]
588                .find('>')
589                .ok_or(ChordParseError::UnterminatedAngle { at: start })?;
590            let body_end = start + 1 + close_rel;
591            let body = &s[start + 1..body_end];
592            // Single-chord parse rejects trailing input -- the
593            // caller is asking for ONE chord, not a sequence.
594            if body_end + 1 != s.len() {
595                return Err(ChordParseError::BodyTooLong {
596                    name: s.to_string(),
597                    at: start,
598                });
599            }
600            return parse_angle_body(body, start);
601        }
602        // Bare char. Reject trailing input for the same reason.
603        if iter.next().is_some() {
604            return Err(ChordParseError::BodyTooLong {
605                name: s.to_string(),
606                at: start,
607            });
608        }
609        Ok(KeyChord::char(first))
610    }
611}
612
613/// Parse the body of an `<...>` token (without the angles).
614/// Handles modifier prefixes (`C-`, `S-`, `M-`, `D-`), the
615/// `lt` literal, special-key names, and bare letters.
616fn parse_angle_body(body: &str, at: usize) -> Result<KeyChord, ChordParseError> {
617    if body.is_empty() {
618        return Err(ChordParseError::EmptyAngle { at });
619    }
620    if body == "lt" {
621        return Ok(KeyChord::char('<'));
622    }
623    let mut mods = KeyMods::NONE;
624    let mut rest = body;
625    loop {
626        // Each modifier prefix is exactly two bytes (`C-`,
627        // `S-`, `M-`, `D-`). Walk them off the front.
628        if rest.len() < 2 || rest.as_bytes()[1] != b'-' {
629            break;
630        }
631        let prefix = rest.as_bytes()[0];
632        let m = match prefix {
633            b'C' => KeyMods::CTRL,
634            b'S' => KeyMods::SHIFT,
635            b'M' | b'A' => KeyMods::ALT,
636            b'D' => KeyMods::SUPER,
637            _ => break,
638        };
639        if mods.0 & m.0 != 0 {
640            return Err(ChordParseError::DuplicateModifier { at });
641        }
642        mods = mods | m;
643        rest = &rest[2..];
644    }
645    if rest.is_empty() {
646        return Err(ChordParseError::DanglingModifier { at });
647    }
648    // After modifiers: either a single char or a special-key name.
649    let mut chars = rest.chars();
650    let first = chars.next().expect("rest non-empty checked above");
651    if chars.next().is_none() {
652        // Single char body. Letters with Ctrl / Alt normalise to
653        // lowercase to match the adapter's canonical form. Plain
654        // shift on a letter folds into the case (`<S-a>` -> `A`).
655        if mods.ctrl() || mods.alt() {
656            return Ok(KeyChord {
657                key: KeyKind::Char(first.to_ascii_lowercase()),
658                mods,
659            });
660        }
661        if mods.shift() && first.is_ascii_alphabetic() {
662            // <S-a> = `A`; strip shift.
663            mods = KeyMods(mods.0 & !KeyMods::SHIFT.0);
664            return Ok(KeyChord {
665                key: KeyKind::Char(first.to_ascii_uppercase()),
666                mods,
667            });
668        }
669        return Ok(KeyChord {
670            key: KeyKind::Char(first),
671            mods,
672        });
673    }
674    // Multi-char body -- must be a special-key name.
675    let special = parse_special(rest).ok_or_else(|| ChordParseError::UnknownName {
676        name: rest.to_string(),
677        at,
678    })?;
679    // **An UNMODIFIED `<Space>` is the literal character, not the special key.**
680    //
681    // Both key decoders already apply exactly this rule — a bare space arrives
682    // as `Char(' ')` and only a modified one becomes `Special(Space)` (TUI
683    // `chord::from_event`, GPUI `gpui_chord::from_event`). The parser did not,
684    // so the two spellings of the same keypress disagreed and anything bound
685    // via the string `<Space>` could never be typed.
686    //
687    // That is not hypothetical: the DEFAULT `keymap.leader` is `<Space>`, and
688    // `<leader>` is substituted at BIND time, so every `<leader>x` binding in
689    // the editor landed under `Special(Space)` while the keyboard produced
690    // `Char(' ')`. The entire leader keymap was unreachable.
691    //
692    // Normalising here rather than at each decoder is what makes it stay
693    // fixed: the parser is the one path every plugin binding, user keymap and
694    // `:map` command shares, and both renderers already agree with each other.
695    // `<S-Space>` / `<C-Space>` are untouched — a modifier still means the
696    // special key, because a modified space is not text.
697    if special == SpecialKey::Space && mods.is_empty() {
698        return Ok(KeyChord {
699            key: KeyKind::Char(' '),
700            mods,
701        });
702    }
703    Ok(KeyChord {
704        key: KeyKind::Special(special),
705        mods,
706    })
707}
708
709/// Parse a chord-sequence string into the canonical
710/// `Vec<KeyChord>` the keymap trie indexes by.
711///
712/// Examples:
713///
714/// - `"j"` -> `[char('j')]`
715/// - `"gg"` -> `[char('g'), char('g')]`
716/// - `"<C-w>j"` -> `[ctrl('w'), char('j')]`
717/// - `"dw"` -> `[char('d'), char('w')]`
718/// - `"<lt>"` -> `[char('<')]`
719///
720/// Used by:
721/// - The `keymap_entry!` macro at startup to convert the
722///   catalog's `&'static str` chord into the trie's typed key.
723/// - `:bind` user / plugin invocations that take a
724///   chord-string at runtime.
725///
726/// `<leader>` is *not* understood here: it is substituted before parsing, at
727/// bind time, by the keymap layer.
728///
729/// # Errors
730///
731/// A [`ChordParseError`] naming the first bad token; the empty string is
732/// [`ChordParseError::Empty`].
733///
734/// # Examples
735///
736/// ```
737/// use lattice_protocol::{KeyChord, parse_chord_sequence};
738///
739/// assert_eq!(
740///     parse_chord_sequence("gg").unwrap(),
741///     vec![KeyChord::char('g'), KeyChord::char('g')]
742/// );
743/// // Ctrl letters normalise to lowercase; `<lt>` is a literal `<`.
744/// assert_eq!(parse_chord_sequence("<C-W>").unwrap(), vec![KeyChord::ctrl('w')]);
745/// assert_eq!(parse_chord_sequence("<lt>").unwrap(), vec![KeyChord::char('<')]);
746/// // An unmodified <Space> is the space character.
747/// assert_eq!(parse_chord_sequence("<Space>f").unwrap()[0], KeyChord::char(' '));
748/// ```
749pub fn parse_chord_sequence(s: &str) -> Result<Vec<KeyChord>, ChordParseError> {
750    if s.is_empty() {
751        return Err(ChordParseError::Empty);
752    }
753    let mut out = Vec::new();
754    let mut i = 0;
755    let bytes = s.as_bytes();
756    while i < bytes.len() {
757        if bytes[i] == b'<' {
758            // Walk to matching `>`. Brackets don't nest in
759            // chord notation.
760            let close = match s[i + 1..].find('>') {
761                Some(rel) => i + 1 + rel,
762                None => return Err(ChordParseError::UnterminatedAngle { at: i }),
763            };
764            let body = &s[i + 1..close];
765            out.push(parse_angle_body(body, i)?);
766            i = close + 1;
767        } else if bytes[i] == b'>' {
768            return Err(ChordParseError::StrayClose { at: i });
769        } else {
770            // One UTF-8 char. Walk to the next char boundary.
771            let ch_len = utf8_char_len(bytes[i]);
772            let end = i + ch_len;
773            let ch = s[i..end].chars().next().expect("valid utf-8 boundary");
774            out.push(KeyChord::char(ch));
775            i = end;
776        }
777    }
778    Ok(out)
779}
780
781/// UTF-8 byte length of the leading char given its first byte.
782/// Returns 1 for ASCII / continuation bytes (defensive; the
783/// caller has already checked it's a leading byte).
784#[inline]
785fn utf8_char_len(b: u8) -> usize {
786    if b < 0x80 {
787        1
788    } else if b & 0b1110_0000 == 0b1100_0000 {
789        2
790    } else if b & 0b1111_0000 == 0b1110_0000 {
791        3
792    } else if b & 0b1111_1000 == 0b1111_0000 {
793        4
794    } else {
795        1
796    }
797}
798
799/// Number of bytes the last chord token occupies at the end of
800/// `text`, treating it as a sequence of chord tokens (one chord
801/// per logical keypress). Used by chord-capture's backspace
802/// handler to remove a whole token instead of a single char.
803///
804/// A token is either:
805/// - A `<…>` group (matched balanced angle brackets).
806/// - A single character.
807///
808/// Returns 0 if `text` is empty.
809///
810/// # Examples
811///
812/// ```
813/// use lattice_protocol::last_chord_token_byte_len;
814///
815/// assert_eq!(last_chord_token_byte_len("gg<C-w>"), 5); // "<C-w>"
816/// assert_eq!(last_chord_token_byte_len("<C-w>é"), 2); // one 2-byte char
817/// assert_eq!(last_chord_token_byte_len(""), 0);
818/// ```
819pub fn last_chord_token_byte_len(text: &str) -> usize {
820    let bytes = text.as_bytes();
821    let n = bytes.len();
822    if n == 0 {
823        return 0;
824    }
825    if bytes[n - 1] == b'>' {
826        // Walk back to the matching `<`. Brackets don't nest in
827        // chord notation (no `<<…>>`), so a simple scan suffices.
828        let mut i = n;
829        while i > 0 {
830            i -= 1;
831            if bytes[i] == b'<' {
832                return n - i;
833            }
834        }
835        // Unbalanced `>` -- treat as a single-byte token.
836        return 1;
837    }
838    // Plain char token. UTF-8 safe: walk back to a char boundary.
839    let mut i = n - 1;
840    while i > 0 && (bytes[i] & 0b1100_0000) == 0b1000_0000 {
841        i -= 1;
842    }
843    n - i
844}
845
846#[cfg(test)]
847mod tests {
848    #![allow(clippy::unwrap_used)]
849    use super::*;
850
851    /// **A bare `<Space>` parses to the literal char, not the special key.**
852    ///
853    /// Both key decoders produce `Char(' ')` for an unmodified space, so a
854    /// parser that produced `Special(Space)` made the two spellings of one
855    /// keypress disagree — and nothing bound as `<Space>` could be typed.
856    ///
857    /// The default `keymap.leader` IS `<Space>` and `<leader>` is substituted
858    /// at bind time, so this made the whole leader keymap unreachable.
859    #[test]
860    fn a_bare_space_parses_to_the_literal_char() {
861        let seq = parse_chord_sequence("<Space>").unwrap();
862        assert_eq!(seq.len(), 1);
863        assert_eq!(seq[0].key, KeyKind::Char(' '));
864        assert!(seq[0].mods.is_empty());
865    }
866
867    /// The leader sequence a `<leader>oa` binding actually lands under.
868    #[test]
869    fn a_leader_sequence_parses_as_a_typeable_space_then_letters() {
870        let seq = parse_chord_sequence("<Space>oa").unwrap();
871        assert_eq!(
872            seq.iter().map(|c| c.key).collect::<Vec<_>>(),
873            vec![KeyKind::Char(' '), KeyKind::Char('o'), KeyKind::Char('a')],
874            "every element must be what the keyboard produces: {seq:?}"
875        );
876    }
877
878    /// A MODIFIED space is still the special key — a modified space is not
879    /// text, and `<C-Space>` must keep matching what the decoders now build.
880    #[test]
881    fn a_modified_space_stays_the_special_key() {
882        for spelling in ["<C-Space>", "<S-Space>", "<A-Space>"] {
883            let seq = parse_chord_sequence(spelling).unwrap();
884            assert_eq!(
885                seq[0].key,
886                KeyKind::Special(SpecialKey::Space),
887                "{spelling} keeps the special key"
888            );
889            assert!(!seq[0].mods.is_empty(), "{spelling} keeps its modifier");
890        }
891    }
892
893    /// `<Space>` must survive a Display→parse round trip, and must survive it
894    /// as a **visible, non-splitting** token.
895    ///
896    /// Display used to render the literal char as a bare `" "`. That
897    /// round-tripped through `parse_chord_sequence` and through nothing else:
898    /// the `:` line delimits arguments on whitespace, so `:describe-key`'s
899    /// chord capture appended a space and submitted an empty argument; and a
900    /// keymap listing rendered the leader as an invisible column.
901    ///
902    /// So the assertion is two-part on purpose. Round-tripping alone is what
903    /// the old spelling already satisfied, which is why it survived.
904    #[test]
905    fn a_space_chord_round_trips_through_display() {
906        let parsed = parse_chord_sequence("<Space>oa").unwrap();
907        let rendered: String = parsed.iter().map(|c| c.to_string()).collect();
908        assert_eq!(
909            parse_chord_sequence(&rendered).unwrap(),
910            parsed,
911            "rendered as {rendered:?}"
912        );
913        assert_eq!(
914            rendered, "<Space>oa",
915            "the leader must render as a token a human can see and a \
916             whitespace-delimited parser will not split"
917        );
918        assert!(
919            !rendered.contains(' '),
920            "no rendered chord sequence may contain a raw space: {rendered:?}"
921        );
922    }
923
924    /// A MODIFIED space keeps rendering through the modifier path, which
925    /// already spelled it `<C-Space>`. The new escape must not double up.
926    #[test]
927    fn a_modified_space_still_renders_once() {
928        let seq = parse_chord_sequence("<C-Space>").unwrap();
929        assert_eq!(seq[0].to_string(), "<C-Space>");
930    }
931
932    #[test]
933    fn keychord_display_round_trips_through_from_str() {
934        // Pick representative chords across every shape:
935        // bare char, Ctrl/Alt letters, multi-modifier, specials,
936        // shift-special, function keys, the `<lt>` literal.
937        let cases: &[KeyChord] = &[
938            KeyChord::char('a'),
939            KeyChord::char('$'),
940            KeyChord::char('A'),
941            KeyChord::ctrl('c'),
942            KeyChord {
943                key: KeyKind::Char('x'),
944                mods: KeyMods::CTRL | KeyMods::SHIFT,
945            },
946            KeyChord {
947                key: KeyKind::Char('q'),
948                mods: KeyMods::ALT,
949            },
950            KeyChord::special(SpecialKey::Esc),
951            KeyChord::special(SpecialKey::Enter),
952            KeyChord::special(SpecialKey::Backspace),
953            KeyChord {
954                key: KeyKind::Special(SpecialKey::Tab),
955                mods: KeyMods::SHIFT,
956            },
957            KeyChord::special(SpecialKey::F(1)),
958            KeyChord::special(SpecialKey::F(12)),
959            KeyChord::char('<'),
960            KeyChord::char('>'),
961        ];
962        for c in cases {
963            let s = c.to_string();
964            let parsed: KeyChord = s
965                .parse()
966                .unwrap_or_else(|e| panic!("re-parse {s:?}: {e:?}"));
967            assert_eq!(parsed, *c, "round-trip differs for {s:?}");
968        }
969    }
970
971    #[test]
972    fn parse_chord_sequence_walks_mixed_tokens() {
973        let seq = parse_chord_sequence("<C-w>j").unwrap();
974        assert_eq!(seq, vec![KeyChord::ctrl('w'), KeyChord::char('j')]);
975    }
976
977    #[test]
978    fn parse_chord_sequence_handles_multi_key_built_in_chords() {
979        assert_eq!(
980            parse_chord_sequence("gg").unwrap(),
981            vec![KeyChord::char('g'), KeyChord::char('g')]
982        );
983        assert_eq!(
984            parse_chord_sequence("dw").unwrap(),
985            vec![KeyChord::char('d'), KeyChord::char('w')]
986        );
987        assert_eq!(
988            parse_chord_sequence("zt").unwrap(),
989            vec![KeyChord::char('z'), KeyChord::char('t')]
990        );
991    }
992
993    #[test]
994    fn parse_chord_sequence_handles_lt_literal() {
995        assert_eq!(
996            parse_chord_sequence("<lt>").unwrap(),
997            vec![KeyChord::char('<')]
998        );
999        // Mid-sequence too.
1000        assert_eq!(
1001            parse_chord_sequence("a<lt>b").unwrap(),
1002            vec![
1003                KeyChord::char('a'),
1004                KeyChord::char('<'),
1005                KeyChord::char('b')
1006            ]
1007        );
1008    }
1009
1010    #[test]
1011    fn parse_chord_sequence_rejects_unterminated_angle() {
1012        assert!(matches!(
1013            parse_chord_sequence("<C-x"),
1014            Err(ChordParseError::UnterminatedAngle { .. })
1015        ));
1016    }
1017
1018    #[test]
1019    fn parse_chord_sequence_rejects_stray_close() {
1020        assert!(matches!(
1021            parse_chord_sequence("a>b"),
1022            Err(ChordParseError::StrayClose { .. })
1023        ));
1024    }
1025
1026    #[test]
1027    fn parse_chord_sequence_rejects_unknown_special() {
1028        assert!(matches!(
1029            parse_chord_sequence("<Foo>"),
1030            Err(ChordParseError::UnknownName { .. })
1031        ));
1032        assert!(matches!(
1033            parse_chord_sequence("<F99>"),
1034            Err(ChordParseError::UnknownName { .. })
1035        ));
1036    }
1037
1038    #[test]
1039    fn parse_chord_sequence_rejects_dangling_modifier() {
1040        assert!(matches!(
1041            parse_chord_sequence("<C->"),
1042            Err(ChordParseError::DanglingModifier { .. })
1043        ));
1044    }
1045
1046    #[test]
1047    fn parse_chord_sequence_rejects_duplicate_modifier() {
1048        assert!(matches!(
1049            parse_chord_sequence("<C-C-x>"),
1050            Err(ChordParseError::DuplicateModifier { .. })
1051        ));
1052    }
1053
1054    #[test]
1055    fn parse_chord_sequence_accepts_uppercase_shifted_letter_via_s_prefix() {
1056        // `<S-a>` is a legacy form; canonicalises to `Char('A')`
1057        // (vim does the same).
1058        assert_eq!(
1059            parse_chord_sequence("<S-a>").unwrap(),
1060            vec![KeyChord::char('A')]
1061        );
1062    }
1063
1064    #[test]
1065    fn last_chord_token_treats_angle_group_as_one_unit() {
1066        assert_eq!(last_chord_token_byte_len("<C-c>"), 5);
1067        assert_eq!(last_chord_token_byte_len("a<C-c>"), 5);
1068        assert_eq!(last_chord_token_byte_len("<Esc>"), 5);
1069    }
1070
1071    #[test]
1072    fn last_chord_token_handles_plain_char() {
1073        assert_eq!(last_chord_token_byte_len("abc"), 1);
1074        assert_eq!(last_chord_token_byte_len("a"), 1);
1075    }
1076
1077    #[test]
1078    fn last_chord_token_handles_empty() {
1079        assert_eq!(last_chord_token_byte_len(""), 0);
1080    }
1081
1082    #[test]
1083    fn last_chord_token_handles_utf8_char() {
1084        // Two-byte UTF-8 char (é) should pop as one unit.
1085        assert_eq!(last_chord_token_byte_len("aé"), 2);
1086    }
1087}