Skip to main content

lore/shell/
chord.rs

1//! The key that opens the picker, written once and translated per shell.
2//!
3//! Every shell spells a chord differently, and `init` is not allowed to read a
4//! config file, so the chord travels through the init line as a canonical
5//! `ctrl-g` and each snippet receives whatever its own binding syntax expects.
6
7use std::fmt;
8use std::str::FromStr;
9
10use thiserror::Error;
11
12use crate::shell::Shell;
13
14/// The chord used when `--key` is not given.
15pub const DEFAULT: &str = "ctrl-g";
16
17/// Control keys the terminal itself consumes or that carry a meaning nothing
18/// should take over. Binding `ctrl-m` costs the user the enter key.
19const RESERVED: &[(char, &str)] = &[
20    ('c', "interrupts the running command"),
21    ('d', "ends input"),
22    ('i', "is the tab key"),
23    ('j', "is a line feed"),
24    ('m', "is the enter key"),
25    ('q', "resumes a stopped terminal"),
26    ('s', "stops terminal output"),
27    ('z', "suspends the running command"),
28];
29
30#[derive(Debug, Error, PartialEq, Eq)]
31pub enum ChordError {
32    #[error("write the key as ctrl-<letter> or alt-<letter>, such as {DEFAULT}")]
33    Shape,
34
35    #[error("{0} is not a modifier lore can bind, use ctrl or alt")]
36    Modifier(String),
37
38    #[error("{0} is not a single letter")]
39    Key(String),
40
41    #[error("ctrl-{0} cannot be bound because it {1}")]
42    Reserved(char, &'static str),
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46enum Modifier {
47    Ctrl,
48    Alt,
49}
50
51/// A keybinding that opens the picker.
52///
53/// Deliberately narrow. A letter with control or alt is the only combination
54/// all four shells spell unambiguously and every terminal actually transmits.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct Chord {
57    modifier: Modifier,
58    letter: char,
59}
60
61impl Chord {
62    /// The chord in the binding syntax `shell` expects.
63    pub fn render(self, shell: Shell) -> String {
64        let letter = self.letter;
65        match (shell, self.modifier) {
66            (Shell::Bash, Modifier::Ctrl) => format!(r"\C-{letter}"),
67            (Shell::Bash, Modifier::Alt) => format!(r"\M-{letter}"),
68            // A control key is written as the letter it is typed with, which is
69            // the upper case one; alt is the escape prefix.
70            (Shell::Zsh, Modifier::Ctrl) => format!("^{}", letter.to_ascii_uppercase()),
71            (Shell::Zsh, Modifier::Alt) => format!("^[{letter}"),
72            (Shell::Fish, Modifier::Ctrl) => format!(r"\c{letter}"),
73            (Shell::Fish, Modifier::Alt) => format!(r"\e{letter}"),
74            (Shell::PowerShell, Modifier::Ctrl) => format!("Ctrl+{letter}"),
75            (Shell::PowerShell, Modifier::Alt) => format!("Alt+{letter}"),
76        }
77    }
78
79    /// How to name the chord when telling the user which key to press.
80    pub fn spoken(self) -> String {
81        let modifier = match self.modifier {
82            Modifier::Ctrl => "ctrl",
83            Modifier::Alt => "alt",
84        };
85        format!("{modifier}+{}", self.letter)
86    }
87
88    pub fn is_default(self) -> bool {
89        self == Self::default()
90    }
91}
92
93impl Default for Chord {
94    fn default() -> Self {
95        DEFAULT.parse().expect("the default chord should parse")
96    }
97}
98
99impl FromStr for Chord {
100    type Err = ChordError;
101
102    fn from_str(text: &str) -> Result<Self, Self::Err> {
103        let text = text.trim().to_ascii_lowercase();
104        let (modifier, key) = text.split_once('-').ok_or(ChordError::Shape)?;
105
106        let modifier = match modifier {
107            "ctrl" | "control" => Modifier::Ctrl,
108            "alt" | "meta" => Modifier::Alt,
109            other => return Err(ChordError::Modifier(other.to_string())),
110        };
111
112        let mut letters = key.chars();
113        let letter = match (letters.next(), letters.next()) {
114            (Some(letter), None) if letter.is_ascii_alphabetic() => letter,
115            _ => return Err(ChordError::Key(key.to_string())),
116        };
117
118        if modifier == Modifier::Ctrl
119            && let Some((_, why)) = RESERVED.iter().find(|(reserved, _)| *reserved == letter)
120        {
121            return Err(ChordError::Reserved(letter, why));
122        }
123
124        Ok(Self { modifier, letter })
125    }
126}
127
128impl fmt::Display for Chord {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        let modifier = match self.modifier {
131            Modifier::Ctrl => "ctrl",
132            Modifier::Alt => "alt",
133        };
134        write!(f, "{modifier}-{}", self.letter)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn chord(text: &str) -> Chord {
143        text.parse().expect("should parse")
144    }
145
146    #[test]
147    fn the_default_is_the_chord_the_documentation_promises() {
148        assert_eq!(Chord::default().to_string(), "ctrl-g");
149        assert!(chord("ctrl-g").is_default());
150        assert!(!chord("alt-r").is_default());
151    }
152
153    #[test]
154    fn parsing_is_case_insensitive_and_round_trips() {
155        for text in ["ctrl-g", "CTRL-G", " Ctrl-g "] {
156            assert_eq!(chord(text).to_string(), "ctrl-g");
157        }
158        assert_eq!(chord("Alt-R").to_string(), "alt-r");
159        assert_eq!(chord("control-p").to_string(), "ctrl-p");
160        assert_eq!(chord("meta-p").to_string(), "alt-p");
161    }
162
163    #[test]
164    fn every_shell_gets_its_own_spelling() {
165        let ctrl = chord("ctrl-g");
166        assert_eq!(ctrl.render(Shell::Bash), r"\C-g");
167        assert_eq!(ctrl.render(Shell::Zsh), "^G");
168        assert_eq!(ctrl.render(Shell::Fish), r"\cg");
169        assert_eq!(ctrl.render(Shell::PowerShell), "Ctrl+g");
170
171        let alt = chord("alt-r");
172        assert_eq!(alt.render(Shell::Bash), r"\M-r");
173        assert_eq!(alt.render(Shell::Zsh), "^[r");
174        assert_eq!(alt.render(Shell::Fish), r"\er");
175        assert_eq!(alt.render(Shell::PowerShell), "Alt+r");
176    }
177
178    #[test]
179    fn a_chord_without_a_modifier_is_refused() {
180        assert_eq!("g".parse::<Chord>(), Err(ChordError::Shape));
181        assert_eq!("".parse::<Chord>(), Err(ChordError::Shape));
182    }
183
184    #[test]
185    fn an_unbindable_modifier_is_refused() {
186        assert!(matches!(
187            "shift-g".parse::<Chord>(),
188            Err(ChordError::Modifier(_))
189        ));
190        assert!(matches!(
191            "ctrl-shift-g".parse::<Chord>(),
192            Err(ChordError::Key(_))
193        ));
194    }
195
196    #[test]
197    fn a_key_that_is_not_one_letter_is_refused() {
198        for text in ["ctrl-", "ctrl-space", "ctrl-1", "alt-f4"] {
199            assert!(
200                matches!(text.parse::<Chord>(), Err(ChordError::Key(_))),
201                "{text}"
202            );
203        }
204    }
205
206    /// Binding one of these would cost the user a key the terminal needs more
207    /// than it needs the picker.
208    #[test]
209    fn control_keys_the_terminal_owns_are_refused() {
210        for (letter, _) in RESERVED {
211            let text = format!("ctrl-{letter}");
212            assert!(
213                matches!(text.parse::<Chord>(), Err(ChordError::Reserved(..))),
214                "{text}"
215            );
216        }
217        // Alt does not go through the terminal's control character table, so
218        // the same letters are free there.
219        assert!("alt-c".parse::<Chord>().is_ok());
220    }
221
222    #[test]
223    fn the_spoken_form_reads_like_a_key_to_press() {
224        assert_eq!(chord("ctrl-g").spoken(), "ctrl+g");
225        assert_eq!(chord("alt-r").spoken(), "alt+r");
226    }
227}