use std::collections::HashMap; use std::fs::File; use std::io::Read; use std::{fmt, path}; #[derive(Debug)] pub enum Error { ConfigNotFound, Io(std::io::Error), InvalidConfig(ParseError), } #[derive(Debug, PartialEq)] pub enum ParseError { // u32 is the line number where an error occured UnknownSymbol(u32), InvalidModifier(u32), InvalidKeysym(u32), } impl From for Error { fn from(val: std::io::Error) -> Self { if val.kind() == std::io::ErrorKind::NotFound { Error::ConfigNotFound } else { Error::Io(val) } } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &*self { Error::ConfigNotFound => "Config file not found.".fmt(f), Error::Io(io_err) => format!("I/O Error while parsing config file: {}", io_err).fmt(f), Error::InvalidConfig(parse_err) => match parse_err { ParseError::UnknownSymbol(line_nr) => { format!("Unknown symbol at line {}.", line_nr).fmt(f) } ParseError::InvalidKeysym(line_nr) => { format!("Invalid keysym at line {}.", line_nr).fmt(f) } ParseError::InvalidModifier(line_nr) => { format!("Invalid modifier at line {}.", line_nr).fmt(f) } }, } } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Hotkey { pub mode: Option, pub keysym: rdev::Key, pub modifiers: Vec, pub command: String, pub consume: bool, } #[derive(Debug, PartialEq, Copy, Clone, Hash, Eq)] // TODO: make the commented-out modifiers available pub enum Modifier { Super, // Hyper, // Meta, Alt, Control, Shift, // ModeSwitch, // Lock, // Mod1, // Mod2, // Mod3, // Mod4, // Mod5, } impl Hotkey { pub fn new(mode: Option, keysym: rdev::Key, modifiers: Vec, command: String, consume: bool) -> Self { Hotkey { mode, keysym, modifiers, command, consume } } } pub fn load(path: path::PathBuf) -> Result, Error> { let file_contents = load_file_contents(path)?; parse_contents(file_contents) } fn load_file_contents(path: path::PathBuf) -> Result { let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) } fn parse_contents(contents: String) -> Result, Error> { let key_to_evdev_key: HashMap<&str, rdev::Key> = HashMap::from([ ("q", rdev::Key::KeyQ), ("w", rdev::Key::KeyW), ("e", rdev::Key::KeyE), ("r", rdev::Key::KeyR), ("t", rdev::Key::KeyT), ("y", rdev::Key::KeyY), ("u", rdev::Key::KeyU), ("i", rdev::Key::KeyI), ("o", rdev::Key::KeyO), ("p", rdev::Key::KeyP), ("a", rdev::Key::KeyA), ("s", rdev::Key::KeyS), ("d", rdev::Key::KeyD), ("f", rdev::Key::KeyF), ("g", rdev::Key::KeyG), ("h", rdev::Key::KeyH), ("j", rdev::Key::KeyJ), ("k", rdev::Key::KeyK), ("l", rdev::Key::KeyL), ("z", rdev::Key::KeyZ), ("x", rdev::Key::KeyX), ("c", rdev::Key::KeyC), ("v", rdev::Key::KeyV), ("b", rdev::Key::KeyB), ("n", rdev::Key::KeyN), ("m", rdev::Key::KeyM), ("1", rdev::Key::Num1), ("2", rdev::Key::Num2), ("3", rdev::Key::Num3), ("4", rdev::Key::Num4), ("5", rdev::Key::Num5), ("6", rdev::Key::Num6), ("7", rdev::Key::Num7), ("8", rdev::Key::Num8), ("9", rdev::Key::Num9), ("0", rdev::Key::Num0), ("escape", rdev::Key::Escape), ("delete", rdev::Key::Delete), ("backspace", rdev::Key::Backspace), ("return", rdev::Key::Return), ("enter", rdev::Key::Return), ("tab", rdev::Key::Tab), ("space", rdev::Key::Space), ("minus", rdev::Key::Minus), ("-", rdev::Key::Minus), ("equal", rdev::Key::Equal), ("=", rdev::Key::Equal), ("grave", rdev::Key::BackQuote), ("`", rdev::Key::BackQuote), ("print", rdev::Key::PrintScreen), /* ("volumeup", rdev::Key::KEY_VOLUMEUP), ("xf86audioraisevolume", rdev::Key::KEY_VOLUMEUP), ("volumedown", rdev::Key::KEY_VOLUMEDOWN), ("xf86audiolowervolume", rdev::Key::KEY_VOLUMEDOWN), ("mute", rdev::Key::KEY_MUTE), ("xf86audiomute", rdev::Key::KEY_MUTE), ("brightnessup", rdev::Key::KEY_BRIGHTNESSUP), ("brightnessdown", rdev::Key::KEY_BRIGHTNESSDOWN), TODO: find ways to accept xf86 keys */ (",", rdev::Key::Comma), ("comma", rdev::Key::Comma), (".", rdev::Key::Dot), ("dot", rdev::Key::Dot), ("/", rdev::Key::Slash), ("slash", rdev::Key::Slash), ("backslash", rdev::Key::IntlBackslash), ("leftbrace", rdev::Key::LeftBracket), ("[", rdev::Key::LeftBracket), ("rightbrace", rdev::Key::RightBracket), ("]", rdev::Key::RightBracket), (";", rdev::Key::SemiColon), ("semicolon", rdev::Key::SemiColon), ("'", rdev::Key::Quote), ("apostrophe", rdev::Key::Quote), ("left", rdev::Key::LeftArrow), ("right", rdev::Key::RightArrow), ("up", rdev::Key::UpArrow), ("down", rdev::Key::DownArrow), ("f1", rdev::Key::F1), ("f2", rdev::Key::F2), ("f3", rdev::Key::F3), ("f4", rdev::Key::F4), ("f5", rdev::Key::F5), ("f6", rdev::Key::F6), ("f7", rdev::Key::F7), ("f8", rdev::Key::F8), ("f9", rdev::Key::F9), ("f10", rdev::Key::F10), ("f11", rdev::Key::F11), ("f12", rdev::Key::F12), /* ("f13", rdev::Key::F13), ("f14", rdev::Key::F14), ("f15", rdev::Key::F15), ("f16", rdev::Key::F16), ("f17", rdev::Key::F17), ("f18", rdev::Key::F18), ("f19", rdev::Key::F19), ("f20", rdev::Key::F20), ("f21", rdev::Key::F21), ("f22", rdev::Key::F22), ("f23", rdev::Key::F23), ("f24", rdev::Key::F24), */ ]); let mod_to_mod_enum: HashMap<&str, Modifier> = HashMap::from([ ("ctrl", Modifier::Control), ("control", Modifier::Control), ("super", Modifier::Super), ("mod4", Modifier::Super), ("alt", Modifier::Alt), ("mod1", Modifier::Alt), ("shift", Modifier::Shift), ]); let lines: Vec<&str> = contents.split('\n').collect(); // Go through each line, ignore comments and empty lines, mark lines starting with whitespace // as commands, and mark the other lines as keysyms. Mark means storing a line's type and the // line number in a vector. let mut lines_with_types: Vec<(&str, u32)> = Vec::new(); for (line_number, line) in lines.iter().enumerate() { if line.trim().starts_with('#') || line.trim().is_empty() { continue; } if line.starts_with(' ') || line.starts_with('\t') { lines_with_types.push(("command", line_number as u32)); } else { lines_with_types.push(("keysym", line_number as u32)); } } // Edge case: return a blank vector if no lines detected if lines_with_types.is_empty() { return Ok(vec![]); } // Go through lines_with_types, and add the next line over and over until the current line no // longer ends with backslash. (Only if the lines have the same type) let mut actual_lines: Vec<(&str, u32, String)> = Vec::new(); let mut current_line_type = lines_with_types[0].0; let mut current_line_number = lines_with_types[0].1; let mut current_line_string = String::new(); for (line_type, line_number) in lines_with_types { if line_type != current_line_type { current_line_type = line_type; current_line_number = line_number; current_line_string = String::new(); } current_line_string.push_str(lines[line_number as usize].trim()); if !current_line_string.ends_with('\\') { actual_lines.push(( current_line_type, current_line_number, current_line_string.replace("\\", ""), )); current_line_type = line_type; current_line_number = line_number; current_line_string = String::new(); } } let mut hotkeys: Vec = Vec::new(); for (i, item) in actual_lines.iter().enumerate() { let line_type = item.0; let line_number = item.1; let line = &item.2; if line_type != "keysym" { continue; } let next_line = actual_lines.get(i + 1); if next_line.is_none() { break; } let next_line = next_line.unwrap(); if next_line.0 != "command" { continue; // this should ignore keysyms that are not followed by a command } let extracted_keys = extract_curly_brace(line); let extracted_commands = extract_curly_brace(&next_line.2); 'hotkey_parse: for (key, command) in extracted_keys.iter().zip(extracted_commands.iter()) { println!("{} {}", key, command); let (mode, keysym, modifiers, consume) = parse_keybind(key, line_number + 1, &key_to_evdev_key, &mod_to_mod_enum)?; let hotkey = Hotkey { mode, keysym, modifiers, command: command.to_string(), consume }; // Ignore duplicate hotkeys for i in hotkeys.iter() { if i.keysym == hotkey.keysym && i.modifiers == hotkey.modifiers { continue 'hotkey_parse; } } hotkeys.push(hotkey); } } Ok(hotkeys) } // We need to get the reference to key_to_evdev_key // and mod_to_mod enum instead of recreating them // after each function call because it's too expensive fn parse_keybind( line: &str, line_nr: u32, key_to_evdev_key: &HashMap<&str, rdev::Key>, mod_to_mod_enum: &HashMap<&str, Modifier>, ) -> Result<(Option, rdev::Key, Vec, bool), Error> { let line = line.split('#').next().unwrap(); let tokens: Vec = line.split('+').map(|s| s.trim().to_lowercase()).filter(|s| s != "_").collect(); let last_token = tokens.last().unwrap().trim(); // Check if each token is valid for token in &tokens { if token.starts_with("[") && token.ends_with("]") { continue; } if token.to_string() == "!" { continue; } if key_to_evdev_key.contains_key(token.as_str()) { // Can't have a key that's like a modifier if token != last_token { return Err(Error::InvalidConfig(ParseError::InvalidModifier(line_nr))); } } else if mod_to_mod_enum.contains_key(token.as_str()) { // Can't have a modifier that's like a modifier if token == last_token { return Err(Error::InvalidConfig(ParseError::InvalidKeysym(line_nr))); } } else { return Err(Error::InvalidConfig(ParseError::UnknownSymbol(line_nr))); } } // Translate keypress into evdev key let keysym = key_to_evdev_key.get(last_token).unwrap(); let consume = if tokens .iter() .filter(|s| s.to_string() == "!") .map(|s| s.to_string()) .collect::>() .len() == 1 { true } else { false }; log::trace!("Consume: {}", consume); let mode = match tokens .iter() .filter(|s| s.starts_with("[") && s.ends_with("]")) .map(|s| s.replace(&['[', ']'][..], "")) .collect::>() .get(0) { Some(z) => Some(z.to_string()), None => { log::trace!("No mode specified"); None } }; let mut mod_index = if mode.is_some() { 1 } else { 0 }; // make sure to push index forward if consume { mod_index += 1; } let modifiers: Vec = tokens[mod_index..(tokens.len() - 1)] .iter() .map(|token| *mod_to_mod_enum.get(token.as_str()).unwrap()) .collect(); Ok((mode, *keysym, modifiers, consume)) } fn extract_curly_brace(line: &str) -> Vec { if !line.is_ascii() { return vec![line.to_string()]; } let mut output: Vec = Vec::new(); let index_open_brace = line.chars().position(|c| c == '{'); let index_close_brace = line.chars().position(|c| c == '}'); if index_open_brace.is_none() || index_close_brace.is_none() { return vec![line.to_string()]; } let start_index = index_open_brace.unwrap(); let end_index = index_close_brace.unwrap(); // There are no expansions to build if } is earlier than { if start_index >= end_index { return vec![line.to_string()]; } let str_before_braces = line[..start_index].to_string(); let str_after_braces = line[end_index + 1..].to_string(); let comma_separated_items = line[start_index + 1..end_index].split(','); for item in comma_separated_items { let mut push_one_item = || { output.push(format!("{}{}{}", str_before_braces, item.trim(), str_after_braces)); }; if !item.contains('-') { push_one_item(); continue; } // Parse dash ranges like {1-5} and {a-f} let mut range = item.split('-').map(|s| s.trim()); let begin_char: &str; let end_char: &str; if let Some(b) = range.next() { begin_char = b; } else { push_one_item(); continue; } if let Some(e) = range.next() { end_char = e; } else { push_one_item(); continue; } // Do not accept range values that are longer than one char // Example invalid: {ef-p} {3-56} // Beginning of the range cannot be greater than end // Example invalid: {9-4} {3-2} if begin_char.len() != 1 || end_char.len() != 1 || begin_char > end_char { push_one_item(); continue; } // In swhkd we will parse the full range using ASCII values. let begin_ascii_val = begin_char.parse::().unwrap() as u8; let end_ascii_val = end_char.parse::().unwrap() as u8; for ascii_number in begin_ascii_val..=end_ascii_val { output .push(format!("{}{}{}", str_before_braces, ascii_number as char, str_after_braces)); } } output } #[cfg(test)] mod tests { use super::*; use std::fs; use std::io::Write; // Implement a struct for a path used in tests // so that the test file will be automatically removed // no matter how the test goes struct TestPath { path: path::PathBuf, } impl TestPath { fn new(path: &str) -> Self { TestPath { path: path::PathBuf::from(path) } } // Create a path method for a more succinct way // to deal with borrowing the path value fn path(&self) -> path::PathBuf { self.path.clone() } } impl Drop for TestPath { fn drop(self: &mut TestPath) { if self.path.exists() { fs::remove_file(self.path()).unwrap(); } } } // Wrapper for config tests fn eval_config_test(contents: &str, expected_hotkeys: Vec) -> std::io::Result<()> { let result = parse_contents(contents.to_string()); let mut expected_hotkeys_mut = expected_hotkeys; if result.is_err() { panic!("Expected Ok config, found Err {:?}", result.unwrap_err()); } let actual_hotkeys = result.unwrap(); assert_eq!(actual_hotkeys.len(), expected_hotkeys_mut.len()); // Go through each actual hotkey, and pop a corresponding // hotkey from the expected hotkeys // to make sure that order does not matter for hotkey in actual_hotkeys { if let Some(index) = expected_hotkeys_mut.iter().position(|key| { key.keysym == hotkey.keysym && key.command == hotkey.command && key.modifiers == hotkey.modifiers }) { expected_hotkeys_mut.remove(index); } else { panic!( "unexpected hotkey {:#?} found in result\nExpected result:\n{:#?}", hotkey, expected_hotkeys_mut ); } } if !expected_hotkeys_mut.is_empty() { panic!( "Some hotkeys were not returned by the actual result:\n{:#?}", expected_hotkeys_mut ); } Ok(()) } // Wrapper for the many error tests fn eval_invalid_config_test( contents: &str, parse_error_type: ParseError, ) -> std::io::Result<()> { let result = parse_contents(contents.to_string()); assert!(result.is_err()); let result = result.unwrap_err(); // Check if the Error type is InvalidConfig let result = match result { Error::InvalidConfig(parse_err) => parse_err, _ => panic!(), }; // Check the ParseError enum type if result != parse_error_type { panic!("ParseError: Expected `{:?}`, found `{:?}`", parse_error_type, result); } Ok(()) } #[test] fn test_nonexistent_file() { let path = path::PathBuf::from(r"This File Doesn't Exist"); let result = load_file_contents(path); assert!(result.is_err()); match result.unwrap_err() { Error::ConfigNotFound => {} _ => { panic!("Error type for nonexistent file is wrong."); } } } #[test] fn test_existing_file() -> std::io::Result<()> { let setup = TestPath::new("/tmp/swhkd-test-file1"); // Build a dummy file in /tmp let mut f = File::create(setup.path())?; f.write_all( b" x dmenu_run q bspc node -q", )?; let result = load_file_contents(setup.path()); assert!(result.is_ok()); Ok(()) } #[test] fn test_basic_keybind() -> std::io::Result<()> { let contents = " r alacritty "; eval_config_test( contents, vec![Hotkey::new("".to_string(), evdev::Key::KEY_R, vec![], String::from("alacritty"))], ) } #[test] fn test_multiple_keybinds() -> std::io::Result<()> { let contents = " r alacritty w kitty t /bin/firefox "; let hotkey_1 = Hotkey::new("".to_string(), evdev::Key::KEY_R, vec![], String::from("alacritty")); let hotkey_2 = Hotkey::new("".to_string(), evdev::Key::KEY_W, vec![], String::from("kitty")); let hotkey_3 = Hotkey::new("".to_string(), evdev::Key::KEY_T, vec![], String::from("/bin/firefox")); eval_config_test(contents, vec![hotkey_1, hotkey_2, hotkey_3]) } #[test] fn test_comments() -> std::io::Result<()> { let contents = " r alacritty w kitty #t #/bin/firefox "; let expected_keybinds = vec![ Hotkey::new("".to_string(), evdev::Key::KEY_R, vec![], String::from("alacritty")), Hotkey::new("".to_string(), evdev::Key::KEY_W, vec![], String::from("kitty")), ]; eval_config_test(contents, expected_keybinds) } #[test] fn test_multiple_keypress() -> std::io::Result<()> { let contents = " super + 5 alacritty "; let expected_keybinds = vec![Hotkey::new("".to_string(), evdev::Key::KEY_5, vec![Modifier::Super], String::from("alacritty"))]; eval_config_test(contents, expected_keybinds) } #[test] fn test_keysym_instead_of_modifier() -> std::io::Result<()> { let contents = " shift + k + m notify-send 'Hello world!' "; eval_invalid_config_test(contents, ParseError::InvalidModifier(2)) } #[test] fn test_modifier_instead_of_keysym() -> std::io::Result<()> { let contents = " shift + k + alt notify-send 'Hello world!' "; eval_invalid_config_test(contents, ParseError::InvalidModifier(2)) } #[test] fn test_unfinished_plus_sign() -> std::io::Result<()> { let contents = " shift + alt + notify-send 'Hello world!' "; eval_invalid_config_test(contents, ParseError::UnknownSymbol(4)) } #[test] fn test_plus_sign_at_start() -> std::io::Result<()> { let contents = " + shift + k notify-send 'Hello world!' "; eval_invalid_config_test(contents, ParseError::UnknownSymbol(2)) } #[test] fn test_common_modifiers() -> std::io::Result<()> { let contents = " shift + k notify-send 'Hello world!' control + 5 notify-send 'Hello world!' alt + 2 notify-send 'Hello world!' super + z notify-send 'Hello world!' "; let expected_hotkeys = vec![ Hotkey::new( "".to_string(), evdev::Key::KEY_K, vec![Modifier::Shift], "notify-send 'Hello world!'".to_string(), ), Hotkey::new( "".to_string(), evdev::Key::KEY_5, vec![Modifier::Control], "notify-send 'Hello world!'".to_string(), ), Hotkey::new( "".to_string(), evdev::Key::KEY_2, vec![Modifier::Alt], "notify-send 'Hello world!'".to_string(), ), Hotkey::new( "".to_string(), evdev::Key::KEY_Z, vec![Modifier::Super], "notify-send 'Hello world!'".to_string(), ), ]; eval_config_test(contents, expected_hotkeys) } #[test] fn test_command_with_many_spaces() -> std::io::Result<()> { let contents = " p xbacklight -inc 10 -fps 30 -time 200 "; let expected_keybinds = vec![Hotkey::new( evdev::Key::KEY_P, vec![], String::from("xbacklight -inc 10 -fps 30 -time 200"), )]; eval_config_test(contents, expected_keybinds) } #[test] fn test_invalid_keybinding() -> std::io::Result<()> { let contents = " p xbacklight -inc 10 -fps 30 -time 200 pesto xterm "; eval_invalid_config_test(contents, ParseError::UnknownSymbol(5)) } #[test] // keysyms not followed by command should be ignored fn test_no_command() -> std::io::Result<()> { let contents = " k xbacklight -inc 10 -fps 30 -time 200 w "; eval_config_test( contents, vec![Hotkey::new( evdev::Key::KEY_K, vec![], "xbacklight -inc 10 -fps 30 -time 200".to_string(), )], ) } #[test] fn test_real_config_snippet() -> std::io::Result<()> { let contents = " # reloads sxhkd configuration: super + Escape pkill -USR1 -x sxhkd ; sxhkd & # Launch Terminal super + Return alacritty -t \"Terminal\" -e \"$HOME/.config/sxhkd/new_tmux_terminal.sh\" # terminal emulator (no tmux) super + shift + Return alacritty -t \"Terminal\" # terminal emulator (new tmux session) alt + Return alacritty -t \"Terminal\" -e \"tmux\" ctrl + 0 play-song.sh super + minus play-song.sh album "; let expected_result: Vec = vec![ Hotkey::new( evdev::Key::KEY_ESC, vec![Modifier::Super], String::from("pkill -USR1 -x sxhkd ; sxhkd &"), ), Hotkey::new( evdev::Key::KEY_ENTER, vec![Modifier::Super], String::from( "alacritty -t \"Terminal\" -e \"$HOME/.config/sxhkd/new_tmux_terminal.sh\"", ), ), Hotkey::new( evdev::Key::KEY_ENTER, vec![Modifier::Super, Modifier::Shift], String::from("alacritty -t \"Terminal\""), ), Hotkey::new( evdev::Key::KEY_ENTER, vec![Modifier::Alt], String::from("alacritty -t \"Terminal\" -e \"tmux\""), ), Hotkey::new(evdev::Key::KEY_0, vec![Modifier::Control], String::from("play-song.sh")), Hotkey::new( evdev::Key::KEY_MINUS, vec![Modifier::Super], String::from("play-song.sh album"), ), ]; eval_config_test(contents, expected_result) } #[test] fn test_multiline_command() -> std::io::Result<()> { let contents = " k mpc ls | dmenu | \\ sed -i 's/foo/bar/g' "; let expected_keybind = Hotkey::new( evdev::Key::KEY_K, vec![], String::from("mpc ls | dmenu | sed -i 's/foo/bar/g'"), ); eval_config_test(contents, vec![expected_keybind]) } #[test] fn test_commented_out_keybind() -> std::io::Result<()> { let contents = " #w gimp "; eval_config_test(contents, vec![]) } // TODO: Write these tests as needed. #[test] fn test_all_alphanumeric() -> std::io::Result<()> { let symbols: [&str; 36] = [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ]; let keysyms: [evdev::Key; 36] = [ evdev::Key::KEY_A, evdev::Key::KEY_B, evdev::Key::KEY_C, evdev::Key::KEY_D, evdev::Key::KEY_E, evdev::Key::KEY_F, evdev::Key::KEY_G, evdev::Key::KEY_H, evdev::Key::KEY_I, evdev::Key::KEY_J, evdev::Key::KEY_K, evdev::Key::KEY_L, evdev::Key::KEY_M, evdev::Key::KEY_N, evdev::Key::KEY_O, evdev::Key::KEY_P, evdev::Key::KEY_Q, evdev::Key::KEY_R, evdev::Key::KEY_S, evdev::Key::KEY_T, evdev::Key::KEY_U, evdev::Key::KEY_V, evdev::Key::KEY_W, evdev::Key::KEY_X, evdev::Key::KEY_Y, evdev::Key::KEY_Z, evdev::Key::KEY_0, evdev::Key::KEY_1, evdev::Key::KEY_2, evdev::Key::KEY_3, evdev::Key::KEY_4, evdev::Key::KEY_5, evdev::Key::KEY_6, evdev::Key::KEY_7, evdev::Key::KEY_8, evdev::Key::KEY_9, ]; let mut contents = String::new(); for symbol in &symbols { contents.push_str(&format!("{}\n st\n", symbol)); } let contents = &contents; let expected_result: Vec = keysyms.iter().map(|keysym| Hotkey::new(*keysym, vec![], "st".to_string())).collect(); eval_config_test(contents, expected_result) } #[test] fn test_homerow_special_keys_top() -> std::io::Result<()> { let symbols: [&str; 7] = ["Escape", "BackSpace", "Return", "Tab", "minus", "equal", "grave"]; let keysyms: [evdev::Key; 7] = [ evdev::Key::KEY_ESC, evdev::Key::KEY_BACKSPACE, evdev::Key::KEY_ENTER, evdev::Key::KEY_TAB, evdev::Key::KEY_MINUS, evdev::Key::KEY_EQUAL, evdev::Key::KEY_GRAVE, ]; let mut contents = String::new(); for symbol in &symbols { contents.push_str(&format!("{}\n st\n", symbol)); } let contents = &contents; let expected_result: Vec = keysyms.iter().map(|keysym| Hotkey::new(*keysym, vec![], "st".to_string())).collect(); eval_config_test(contents, expected_result) } #[test] fn test_case_insensitive() -> std::io::Result<()> { let contents = " Super + SHIFT + alt + a st ReTurn ts "; eval_config_test( contents, vec![ Hotkey::new( evdev::Key::KEY_A, vec![Modifier::Super, Modifier::Shift, Modifier::Alt], "st".to_string(), ), Hotkey::new(evdev::Key::KEY_ENTER, vec![], "ts".to_string()), ], ) } #[test] fn test_duplicate_hotkeys() -> std::io::Result<()> { let contents = " super + a st suPer + A ts b st B ts "; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "st".to_string()), Hotkey::new(evdev::Key::KEY_B, vec![], "st".to_string()), ], ) } #[test] fn test_inline_comment() -> std::io::Result<()> { let contents = " super + a #comment and comment super st super + shift + b ts #this comment should be handled by shell " .to_string(); eval_config_test( &contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "st".to_string()), Hotkey::new( evdev::Key::KEY_B, vec![Modifier::Super, Modifier::Shift], "ts #this comment should be handled by shell".to_string(), ), ], ) } #[test] fn test_blank_config() -> std::io::Result<()> { let contents = ""; eval_config_test(contents, vec![]) } #[test] fn test_blank_config_with_whitespace() -> std::io::Result<()> { let contents = " "; eval_config_test(contents, vec![]) } #[test] fn test_extract_curly_brace() -> std::io::Result<()> { let keybind_with_curly_brace = "super + {a,b,c}"; assert_eq!( extract_curly_brace(keybind_with_curly_brace), vec!["super + a", "super + b", "super + c",] ); let command_with_curly_brace = "bspc node -p {west,south,north,west}"; assert_eq!( extract_curly_brace(command_with_curly_brace), vec![ "bspc node -p west", "bspc node -p south", "bspc node -p north", "bspc node -p west", ] ); let wrong_format = "super + }a, b, c{"; assert_eq!(extract_curly_brace(wrong_format), vec![wrong_format]); let single_sym = "super + {a}"; assert_eq!(extract_curly_brace(single_sym), vec!["super + a"]); Ok(()) } #[test] fn test_curly_brace() -> std::io::Result<()> { let contents = " super + {a,b,c} {firefox, brave, chrome}"; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "firefox".to_string()), Hotkey::new(evdev::Key::KEY_B, vec![Modifier::Super], "brave".to_string()), Hotkey::new(evdev::Key::KEY_C, vec![Modifier::Super], "chrome".to_string()), ], ) } #[test] fn test_curly_brace_less_commands() -> std::io::Result<()> { let contents = " super + {a,b,c} {firefox, brave}"; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "firefox".to_string()), Hotkey::new(evdev::Key::KEY_B, vec![Modifier::Super], "brave".to_string()), ], ) } #[test] fn test_curly_brace_less_keysyms() -> std::io::Result<()> { let contents = " super + {a, b} {firefox, brave, chrome}"; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "firefox".to_string()), Hotkey::new(evdev::Key::KEY_B, vec![Modifier::Super], "brave".to_string()), ], ) } #[test] fn test_range_syntax() -> std::io::Result<()> { let contents = " super + {1-9,0} bspc desktop -f '{1-9,0}'"; eval_config_test( contents, vec![ Hotkey::new( evdev::Key::KEY_1, vec![Modifier::Super], "bspc desktop -f '1'".to_string(), ), Hotkey::new( evdev::Key::KEY_2, vec![Modifier::Super], "bspc desktop -f '2'".to_string(), ), Hotkey::new( evdev::Key::KEY_3, vec![Modifier::Super], "bspc desktop -f '3'".to_string(), ), Hotkey::new( evdev::Key::KEY_4, vec![Modifier::Super], "bspc desktop -f '4'".to_string(), ), Hotkey::new( evdev::Key::KEY_5, vec![Modifier::Super], "bspc desktop -f '5'".to_string(), ), Hotkey::new( evdev::Key::KEY_6, vec![Modifier::Super], "bspc desktop -f '6'".to_string(), ), Hotkey::new( evdev::Key::KEY_7, vec![Modifier::Super], "bspc desktop -f '7'".to_string(), ), Hotkey::new( evdev::Key::KEY_8, vec![Modifier::Super], "bspc desktop -f '8'".to_string(), ), Hotkey::new( evdev::Key::KEY_9, vec![Modifier::Super], "bspc desktop -f '9'".to_string(), ), Hotkey::new( evdev::Key::KEY_0, vec![Modifier::Super], "bspc desktop -f '0'".to_string(), ), ], ) } #[test] fn test_range_syntax_ascii_character() -> std::io::Result<()> { let contents = " super + {a-c} {firefox, brave, chrome}"; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_A, vec![Modifier::Super], "firefox".to_string()), Hotkey::new(evdev::Key::KEY_B, vec![Modifier::Super], "brave".to_string()), Hotkey::new(evdev::Key::KEY_C, vec![Modifier::Super], "chrome".to_string()), ], ) } #[test] fn test_range_syntax_not_ascii() -> std::io::Result<()> { let contents = " super + {a-是} {firefox, brave} "; eval_invalid_config_test(contents, ParseError::UnknownSymbol(2)) } #[test] fn test_range_syntax_invalid_range() -> std::io::Result<()> { let contents = " super + {bc-ad} {firefox, brave} "; eval_invalid_config_test(contents, ParseError::UnknownSymbol(2)) } #[test] fn test_ranger_syntax_not_full_range() -> std::io::Result<()> { let contents = " super + {a-} {firefox, brave}"; eval_invalid_config_test(contents, ParseError::UnknownSymbol(2)) } #[test] fn test_none() -> std::io::Result<()> { let contents = " super + {_, shift} + b {firefox, brave}"; eval_config_test( contents, vec![ Hotkey::new(evdev::Key::KEY_B, vec![Modifier::Super], "firefox".to_string()), Hotkey::new( evdev::Key::KEY_B, vec![Modifier::Super, Modifier::Shift], "brave".to_string(), ), ], ) } #[test] #[ignore] // TODO: handle multiple ranges fn test_multiple_ranges() -> std::io::Result<()> { let contents = " super + {shift,alt} + {c,d} {librewolf, firefox} {--sync, --help} "; eval_config_test( contents, vec![ Hotkey::new( evdev::Key::KEY_C, vec![Modifier::Super, Modifier::Shift], "librewolf --sync".to_string(), ), Hotkey::new( evdev::Key::KEY_D, vec![Modifier::Super, Modifier::Shift], "librewolf --help".to_string(), ), Hotkey::new( evdev::Key::KEY_C, vec![Modifier::Super, Modifier::Alt], "firefox --sync".to_string(), ), Hotkey::new( evdev::Key::KEY_D, vec![Modifier::Super, Modifier::Alt], "firefox --help".to_string(), ), ], ) } #[test] #[ignore] fn test_multiple_ranges_numbers() -> std::io::Result<()> { let contents = " {control,super} + {1-3} {notify-send, echo} {hello,how,are} "; eval_config_test( contents, vec![ Hotkey::new( evdev::Key::KEY_1, vec![Modifier::Control], "notify-send hello".to_string(), ), Hotkey::new( evdev::Key::KEY_2, vec![Modifier::Control], "notify-send how".to_string(), ), Hotkey::new( evdev::Key::KEY_3, vec![Modifier::Control], "notify-send are".to_string(), ), Hotkey::new(evdev::Key::KEY_1, vec![Modifier::Super], "echo hello".to_string()), Hotkey::new(evdev::Key::KEY_2, vec![Modifier::Super], "echo how".to_string()), Hotkey::new(evdev::Key::KEY_3, vec![Modifier::Super], "echo are".to_string()), ], ) } } #[cfg(test)] mod display_test { use super::*; use std::io; #[test] fn test_display_config_not_found_error() { let error = Error::ConfigNotFound; assert_eq!(format!("{}", error), "Config file not found."); } #[test] fn test_display_io_error() { let error = Error::Io(io::Error::from(io::ErrorKind::UnexpectedEof)); if !format!("{}", error).contains("unexpected end of file") { panic!("Error message was '{}", error); } } #[test] fn test_display_unknown_symbol_error() { let error = Error::InvalidConfig(ParseError::UnknownSymbol(10)); assert_eq!(format!("{}", error), "Unknown symbol at line 10."); } #[test] fn test_display_invalid_modifier_error() { let error = Error::InvalidConfig(ParseError::InvalidModifier(25)); assert_eq!(format!("{}", error), "Invalid modifier at line 25."); } #[test] fn test_invalid_keysm_error() { let error = Error::InvalidConfig(ParseError::InvalidKeysym(7)); assert_eq!(format!("{}", error), "Invalid keysym at line 7."); } }