use std::fmt;
#[derive(Debug, Clone)]
pub struct ParseError {
msg: String,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ParseError {}
pub fn compile_to_bytes(tokens: &[String]) -> Result<Vec<u8>, ParseError> {
if tokens.is_empty() {
return Err(err("no keys to send"));
}
let mut out = Vec::new();
for tok in tokens {
compile_token(tok, &mut out)?;
}
Ok(out)
}
pub fn is_named_key(token: &str) -> bool {
lookup_named(token).is_some()
}
fn compile_token(token: &str, out: &mut Vec<u8>) -> Result<(), ParseError> {
if token.is_empty() {
return Err(err("empty chord"));
}
if token.contains('\n') {
return Err(err(
"newline in non-literal token; use 'Enter' or --literal",
));
}
if let Some((mods, atom)) = try_split_chord(token)? {
emit_chord(&mods, atom, out)?;
return Ok(());
}
if let Some(bytes) = lookup_named(token) {
out.extend_from_slice(bytes);
return Ok(());
}
out.extend_from_slice(token.as_bytes());
Ok(())
}
const NAMED_KEYS: &[(&str, &[u8])] = &[
("Enter", b"\r"),
("Tab", b"\t"),
("Esc", b"\x1b"),
("Space", b" "),
("Backspace", b"\x7f"),
("Delete", b"\x1b[3~"),
("Up", b"\x1b[A"),
("Down", b"\x1b[B"),
("Left", b"\x1b[D"),
("Right", b"\x1b[C"),
("Home", b"\x1b[H"),
("End", b"\x1b[F"),
("PageUp", b"\x1b[5~"),
("PageDown", b"\x1b[6~"),
("F1", b"\x1bOP"),
("F2", b"\x1bOQ"),
("F3", b"\x1bOR"),
("F4", b"\x1bOS"),
("F5", b"\x1b[15~"),
("F6", b"\x1b[17~"),
("F7", b"\x1b[18~"),
("F8", b"\x1b[19~"),
("F9", b"\x1b[20~"),
("F10", b"\x1b[21~"),
("F11", b"\x1b[23~"),
("F12", b"\x1b[24~"),
];
fn lookup_named(name: &str) -> Option<&'static [u8]> {
NAMED_KEYS.iter().find(|(n, _)| *n == name).map(|(_, b)| *b)
}
fn try_split_chord(token: &str) -> Result<Option<(Vec<char>, &str)>, ParseError> {
if !token.contains('-') {
return Ok(None);
}
let parts: Vec<&str> = token.split('-').collect();
if parts.len() < 2 {
return Ok(None);
}
if !is_modifier(parts[0]) {
return Ok(None);
}
let mut mods = Vec::with_capacity(parts.len() - 1);
for &m in &parts[..parts.len() - 1] {
if m.is_empty() {
return Err(err("malformed chord (empty modifier)"));
}
if !is_modifier(m) {
return Err(err(format!("unknown modifier '{m}'")));
}
mods.push(m.chars().next().expect("non-empty mod"));
}
let atom = *parts.last().expect("parts.len() >= 2");
if atom.is_empty() {
return Err(err("incomplete chord (no atom)"));
}
Ok(Some((mods, atom)))
}
fn is_modifier(s: &str) -> bool {
matches!(s, "C" | "M" | "S")
}
fn emit_chord(mods: &[char], atom: &str, out: &mut Vec<u8>) -> Result<(), ParseError> {
let has_alt = mods.contains(&'M');
let has_ctrl = mods.contains(&'C');
let has_shift = mods.contains(&'S');
if let Some(named_bytes) = lookup_named(atom) {
match (atom, has_shift, has_ctrl, has_alt) {
("Tab", true, false, false) => {
out.extend_from_slice(b"\x1b[Z");
}
(_, false, false, true) => {
out.push(0x1b);
out.extend_from_slice(named_bytes);
}
(_, false, false, false) => out.extend_from_slice(named_bytes),
_ => {
return Err(err(format!("unsupported modifier on named key '{atom}'")));
}
}
return Ok(());
}
let mut chars = atom.chars();
let c = chars.next().ok_or_else(|| err("empty chord atom"))?;
if chars.next().is_some() {
return Err(err(format!(
"modifier prefix valid on single-char atom or Named only; got '{atom}'"
)));
}
if !c.is_ascii() {
return Err(err(format!("non-ASCII atom in chord: '{c}'")));
}
let mut byte = c as u8;
if has_shift {
byte = byte.to_ascii_uppercase();
}
if has_ctrl {
byte &= 0x1f;
}
if has_alt {
out.push(0x1b);
}
out.push(byte);
Ok(())
}
fn err(msg: impl Into<String>) -> ParseError {
ParseError { msg: msg.into() }
}
#[cfg(test)]
mod tests {
use super::*;
fn t(s: &str) -> String {
s.to_string()
}
fn compile(strs: &[&str]) -> Result<Vec<u8>, ParseError> {
let toks: Vec<String> = strs.iter().map(|s| t(s)).collect();
compile_to_bytes(&toks)
}
#[test]
fn literal_single_char() {
assert_eq!(compile(&["a"]).unwrap(), b"a");
}
#[test]
fn ctrl_letter_collapses_to_control_code() {
assert_eq!(compile(&["C-a"]).unwrap(), &[0x01]);
assert_eq!(compile(&["C-c"]).unwrap(), &[0x03]);
assert_eq!(compile(&["C-l"]).unwrap(), &[0x0c]);
}
#[test]
fn ctrl_alt_letter_uses_esc_prefix() {
assert_eq!(compile(&["C-M-x"]).unwrap(), &[0x1b, 0x18]);
}
#[test]
fn named_keys_round_trip_table() {
let pairs: &[(&str, &[u8])] = &[
("Enter", b"\r"),
("Tab", b"\t"),
("Esc", b"\x1b"),
("Space", b" "),
("Backspace", b"\x7f"),
("Delete", b"\x1b[3~"),
("Up", b"\x1b[A"),
("Down", b"\x1b[B"),
("Left", b"\x1b[D"),
("Right", b"\x1b[C"),
("Home", b"\x1b[H"),
("End", b"\x1b[F"),
("PageUp", b"\x1b[5~"),
("PageDown", b"\x1b[6~"),
("F1", b"\x1bOP"),
("F4", b"\x1bOS"),
("F5", b"\x1b[15~"),
("F12", b"\x1b[24~"),
];
for (name, want) in pairs {
let got = compile(&[name]).unwrap();
assert_eq!(&got, want, "mismatch on Named '{name}'");
}
}
#[test]
fn multi_token_concat_no_separator() {
assert_eq!(
compile(&["echo", "Space", "hi", "Enter"]).unwrap(),
b"echo hi\r"
);
}
#[test]
fn multi_char_token_is_literal() {
assert_eq!(compile(&["echo hi"]).unwrap(), b"echo hi");
}
#[test]
fn shift_tab_is_back_tab() {
assert_eq!(compile(&["S-Tab"]).unwrap(), b"\x1b[Z");
}
#[test]
fn alt_named_uses_esc_prefix() {
let got = compile(&["M-Up"]).unwrap();
assert_eq!(got, b"\x1b\x1b[A", "M-Up = ESC + Up");
}
#[test]
fn token_with_dash_but_no_modifier_is_literal() {
assert_eq!(compile(&["long-name"]).unwrap(), b"long-name");
}
#[test]
fn rejects_unknown_modifier() {
assert_eq!(compile(&["X-a"]).unwrap(), b"X-a");
}
#[test]
fn rejects_unknown_modifier_after_valid_one() {
let e = compile(&["C-X-y"]).unwrap_err();
assert!(e.to_string().contains("unknown modifier 'X'"), "got: {e}");
}
#[test]
fn rejects_empty_modifier_segment() {
let e = compile(&["C--a"]).unwrap_err();
assert!(e.to_string().contains("empty modifier"), "got: {e}");
}
#[test]
fn rejects_chord_without_atom() {
let e = compile(&["C-"]).unwrap_err();
assert!(e.to_string().contains("incomplete chord"), "got: {e}");
}
#[test]
fn rejects_empty_token() {
let e = compile(&[""]).unwrap_err();
assert!(e.to_string().contains("empty chord"), "got: {e}");
}
#[test]
fn rejects_newline_in_token() {
let e = compile(&["abc\ndef"]).unwrap_err();
assert!(e.to_string().contains("newline"), "got: {e}");
}
#[test]
fn rejects_empty_input() {
let toks: Vec<String> = vec![];
let e = compile_to_bytes(&toks).unwrap_err();
assert!(e.to_string().contains("no keys"), "got: {e}");
}
#[test]
fn is_named_key_recognises_table_entries() {
assert!(is_named_key("Enter"));
assert!(is_named_key("F12"));
assert!(!is_named_key("enter")); assert!(!is_named_key("hello"));
}
#[test]
fn rejects_unsupported_named_modifier_combo() {
let e = compile(&["C-Enter"]).unwrap_err();
assert!(
e.to_string().contains("unsupported modifier on named key"),
"got: {e}"
);
}
}