use std::fmt;
use std::str::FromStr;
use thiserror::Error;
use crate::shell::Shell;
pub const DEFAULT: &str = "ctrl-g";
const RESERVED: &[(char, &str)] = &[
('c', "interrupts the running command"),
('d', "ends input"),
('i', "is the tab key"),
('j', "is a line feed"),
('m', "is the enter key"),
('q', "resumes a stopped terminal"),
('s', "stops terminal output"),
('z', "suspends the running command"),
];
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ChordError {
#[error("write the key as ctrl-<letter> or alt-<letter>, such as {DEFAULT}")]
Shape,
#[error("{0} is not a modifier lore can bind, use ctrl or alt")]
Modifier(String),
#[error("{0} is not a single letter")]
Key(String),
#[error("ctrl-{0} cannot be bound because it {1}")]
Reserved(char, &'static str),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Modifier {
Ctrl,
Alt,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Chord {
modifier: Modifier,
letter: char,
}
impl Chord {
pub fn render(self, shell: Shell) -> String {
let letter = self.letter;
match (shell, self.modifier) {
(Shell::Bash, Modifier::Ctrl) => format!(r"\C-{letter}"),
(Shell::Bash, Modifier::Alt) => format!(r"\M-{letter}"),
(Shell::Zsh, Modifier::Ctrl) => format!("^{}", letter.to_ascii_uppercase()),
(Shell::Zsh, Modifier::Alt) => format!("^[{letter}"),
(Shell::Fish, Modifier::Ctrl) => format!(r"\c{letter}"),
(Shell::Fish, Modifier::Alt) => format!(r"\e{letter}"),
(Shell::PowerShell, Modifier::Ctrl) => format!("Ctrl+{letter}"),
(Shell::PowerShell, Modifier::Alt) => format!("Alt+{letter}"),
}
}
pub fn spoken(self) -> String {
let modifier = match self.modifier {
Modifier::Ctrl => "ctrl",
Modifier::Alt => "alt",
};
format!("{modifier}+{}", self.letter)
}
pub fn is_default(self) -> bool {
self == Self::default()
}
}
impl Default for Chord {
fn default() -> Self {
DEFAULT.parse().expect("the default chord should parse")
}
}
impl FromStr for Chord {
type Err = ChordError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let text = text.trim().to_ascii_lowercase();
let (modifier, key) = text.split_once('-').ok_or(ChordError::Shape)?;
let modifier = match modifier {
"ctrl" | "control" => Modifier::Ctrl,
"alt" | "meta" => Modifier::Alt,
other => return Err(ChordError::Modifier(other.to_string())),
};
let mut letters = key.chars();
let letter = match (letters.next(), letters.next()) {
(Some(letter), None) if letter.is_ascii_alphabetic() => letter,
_ => return Err(ChordError::Key(key.to_string())),
};
if modifier == Modifier::Ctrl
&& let Some((_, why)) = RESERVED.iter().find(|(reserved, _)| *reserved == letter)
{
return Err(ChordError::Reserved(letter, why));
}
Ok(Self { modifier, letter })
}
}
impl fmt::Display for Chord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let modifier = match self.modifier {
Modifier::Ctrl => "ctrl",
Modifier::Alt => "alt",
};
write!(f, "{modifier}-{}", self.letter)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chord(text: &str) -> Chord {
text.parse().expect("should parse")
}
#[test]
fn the_default_is_the_chord_the_documentation_promises() {
assert_eq!(Chord::default().to_string(), "ctrl-g");
assert!(chord("ctrl-g").is_default());
assert!(!chord("alt-r").is_default());
}
#[test]
fn parsing_is_case_insensitive_and_round_trips() {
for text in ["ctrl-g", "CTRL-G", " Ctrl-g "] {
assert_eq!(chord(text).to_string(), "ctrl-g");
}
assert_eq!(chord("Alt-R").to_string(), "alt-r");
assert_eq!(chord("control-p").to_string(), "ctrl-p");
assert_eq!(chord("meta-p").to_string(), "alt-p");
}
#[test]
fn every_shell_gets_its_own_spelling() {
let ctrl = chord("ctrl-g");
assert_eq!(ctrl.render(Shell::Bash), r"\C-g");
assert_eq!(ctrl.render(Shell::Zsh), "^G");
assert_eq!(ctrl.render(Shell::Fish), r"\cg");
assert_eq!(ctrl.render(Shell::PowerShell), "Ctrl+g");
let alt = chord("alt-r");
assert_eq!(alt.render(Shell::Bash), r"\M-r");
assert_eq!(alt.render(Shell::Zsh), "^[r");
assert_eq!(alt.render(Shell::Fish), r"\er");
assert_eq!(alt.render(Shell::PowerShell), "Alt+r");
}
#[test]
fn a_chord_without_a_modifier_is_refused() {
assert_eq!("g".parse::<Chord>(), Err(ChordError::Shape));
assert_eq!("".parse::<Chord>(), Err(ChordError::Shape));
}
#[test]
fn an_unbindable_modifier_is_refused() {
assert!(matches!(
"shift-g".parse::<Chord>(),
Err(ChordError::Modifier(_))
));
assert!(matches!(
"ctrl-shift-g".parse::<Chord>(),
Err(ChordError::Key(_))
));
}
#[test]
fn a_key_that_is_not_one_letter_is_refused() {
for text in ["ctrl-", "ctrl-space", "ctrl-1", "alt-f4"] {
assert!(
matches!(text.parse::<Chord>(), Err(ChordError::Key(_))),
"{text}"
);
}
}
#[test]
fn control_keys_the_terminal_owns_are_refused() {
for (letter, _) in RESERVED {
let text = format!("ctrl-{letter}");
assert!(
matches!(text.parse::<Chord>(), Err(ChordError::Reserved(..))),
"{text}"
);
}
assert!("alt-c".parse::<Chord>().is_ok());
}
#[test]
fn the_spoken_form_reads_like_a_key_to_press() {
assert_eq!(chord("ctrl-g").spoken(), "ctrl+g");
assert_eq!(chord("alt-r").spoken(), "alt+r");
}
}