use std::borrow::Cow;
pub const SPECIAL_CHARS: [char; 6] = ['{', '}', '!', '+', '^', '#'];
const NAMED_KEYS: &[&str] = &[
"SPACE",
"ENTER",
"ESCAPE",
"ESC",
"TAB",
"BACKSPACE",
"BS",
"DELETE",
"DEL",
"INSERT",
"INS",
"UP",
"DOWN",
"LEFT",
"RIGHT",
"HOME",
"END",
"PGUP",
"PGDN",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"CAPSLOCK",
"NUMLOCK",
"SCROLLLOCK",
"PRINTSCREEN",
"BREAK",
"PAUSE",
"APPSKEY",
"SLEEP",
"ALT",
"CTRL",
"SHIFT",
"LALT",
"RALT",
"LCTRL",
"RCTRL",
"LSHIFT",
"RSHIFT",
"LWIN",
"RWIN",
"ALTDOWN",
"ALTUP",
"SHIFTDOWN",
"SHIFTUP",
"CTRLDOWN",
"CTRLUP",
"LWINDOWN",
"LWINUP",
"RWINDOWN",
"RWINUP",
"NUMPAD0",
"NUMPAD1",
"NUMPAD2",
"NUMPAD3",
"NUMPAD4",
"NUMPAD5",
"NUMPAD6",
"NUMPAD7",
"NUMPAD8",
"NUMPAD9",
"NUMPADMULT",
"NUMPADADD",
"NUMPADSUB",
"NUMPADDIV",
"NUMPADDOT",
"NUMPADENTER",
"ASC",
"BROWSER_BACK",
"BROWSER_FORWARD",
"BROWSER_REFRESH",
"BROWSER_STOP",
"BROWSER_SEARCH",
"BROWSER_FAVORITES",
"BROWSER_HOME",
"VOLUME_MUTE",
"VOLUME_DOWN",
"VOLUME_UP",
"MEDIA_NEXT",
"MEDIA_PREV",
"MEDIA_STOP",
"MEDIA_PLAY_PAUSE",
"LAUNCH_MAIL",
"LAUNCH_MEDIA",
"LAUNCH_APP1",
"LAUNCH_APP2",
];
#[allow(clippy::manual_ignore_case_cmp)]
const fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i].to_ascii_uppercase() != b[i].to_ascii_uppercase() {
return false;
}
i += 1;
}
true
}
const fn is_known_name(name: &[u8]) -> bool {
if name.len() == 1 {
return true;
}
let mut i = 0;
while i < NAMED_KEYS.len() {
if eq_ascii_ci(name, NAMED_KEYS[i].as_bytes()) {
return true;
}
i += 1;
}
false
}
const fn is_valid_suffix(suffix: &[u8]) -> bool {
if suffix.is_empty() {
return false;
}
if eq_ascii_ci(suffix, b"down") || eq_ascii_ci(suffix, b"up") {
return true;
}
let mut i = 0;
while i < suffix.len() {
if !suffix[i].is_ascii_digit() {
return false;
}
i += 1;
}
true
}
#[must_use]
pub const fn validate(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] != b'{' {
i += 1;
continue;
}
let start = i + 1;
if start >= b.len() {
return false; }
let mut j = start + 1;
while j < b.len() && b[j] != b'}' {
j += 1;
}
if j >= b.len() {
return false; }
let mut sp = start;
while sp < j && b[sp] != b' ' {
sp += 1;
}
let name = split(b, start, sp);
if !is_known_name(name) {
return false;
}
if sp < j {
let mut k = sp;
while k < j && b[k] == b' ' {
k += 1;
}
if !is_valid_suffix(split(b, k, j)) {
return false;
}
}
i = j + 1;
}
true
}
const fn split(b: &[u8], from: usize, to: usize) -> &[u8] {
let (_, rest) = b.split_at(from);
let (mid, _) = rest.split_at(to - from);
mid
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Keys(Cow<'static, str>);
impl Keys {
#[must_use]
pub fn text(s: &str) -> Self {
if !s.contains(SPECIAL_CHARS) {
return Self(Cow::Owned(s.to_owned()));
}
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'{' => out.push_str("{{}"),
'}' => out.push_str("{}}"),
'!' => out.push_str("{!}"),
'+' => out.push_str("{+}"),
'^' => out.push_str("{^}"),
'#' => out.push_str("{#}"),
_ => out.push(c),
}
}
Self(Cow::Owned(out))
}
pub fn parse(s: &str) -> Result<Self, KeyParseError> {
tokenize(s)?;
Ok(Self(Cow::Owned(s.to_owned())))
}
#[must_use]
#[doc(hidden)]
pub const fn from_literal(s: &'static str) -> Self {
Self(Cow::Borrowed(s))
}
#[must_use]
pub fn raw_unchecked(s: impl Into<Cow<'static, str>>) -> Self {
Self(s.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn then(self, other: Self) -> Self {
Self(Cow::Owned(format!("{}{}", self.0, other.0)))
}
pub fn tokens(&self) -> Result<Vec<Token>, KeyParseError> {
tokenize(&self.0)
}
}
impl std::fmt::Display for Keys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Token {
Char(char),
Named {
name: String,
repeat: u32,
hold: Option<bool>,
},
Modifier(Modifier),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Modifier {
Alt,
Shift,
Ctrl,
Win,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum KeyParseError {
#[error("unterminated '{{' at byte {at}")]
Unterminated {
at: usize,
},
#[error("unknown key name {name:?} at byte {at} — see autoitx::keys for the vocabulary")]
UnknownKey {
name: String,
at: usize,
},
#[error("invalid repeat/hold {suffix:?} at byte {at} — expected a number, 'down', or 'up'")]
InvalidSuffix {
suffix: String,
at: usize,
},
}
fn tokenize(s: &str) -> Result<Vec<Token>, KeyParseError> {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
match b[i] {
b'{' => {
let at = i;
let start = i + 1;
if start >= b.len() {
return Err(KeyParseError::Unterminated { at });
}
let mut j = start + 1;
while j < b.len() && b[j] != b'}' {
j += 1;
}
if j >= b.len() {
return Err(KeyParseError::Unterminated { at });
}
let inner = &s[start..j];
let (name, suffix) = match inner.find(' ') {
Some(sp) => (&inner[..sp], inner[sp..].trim_start()),
None => (inner, ""),
};
if !is_known_name(name.as_bytes()) {
return Err(KeyParseError::UnknownKey {
name: name.to_owned(),
at,
});
}
let (repeat, hold) = if suffix.is_empty() {
(1, None)
} else if suffix.eq_ignore_ascii_case("down") {
(1, Some(true))
} else if suffix.eq_ignore_ascii_case("up") {
(1, Some(false))
} else {
let n = suffix
.parse::<u32>()
.map_err(|_| KeyParseError::InvalidSuffix {
suffix: suffix.to_owned(),
at,
})?;
(n, None)
};
let mut chars = name.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => out.push(Token::Char(c)),
_ => out.push(Token::Named {
name: name.to_ascii_uppercase(),
repeat,
hold,
}),
}
i = j + 1;
}
b'!' => {
out.push(Token::Modifier(Modifier::Alt));
i += 1;
}
b'+' => {
out.push(Token::Modifier(Modifier::Shift));
i += 1;
}
b'^' => {
out.push(Token::Modifier(Modifier::Ctrl));
i += 1;
}
b'#' => {
out.push(Token::Modifier(Modifier::Win));
i += 1;
}
_ => {
let c = s[i..].chars().next().expect("index is a char boundary");
out.push(Token::Char(c));
i += c.len_utf8();
}
}
}
Ok(out)
}
#[macro_export]
macro_rules! keys {
($s:literal) => {{
const _: () = assert!(
$crate::keys::validate($s),
"invalid AutoIt key sequence: check for an unclosed brace or an unknown key name",
);
$crate::keys::Keys::from_literal($s)
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_escapes_every_special_character() {
assert_eq!(Keys::text("{").as_str(), "{{}");
assert_eq!(Keys::text("}").as_str(), "{}}");
assert_eq!(Keys::text("!").as_str(), "{!}");
assert_eq!(Keys::text("+").as_str(), "{+}");
assert_eq!(Keys::text("^").as_str(), "{^}");
assert_eq!(Keys::text("#").as_str(), "{#}");
}
#[test]
fn escaped_text_round_trips_to_the_original_characters() {
for input in [
"R$ 1.234,56",
"Order Entry",
"a{b}c",
"!+^#{}",
"password{with}braces!",
"",
] {
let toks = Keys::text(input).tokens().expect("escaped text must parse");
let round: String = toks
.iter()
.map(|t| match t {
Token::Char(c) => *c,
other => panic!("escaped text produced a command token: {other:?}"),
})
.collect();
assert_eq!(round, input, "round trip failed for {input:?}");
}
}
#[test]
fn text_cannot_smuggle_a_command_through_data() {
let hostile = "{ENTER}{CTRLDOWN}a{CTRLUP}!{F4}";
let toks = Keys::text(hostile).tokens().unwrap();
assert!(
toks.iter().all(|t| matches!(t, Token::Char(_))),
"data became commands: {toks:?}"
);
}
#[test]
fn the_sequences_the_rpas_actually_send_all_validate() {
for s in [
"{TAB}",
"{ENTER}",
"{SHIFTDOWN}{TAB}{SHIFTUP}",
"{CTRLDOWN}c{CTRLUP}",
"{CTRLDOWN}v{CTRLUP}",
"{CTRLDOWN}l{CTRLUP}",
"{CTRLDOWN}{SHIFTDOWN}j{SHIFTUP}{CTRLUP}",
"{CTRLDOWN}{SHIFTDOWN}w{SHIFTUP}{CTRLUP}",
"{ALTDOWN}{PRINTSCREEN}{ALTUP}",
"{ALTDOWN}d{ALTUP}",
"{LWINDOWN}d{LWINUP}",
"{END}{SHIFTDOWN}{HOME}{SHIFTUP}",
"{CTRLDOWN}{END}{CTRLUP}{CTRLDOWN}{SHIFTDOWN}{HOME}{SHIFTUP}{CTRLUP}",
"{TAB}{TAB}{TAB}{TAB}{TAB}{TAB}{TAB}{SPACE}",
"{F6}",
"{PGUP}",
"{ESC}",
"{BACKSPACE}",
"no{ENTER}",
"01011900",
] {
assert!(validate(s), "should be valid: {s:?}");
assert!(Keys::parse(s).is_ok(), "should parse: {s:?}");
}
}
#[test]
fn malformed_sequences_are_rejected() {
for s in [
"{CTRLDOWN",
"{",
"{TABB}",
"{TAB 4x}",
"{NOPE}",
"{TAB down up}",
] {
assert!(!validate(s), "should be invalid: {s:?}");
assert!(Keys::parse(s).is_err(), "should not parse: {s:?}");
}
}
#[test]
fn repeat_and_hold_suffixes_parse() {
let t = Keys::parse("{TAB 4}").unwrap().tokens().unwrap();
assert_eq!(
t,
vec![Token::Named {
name: "TAB".into(),
repeat: 4,
hold: None
}]
);
let t = Keys::parse("{SHIFT down}").unwrap().tokens().unwrap();
assert_eq!(
t,
vec![Token::Named {
name: "SHIFT".into(),
repeat: 1,
hold: Some(true)
}]
);
}
#[test]
fn shorthand_modifiers_tokenize() {
let t = Keys::parse("^c").unwrap().tokens().unwrap();
assert_eq!(t, vec![Token::Modifier(Modifier::Ctrl), Token::Char('c')]);
}
#[test]
fn key_names_are_case_insensitive_like_autoit() {
for s in ["{tab}", "{Tab}", "{TAB}", "{ctrldown}a{CtrlUp}"] {
assert!(validate(s), "{s:?}");
}
}
#[test]
fn non_ascii_text_is_preserved_character_by_character() {
let t = Keys::text("ção").tokens().unwrap();
assert_eq!(
t,
vec![Token::Char('ç'), Token::Char('ã'), Token::Char('o')]
);
}
#[test]
fn const_validator_agrees_with_the_runtime_parser() {
for s in [
"",
"abc",
"{TAB}",
"{TAB 4}",
"{TAB down}",
"{!}",
"{{}",
"{}}",
"^c",
"{CTRLDOWN",
"{NOPE}",
"{TAB 4x}",
"{",
"}",
"a}b",
"{a}",
"{ASC 065}",
] {
assert_eq!(
validate(s),
tokenize(s).is_ok(),
"validator and parser disagree on {s:?}"
);
}
}
#[test]
fn then_concatenates() {
let k = Keys::text("74").then(Keys::parse("{TAB}").unwrap());
assert_eq!(k.as_str(), "74{TAB}");
}
}