Skip to main content

keymap_core/input/
grammar.rs

1//! The key-string grammar: `FromStr` and `Display` for [`KeyInput`].
2//!
3//! This is a stable, public compatibility surface — config files contain these
4//! strings and the discovery layer prints them. The policy is liberal-in /
5//! canonical-out:
6//!
7//! - `Display` emits exactly one canonical spelling: modifiers in the fixed
8//!   order `ctrl`, `alt`, `shift`, `super`, joined with `+`, then the key. Key
9//!   names and modifier tokens are lowercase; a character glyph is emitted
10//!   verbatim (case-sensitive, since `Char('A')` and `Char('a')` differ).
11//! - `FromStr` accepts case-insensitive tokens and aliases (`control`, `cmd`,
12//!   `return`, `escape`, …). Adding aliases is backward compatible; removing or
13//!   renaming any accepted/emitted spelling is a breaking change.
14//!
15//! Parsing applies the same [`normalize`](super::normalize) rule as the backend
16//! conversion, so `"shift+a"` and `"a"` both parse to `Char('a')` with no
17//! modifiers — matching what a terminal actually delivers.
18
19use core::fmt;
20use core::str::FromStr;
21
22use super::{Key, KeyInput, Modifiers};
23
24impl fmt::Display for KeyInput {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        let mods = self.modifiers();
27        if mods.contains(Modifiers::CTRL) {
28            f.write_str("ctrl+")?;
29        }
30        if mods.contains(Modifiers::ALT) {
31            f.write_str("alt+")?;
32        }
33        if mods.contains(Modifiers::SHIFT) {
34            f.write_str("shift+")?;
35        }
36        if mods.contains(Modifiers::SUPER) {
37            f.write_str("super+")?;
38        }
39
40        // No `_` arm: a newly added `Key` variant must be given a spelling here,
41        // or this fails to compile — `Display` is serialization-grade.
42        match self.key() {
43            Key::Char(c) => write!(f, "{c}"),
44            Key::F(n) => write!(f, "f{n}"),
45            Key::Enter => f.write_str("enter"),
46            Key::Esc => f.write_str("esc"),
47            Key::Tab => f.write_str("tab"),
48            Key::Backspace => f.write_str("backspace"),
49            Key::Delete => f.write_str("delete"),
50            Key::Insert => f.write_str("insert"),
51            Key::Home => f.write_str("home"),
52            Key::End => f.write_str("end"),
53            Key::PageUp => f.write_str("pageup"),
54            Key::PageDown => f.write_str("pagedown"),
55            Key::Up => f.write_str("up"),
56            Key::Down => f.write_str("down"),
57            Key::Left => f.write_str("left"),
58            Key::Right => f.write_str("right"),
59        }
60    }
61}
62
63impl FromStr for KeyInput {
64    type Err = ParseKeyInputError;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        if s.is_empty() {
68            return Err(ParseKeyInputError::new(s, ErrorKind::Empty));
69        }
70
71        // Greedily consume leading `modifier+` tokens; the remainder is the key.
72        // This leaves a literal `+` (e.g. `"ctrl++"`) as the key correctly.
73        let mut rest = s;
74        let mut mods = Modifiers::NONE;
75        while let Some(idx) = rest.find('+') {
76            let token = &rest[..idx];
77            if let Some(m) = parse_modifier(token) {
78                mods |= m;
79                rest = &rest[idx + 1..];
80            } else {
81                break;
82            }
83        }
84
85        let key =
86            parse_key(rest).ok_or_else(|| ParseKeyInputError::new(s, ErrorKind::InvalidKey))?;
87        Ok(KeyInput::normalized(key, mods))
88    }
89}
90
91fn parse_modifier(token: &str) -> Option<Modifiers> {
92    match token.to_ascii_lowercase().as_str() {
93        "ctrl" | "control" => Some(Modifiers::CTRL),
94        "alt" | "opt" | "option" => Some(Modifiers::ALT),
95        "shift" => Some(Modifiers::SHIFT),
96        "cmd" | "super" | "win" | "meta" => Some(Modifiers::SUPER),
97        _ => None,
98    }
99}
100
101fn parse_key(token: &str) -> Option<Key> {
102    if token.is_empty() {
103        return None;
104    }
105    let lower = token.to_ascii_lowercase();
106    let named = match lower.as_str() {
107        "tab" => Some(Key::Tab),
108        "enter" | "return" => Some(Key::Enter),
109        "esc" | "escape" => Some(Key::Esc),
110        "space" => Some(Key::Char(' ')),
111        "backspace" => Some(Key::Backspace),
112        "delete" | "del" => Some(Key::Delete),
113        "insert" | "ins" => Some(Key::Insert),
114        "home" => Some(Key::Home),
115        "end" => Some(Key::End),
116        "pageup" | "pgup" => Some(Key::PageUp),
117        "pagedown" | "pgdn" => Some(Key::PageDown),
118        "up" => Some(Key::Up),
119        "down" => Some(Key::Down),
120        "left" => Some(Key::Left),
121        "right" => Some(Key::Right),
122        _ => None,
123    };
124    if let Some(key) = named {
125        return Some(key);
126    }
127
128    if let Some(rest) = lower.strip_prefix('f') {
129        if let Ok(n) = rest.parse::<u8>() {
130            if (1..=24).contains(&n) {
131                return Some(Key::F(n));
132            }
133        }
134    }
135
136    // A single character key — case preserved (the glyph is significant).
137    let mut chars = token.chars();
138    let c = chars.next()?;
139    if chars.next().is_some() {
140        return None;
141    }
142    Some(Key::Char(c))
143}
144
145/// Error returned when a string cannot be parsed into a [`KeyInput`].
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ParseKeyInputError {
148    input: String,
149    kind: ErrorKind,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153enum ErrorKind {
154    Empty,
155    InvalidKey,
156}
157
158impl ParseKeyInputError {
159    fn new(input: &str, kind: ErrorKind) -> Self {
160        ParseKeyInputError {
161            input: input.to_string(),
162            kind,
163        }
164    }
165
166    /// The input string that failed to parse.
167    #[must_use]
168    pub fn input(&self) -> &str {
169        &self.input
170    }
171}
172
173impl fmt::Display for ParseKeyInputError {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        match self.kind {
176            ErrorKind::Empty => f.write_str("empty key string"),
177            ErrorKind::InvalidKey => write!(f, "invalid key string: {:?}", self.input),
178        }
179    }
180}
181
182impl std::error::Error for ParseKeyInputError {}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn display_round_trips_through_from_str() {
190        // The fixed-point law: parsing a Display'd (already-normalized) value
191        // returns it unchanged. A Char carries SHIFT only alongside another
192        // modifier (bare Shift on a Char is folded away, so those aren't fixed
193        // points and are excluded here).
194        let cases = [
195            KeyInput::new(Key::Char('a'), Modifiers::NONE),
196            KeyInput::new(Key::Char('A'), Modifiers::NONE),
197            KeyInput::new(Key::Char('1'), Modifiers::SUPER),
198            KeyInput::new(Key::Char('s'), Modifiers::CTRL | Modifiers::SHIFT),
199            KeyInput::new(Key::Char('あ'), Modifiers::CTRL),
200            KeyInput::new(Key::Char(' '), Modifiers::NONE),
201            KeyInput::new(Key::Char('+'), Modifiers::CTRL),
202            KeyInput::new(Key::F(1), Modifiers::NONE),
203            KeyInput::new(Key::F(12), Modifiers::SHIFT),
204            KeyInput::new(Key::Tab, Modifiers::SHIFT),
205            KeyInput::new(Key::Esc, Modifiers::NONE),
206            KeyInput::new(Key::Up, Modifiers::CTRL | Modifiers::ALT),
207            KeyInput::new(
208                Key::Enter,
209                Modifiers::CTRL | Modifiers::ALT | Modifiers::SHIFT | Modifiers::SUPER,
210            ),
211        ];
212        for k in cases {
213            let rendered = k.to_string();
214            assert_eq!(
215                rendered.parse::<KeyInput>(),
216                Ok(k),
217                "round trip via {rendered:?}"
218            );
219        }
220    }
221
222    #[test]
223    fn display_uses_canonical_modifier_order_and_names() {
224        let k = KeyInput::new(Key::Char('a'), Modifiers::SUPER | Modifiers::CTRL);
225        assert_eq!(k.to_string(), "ctrl+super+a");
226        assert_eq!(KeyInput::new(Key::F(1), Modifiers::NONE).to_string(), "f1");
227        assert_eq!(
228            KeyInput::new(Key::Tab, Modifiers::SHIFT).to_string(),
229            "shift+tab"
230        );
231    }
232
233    #[test]
234    fn from_str_accepts_aliases_case_insensitively() {
235        let ctrl_a = KeyInput::new(Key::Char('a'), Modifiers::CTRL);
236        assert_eq!("ctrl+a".parse::<KeyInput>().unwrap(), ctrl_a);
237        assert_eq!("CTRL+a".parse::<KeyInput>().unwrap(), ctrl_a);
238        assert_eq!("control+a".parse::<KeyInput>().unwrap(), ctrl_a);
239        assert_eq!(
240            "cmd+1".parse::<KeyInput>().unwrap(),
241            "super+1".parse::<KeyInput>().unwrap()
242        );
243    }
244
245    #[test]
246    fn from_str_shares_shift_normalization() {
247        // Bare Shift on a char is redundant with the glyph, so "shift+a" == "a".
248        let a = KeyInput::new(Key::Char('a'), Modifiers::NONE);
249        assert_eq!("shift+a".parse::<KeyInput>().unwrap(), a);
250        assert_eq!("a".parse::<KeyInput>().unwrap(), a);
251    }
252
253    #[test]
254    fn from_str_keeps_shift_with_other_modifier() {
255        // With another modifier, Shift survives and the chord is distinct —
256        // this is what makes cmd+shift+s usable as a binding separate from cmd+s.
257        let cmd_shift_s = "cmd+shift+s".parse::<KeyInput>().unwrap();
258        assert_eq!(
259            cmd_shift_s,
260            KeyInput::new(Key::Char('s'), Modifiers::SUPER | Modifiers::SHIFT)
261        );
262        assert_ne!(cmd_shift_s, "cmd+s".parse::<KeyInput>().unwrap());
263        assert_ne!(
264            "ctrl+shift+s".parse::<KeyInput>().unwrap(),
265            "ctrl+s".parse::<KeyInput>().unwrap()
266        );
267    }
268
269    #[test]
270    fn from_str_handles_literal_plus_as_key() {
271        assert_eq!(
272            "ctrl++".parse::<KeyInput>().unwrap(),
273            KeyInput::new(Key::Char('+'), Modifiers::CTRL)
274        );
275        assert_eq!(
276            "+".parse::<KeyInput>().unwrap(),
277            KeyInput::new(Key::Char('+'), Modifiers::NONE)
278        );
279    }
280
281    #[test]
282    fn from_str_parses_named_and_function_keys() {
283        assert_eq!("f1".parse::<KeyInput>().unwrap().key(), Key::F(1));
284        assert_eq!("up".parse::<KeyInput>().unwrap().key(), Key::Up);
285        assert_eq!("esc".parse::<KeyInput>().unwrap().key(), Key::Esc);
286        assert_eq!("escape".parse::<KeyInput>().unwrap().key(), Key::Esc);
287    }
288
289    #[test]
290    fn from_str_rejects_invalid_input() {
291        assert!("".parse::<KeyInput>().is_err());
292        assert!("ctrl".parse::<KeyInput>().is_err());
293        assert!("hyper+a".parse::<KeyInput>().is_err());
294        assert!("ctrl+xyz".parse::<KeyInput>().is_err());
295    }
296}