use std::collections::{HashMap, VecDeque}; use anyhow::{Result, bail}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::model::MacroDef; #[derive(Debug, Clone)] enum Token { Raw(char), Command { name: String, args: Vec }, } #[derive(Debug, Clone)] struct Program { name: String, tokens: Vec, labels: HashMap, } #[derive(Debug, Clone)] struct LoopState { variable: String, current: f64, final_value: f64, increment: f64, body: usize, end_label: String, } #[derive(Debug, Clone)] struct Frame { program: String, pc: usize, locals: HashMap, arg_count: usize, loops: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PromptKind { Text, Category, File, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MenuChoice { pub label: String, pub target: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MacroAction { Key(KeyEvent), Prompt { kind: PromptKind, prompt: String, variable: String, }, Menu { title: String, prompt: String, choices: Vec, }, Message { title: String, message: String, }, GetKey { variable: String, }, IfKey { target: String, }, Selection { prompt: String, }, Finished, } #[derive(Debug, Clone, Default)] pub struct MacroContext { pub date: String, pub time: String, pub filename: String, pub filepath: String, pub view_name: String, pub mode: String, pub highlight_type: String, pub highlight_value: String, pub mark_count: usize, pub marked_in_view: usize, pub clipboard: String, pub depth: usize, pub key_hit: bool, pub prompt: String, } pub struct MacroRuntime { programs: HashMap, frames: Vec, globals: HashMap, pending_keys: VecDeque, waiting: bool, steps: usize, error_handler: Option, break_handler: Option, last_error: Option, } impl MacroRuntime { pub fn new(entry: &str, macros: &[MacroDef], globals: HashMap) -> Result { let mut programs = HashMap::new(); for definition in macros { let program = parse(&definition.source)?; programs.insert(program.name.to_lowercase(), program); } let entry = entry.to_lowercase(); if !programs.contains_key(&entry) { bail!("macro “{entry}” does not exist") } Ok(Self { programs, frames: vec![Frame { program: entry, pc: 0, locals: HashMap::new(), arg_count: 0, loops: vec![], }], globals, pending_keys: VecDeque::new(), waiting: false, steps: 0, error_handler: None, break_handler: None, last_error: None, }) } pub fn globals(&self) -> &HashMap { &self.globals } pub fn resume_value(&mut self, variable: &str, value: String) -> Result<()> { self.set_variable(variable, value)?; self.waiting = false; Ok(()) } pub fn resume_menu(&mut self, target: &str) -> Result<()> { self.jump_to_label(target)?; self.waiting = false; Ok(()) } pub fn resume_message(&mut self) { self.waiting = false; } pub fn resume_if_key(&mut self, target: &str, key_hit: bool) -> Result<()> { if key_hit { self.jump_to_label(target)?; } self.waiting = false; Ok(()) } pub fn trigger_break(&mut self) -> Result { let Some(handler) = self.break_handler.clone() else { return Ok(false); }; self.push_handler(&handler)?; Ok(true) } pub fn next_action(&mut self, context: &MacroContext) -> Result> { if self.waiting { return Ok(None); } if let Some(key) = self.pending_keys.pop_front() { return Ok(Some(MacroAction::Key(key))); } loop { self.steps += 1; if self.steps > 100_000 { bail!("macro stopped after 100,000 instructions (possible infinite loop)") } let Some(frame) = self.frames.last_mut() else { return Ok(Some(MacroAction::Finished)); }; let program = self .programs .get(&frame.program) .ok_or_else(|| anyhow::anyhow!("macro program disappeared"))?; let Some(token) = program.tokens.get(frame.pc).cloned() else { self.frames.pop(); continue; }; frame.pc += 1; match token { Token::Raw(character) => { return Ok(Some(MacroAction::Key(KeyEvent::new( KeyCode::Char(character), KeyModifiers::NONE, )))); } Token::Command { name, args } => { match self.execute_command(&name, &args, context) { Ok(Some(action)) => return Ok(Some(action)), Ok(None) => {} Err(error) => { let Some(handler) = self.error_handler.clone() else { return Err(error); }; self.last_error = Some(format!("{error:#}")); self.push_handler(&handler)?; } } } } } } fn execute_command( &mut self, name: &str, args: &[String], context: &MacroContext, ) -> Result> { if let Some(key) = named_key(name) { let repeat = args .first() .map(|value| self.resolve(value, context)) .transpose()? .and_then(|value| value.parse::().ok()) .unwrap_or(1) .clamp(1, 10_000); self.pending_keys.extend(std::iter::repeat_n(key, repeat)); return Ok(self.pending_keys.pop_front().map(MacroAction::Key)); } match name { "COMMENT" | "DEBUGON" | "DEBUGOFF" | "WINDOWSOFF" | "WINDOWSON" | "WINDOWSUPD" | "SPEED" => {} "DEFSTR" => { for variable in args { self.set_variable(variable, String::new())?; } } "DEFINT" | "DEFFLOAT" => { for variable in args { self.set_variable(variable, "0".into())?; } } "LET" => { require_args(name, args, 2)?; let value = self.resolve_expression_or_text(&args[1], context)?; self.set_variable(&args[0], value)?; } "CLEAR" => { if args .first() .is_some_and(|value| value.eq_ignore_ascii_case("ALL")) { if let Some(frame) = self.frames.last_mut() { frame.locals.clear(); } self.globals.clear(); } else { for variable in args { let current = self.variable(variable, context).unwrap_or_default(); self.set_variable( variable, if current.parse::().is_ok() { "0".into() } else { String::new() }, )?; } } } "UNDEF" => { for variable in args { if variable.eq_ignore_ascii_case("ALL") { if let Some(frame) = self.frames.last_mut() { frame.locals.clear(); } self.globals.clear(); } else if variable.starts_with("%%") { self.globals.remove(&variable.to_uppercase()); } else if let Some(frame) = self.frames.last_mut() { frame.locals.remove(&variable.to_uppercase()); } } } "APPEND" => { require_args(name, args, 2)?; let mut value = self.variable(&args[0], context).unwrap_or_default(); value.push_str(&self.resolve(&args[1], context)?); self.set_variable(&args[0], value)?; } "LENGTH" => { require_args(name, args, 2)?; let length = self.resolve(&args[1], context)?.chars().count(); self.set_variable(&args[0], length.to_string())?; } "FIND" => { require_args(name, args, 4)?; let haystack = self.resolve(&args[1], context)?; let needle = self.resolve(&args[2], context)?; let start = self .resolve(&args[3], context)? .parse::() .unwrap_or(1); let found = haystack .get(start.saturating_sub(1)..) .and_then(|tail| tail.find(&needle)) .map_or(0, |position| position + start); self.set_variable(&args[0], found.to_string())?; } "LEFTSTR" | "RIGHTSTR" | "MIDSTR" => { let required = if name == "MIDSTR" { 4 } else { 3 }; require_args(name, args, required)?; let source = self.resolve(&args[1], context)?; let characters = source.chars().collect::>(); let (start, width) = if name == "MIDSTR" { ( self.resolve(&args[2], context)? .parse::() .unwrap_or(1) .saturating_sub(1), self.resolve(&args[3], context)? .parse::() .unwrap_or(0), ) } else { let width = self .resolve(&args[2], context)? .parse::() .unwrap_or(0); let start = if name == "RIGHTSTR" { characters.len().saturating_sub(width) } else { 0 }; (start, width) }; let result = characters .iter() .skip(start) .take(width) .collect::(); self.set_variable(&args[0], result)?; } "TYPE" => { require_args(name, args, 1)?; let value = self.resolve(&args[0], context)?; self.pending_keys.extend( value.chars().map(|character| { KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE) }), ); return Ok(self.pending_keys.pop_front().map(MacroAction::Key)); } "BRANCH" => { require_args(name, args, 1)?; self.jump_to_label(&args[0])?; } "IF" => { require_args(name, args, 2)?; if self.evaluate_condition(&args[0], context)? { self.jump_to_label(&args[1])?; } } "IFKEY" => { require_args(name, args, 1)?; self.waiting = true; return Ok(Some(MacroAction::IfKey { target: args[0].clone(), })); } "IFEQ" | "IFNOTEQ" => { require_args(name, args, 3)?; let equal = self.resolve(&args[0], context)? == self.resolve(&args[1], context)?; if equal == (name == "IFEQ") { self.jump_to_label(&args[2])?; } } "FOR" => { require_args(name, args, 5)?; let variable = args[0].clone(); let initial = self.evaluate_number(&args[1], context)?; let final_value = self.evaluate_number(&args[2], context)?; let increment = self.evaluate_number(&args[3], context)?; if increment == 0.0 { bail!("FOR increment cannot be zero") } self.set_variable(&variable, format_number(initial))?; let body = self.frames.last().map_or(0, |frame| frame.pc); self.frames .last_mut() .expect("frame exists") .loops .push(LoopState { variable, current: initial, final_value, increment, body, end_label: args[4].to_lowercase(), }); } "LABEL" => { require_args(name, args, 1)?; let label = args[0].to_lowercase(); let repeat = self .frames .last() .and_then(|frame| frame.loops.last()) .is_some_and(|loop_state| loop_state.end_label == label); if repeat { let (variable, value, body, keep_looping) = { let loop_state = self .frames .last_mut() .and_then(|frame| frame.loops.last_mut()) .expect("loop exists"); loop_state.current += loop_state.increment; let keep = if loop_state.increment > 0.0 { loop_state.current <= loop_state.final_value } else { loop_state.current >= loop_state.final_value }; ( loop_state.variable.clone(), format_number(loop_state.current), loop_state.body, keep, ) }; if keep_looping { self.set_variable(&variable, value)?; self.frames.last_mut().expect("frame exists").pc = body; } else { self.frames.last_mut().expect("frame exists").loops.pop(); } } } "CALL" => { require_args(name, args, 1)?; let target = self.resolve(&args[0], context)?.to_lowercase(); self.push_call(&target, &args[1..], context)?; } "GOTO" => { require_args(name, args, 1)?; let target = self.resolve(&args[0], context)?.to_lowercase(); self.frames.pop(); self.push_call(&target, &[], context)?; } "RETURN" => { self.frames.pop(); } "QUIT" => { if args .first() .is_some_and(|argument| argument.eq_ignore_ascii_case("ERROR")) { bail!("macro requested an error exit") } self.frames.clear(); } "INPUTTEXT" | "INPUTCAT" | "INPUTFILE" => { require_args(name, args, 2)?; let prompt = self.resolve(&args[0], context)?; self.waiting = true; return Ok(Some(MacroAction::Prompt { kind: match name { "INPUTCAT" => PromptKind::Category, "INPUTFILE" => PromptKind::File, _ => PromptKind::Text, }, prompt, variable: args[1].clone(), })); } "ALERT" | "LARGEBOX" => { require_args(name, args, usize::from(name == "ALERT") + 1)?; let (title, message) = if args.len() >= 2 { ( self.resolve(&args[0], context)?, self.resolve(&args[1], context)?, ) } else { ("Macro".into(), self.resolve(&args[0], context)?) }; self.waiting = true; return Ok(Some(MacroAction::Message { title, message })); } "GETKEY" => { require_args(name, args, 1)?; self.waiting = true; return Ok(Some(MacroAction::GetKey { variable: args[0].clone(), })); } "SELECTION" => { require_args(name, args, 1)?; return Ok(Some(MacroAction::Selection { prompt: self.resolve(&args[0], context)?, })); } "LOTUSMENU" => { require_args(name, args, 4)?; let count = self .resolve(&args[0], context)? .parse::() .unwrap_or(0); let choices = (0..count) .filter_map(|index| { let offset = 1 + index * 3; Some(MenuChoice { label: self.resolve(args.get(offset)?, context).ok()?, target: args.get(offset + 2)?.to_lowercase(), }) }) .collect::>(); self.waiting = true; return Ok(Some(MacroAction::Menu { title: "Macro Menu".into(), prompt: "Choose a command".into(), choices, })); } "ROLLMENU" => { require_args(name, args, 5)?; let count = self .resolve(&args[2], context)? .parse::() .unwrap_or(0); let choices = (0..count) .filter_map(|index| { let offset = 3 + index * 2; Some(MenuChoice { label: self.resolve(args.get(offset)?, context).ok()?, target: args.get(offset + 1)?.to_lowercase(), }) }) .collect::>(); self.waiting = true; return Ok(Some(MacroAction::Menu { title: self.resolve(&args[0], context)?, prompt: self.resolve(&args[1], context)?, choices, })); } "ONERROR" | "ONBREAK" => { require_args(name, args, 1)?; let handler = self.resolve(&args[0], context)?.to_lowercase(); if !self.programs.contains_key(&handler) { bail!("handler macro “{handler}” does not exist") } if name == "ONERROR" { self.error_handler = Some(handler); } else { self.break_handler = Some(handler); } } other => bail!("unknown macro command {{{other}}}"), } Ok(None) } fn push_call(&mut self, target: &str, args: &[String], context: &MacroContext) -> Result<()> { if self.frames.len() >= 64 { bail!("macro call depth exceeded 64") } if !self.programs.contains_key(target) { bail!("called macro “{target}” does not exist") } let mut locals = HashMap::new(); for (index, argument) in args.iter().enumerate() { locals.insert(format!("%{}", index + 1), self.resolve(argument, context)?); } self.frames.push(Frame { program: target.into(), pc: 0, locals, arg_count: args.len(), loops: vec![], }); Ok(()) } fn push_handler(&mut self, target: &str) -> Result<()> { if self.frames.len() >= 64 { bail!("macro call depth exceeded 64") } if !self.programs.contains_key(target) { bail!("handler macro “{target}” does not exist") } self.frames.push(Frame { program: target.into(), pc: 0, locals: HashMap::new(), arg_count: 0, loops: vec![], }); Ok(()) } fn jump_to_label(&mut self, label: &str) -> Result<()> { let frame = self .frames .last_mut() .ok_or_else(|| anyhow::anyhow!("macro has no active frame"))?; let program = self .programs .get(&frame.program) .ok_or_else(|| anyhow::anyhow!("macro program disappeared"))?; frame.pc = *program .labels .get(&label.to_lowercase()) .ok_or_else(|| anyhow::anyhow!("label “{label}” does not exist"))?; Ok(()) } fn set_variable(&mut self, name: &str, value: String) -> Result<()> { let name = name.trim().to_uppercase(); if name.starts_with("%%") { self.globals.insert(name, value); } else if name.starts_with('%') { self.frames .last_mut() .ok_or_else(|| anyhow::anyhow!("macro has no active frame"))? .locals .insert(name, value); } else { bail!("macro variable “{name}” must begin with % or %%") } Ok(()) } fn variable(&self, name: &str, context: &MacroContext) -> Option { let name = name.trim().to_uppercase(); if name.starts_with("%%") { self.globals.get(&name).cloned() } else if name.starts_with('%') { self.frames.last()?.locals.get(&name).cloned() } else if name.starts_with('#') { self.special_variable(&name, context) } else { None } } fn special_variable(&self, name: &str, context: &MacroContext) -> Option { Some(match name { "#ARGCOUNT" => self.frames.last()?.arg_count.to_string(), "#CLIPBOARD" => context.clipboard.clone(), "#DATE" => context.date.clone(), "#DEPTH" => context.depth.to_string(), "#ERRMSG" => self.last_error.clone().unwrap_or_default(), "#ERRNO" => usize::from(self.last_error.is_some()).to_string(), "#FALSE" => "0".into(), "#FILENAME" => context.filename.clone(), "#FILEPATH" => context.filepath.clone(), "#HIGHLIGHT_TYPE" => context.highlight_type.clone(), "#HIGHLIGHT_VALUE" => context.highlight_value.clone(), "#KEYHIT" => usize::from(context.key_hit).to_string(), "#MARK_COUNT" => context.mark_count.to_string(), "#MARKED_IN_VIEW" => context.marked_in_view.to_string(), "#MODE" => context.mode.clone(), "#NULL" => String::new(), "#PROMPT" => context.prompt.clone(), "#TIME" => context.time.clone(), "#TRUE" => "1".into(), "#VIEWNAME" => context.view_name.clone(), value if value.starts_with("#ASCII(") && value.ends_with(')') => { let number = value[7..value.len() - 1].parse::().ok()?; char::from(number).to_string() } _ => return None, }) } fn resolve(&self, value: &str, context: &MacroContext) -> Result { let value = value.trim(); if (value.starts_with('%') || value.starts_with('#')) && !value.contains(char::is_whitespace) { return self .variable(value, context) .ok_or_else(|| anyhow::anyhow!("undefined macro variable “{value}”")); } Ok(value.trim_matches('"').to_owned()) } fn resolve_expression_or_text(&self, value: &str, context: &MacroContext) -> Result { if let Ok(number) = self.evaluate_number(value, context) { Ok(format_number(number)) } else { self.resolve(value, context) } } fn evaluate_condition(&self, expression: &str, context: &MacroContext) -> Result { let expression = expression .trim() .strip_prefix('(') .and_then(|value| value.strip_suffix(')')) .unwrap_or(expression.trim()); for operator in ["<=", ">=", "<>", "=", "<", ">"] { if let Some((left, right)) = expression.split_once(operator) { let left_text = self.resolve_expression_or_text(left, context)?; let right_text = self.resolve_expression_or_text(right, context)?; let numeric = left_text .parse::() .ok() .zip(right_text.parse::().ok()); return Ok(match (operator, numeric) { ("<=", Some((left, right))) => left <= right, (">=", Some((left, right))) => left >= right, ("<", Some((left, right))) => left < right, (">", Some((left, right))) => left > right, ("=", Some((left, right))) => (left - right).abs() < f64::EPSILON, ("<>", Some((left, right))) => (left - right).abs() >= f64::EPSILON, ("=", None) => left_text.eq_ignore_ascii_case(&right_text), ("<>", None) => !left_text.eq_ignore_ascii_case(&right_text), ("<", None) => left_text < right_text, (">", None) => left_text > right_text, ("<=", None) => left_text <= right_text, (">=", None) => left_text >= right_text, _ => false, }); } } Ok(self.evaluate_number(expression, context)? != 0.0) } fn evaluate_number(&self, expression: &str, context: &MacroContext) -> Result { NumericParser::new(expression, |name| { self.variable(name, context) .and_then(|value| value.parse::().ok()) }) .parse() } } fn parse(source: &str) -> Result { let mut tokens = Vec::new(); let mut characters = source.chars().peekable(); let mut line_start = true; while let Some(character) = characters.next() { if matches!(character, '\r' | '\n') { line_start = true; continue; } if line_start && matches!(character, ' ' | '\t') { continue; } line_start = false; if character == '{' { let mut contents = String::new(); let mut closed = false; for next in characters.by_ref() { if next == '}' { closed = true; break; } contents.push(next); } if !closed { bail!("unterminated macro command") } let mut parts = contents.split(';').map(|part| part.trim().to_owned()); let name = parts.next().unwrap_or_default().to_uppercase(); tokens.push(Token::Command { name, args: parts.collect(), }); } else { tokens.push(Token::Raw(character)); } } let Some(Token::Command { name, args }) = tokens.first() else { bail!("macro source must start with {{macroname}}") }; if !args.is_empty() || name.is_empty() { bail!("first macro entry must be {{macroname}}") } let macro_name = name.to_lowercase(); tokens.remove(0); let labels = tokens .iter() .enumerate() .filter_map(|(index, token)| match token { Token::Command { name, args } if name == "LABEL" => { args.first().map(|label| (label.to_lowercase(), index + 1)) } _ => None, }) .collect(); Ok(Program { name: macro_name, tokens, labels, }) } pub fn macro_name(source: &str) -> Result { Ok(parse(source)?.name) } pub fn parse_key_binding(binding: &str) -> Result { let binding = binding.trim(); let contents = binding .strip_prefix('{') .and_then(|value| value.strip_suffix('}')) .unwrap_or(binding); named_key(&contents.to_uppercase()) .ok_or_else(|| anyhow::anyhow!("unsupported macro key binding “{binding}”")) } pub fn key_to_source(key: KeyEvent) -> String { if let KeyCode::Char(character) = key.code { if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT { return character.to_string(); } if key.modifiers.contains(KeyModifiers::CONTROL) { return format!("{{Ctl{}}}", character.to_ascii_uppercase()); } if key.modifiers.contains(KeyModifiers::ALT) { return format!("{{Alt{}}}", character.to_ascii_uppercase()); } } let modifier = if key.modifiers.contains(KeyModifiers::CONTROL) { "Ctl" } else if key.modifiers.contains(KeyModifiers::ALT) { "Alt" } else if key.modifiers.contains(KeyModifiers::SHIFT) { "Shf" } else { "" }; let name = match key.code { KeyCode::Enter => "ENTER".into(), KeyCode::Esc => "ESC".into(), KeyCode::Backspace => "BS".into(), KeyCode::Tab => "TAB".into(), KeyCode::BackTab => "ShfTAB".into(), KeyCode::Up => "UP".into(), KeyCode::Down => "DOWN".into(), KeyCode::Left => "LEFT".into(), KeyCode::Right => "RIGHT".into(), KeyCode::Home => "HOME".into(), KeyCode::End => "END".into(), KeyCode::PageUp => "PGUP".into(), KeyCode::PageDown => "PGDN".into(), KeyCode::Insert => "INS".into(), KeyCode::Delete => "DEL".into(), KeyCode::F(number) => format!("{modifier}F{number}"), _ => "UNKNOWN".into(), }; format!("{{{name}}}") } fn named_key(name: &str) -> Option { let upper = name.to_uppercase(); if upper == "SHFTAB" { return Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); } let (modifiers, base) = if let Some(base) = upper.strip_prefix("CTL") { (KeyModifiers::CONTROL, base) } else if let Some(base) = upper.strip_prefix("ALT") { (KeyModifiers::ALT, base) } else if let Some(base) = upper.strip_prefix("SHF") { (KeyModifiers::SHIFT, base) } else { (KeyModifiers::NONE, upper.as_str()) }; let code = match base { "ENTER" => KeyCode::Enter, "ESC" => KeyCode::Esc, "BS" => KeyCode::Backspace, "TAB" => KeyCode::Tab, "UP" => KeyCode::Up, "DOWN" => KeyCode::Down, "LEFT" => KeyCode::Left, "RIGHT" => KeyCode::Right, "HOME" => KeyCode::Home, "END" => KeyCode::End, "PGUP" => KeyCode::PageUp, "PGDN" => KeyCode::PageDown, "INS" => KeyCode::Insert, "DEL" => KeyCode::Delete, value if value.starts_with('F') => KeyCode::F(value[1..].parse().ok()?), value if value.chars().count() == 1 => { KeyCode::Char(value.chars().next()?.to_ascii_lowercase()) } _ => return None, }; Some(KeyEvent::new(code, modifiers)) } fn require_args(command: &str, args: &[String], count: usize) -> Result<()> { if args.len() < count { bail!("{{{command}}} requires at least {count} argument(s)") } Ok(()) } fn format_number(value: f64) -> String { if value.fract().abs() < f64::EPSILON { format!("{value:.0}") } else { value.to_string() } } struct NumericParser<'a, F> { input: &'a [u8], position: usize, variable: F, } impl<'a, F: Fn(&str) -> Option> NumericParser<'a, F> { fn new(input: &'a str, variable: F) -> Self { Self { input: input.as_bytes(), position: 0, variable, } } fn parse(mut self) -> Result { let value = self.expression()?; self.whitespace(); if self.position != self.input.len() { bail!("invalid numeric expression") } Ok(value) } fn expression(&mut self) -> Result { let mut value = self.term()?; loop { self.whitespace(); if self.consume(b'+') { value += self.term()?; } else if self.consume(b'-') { value -= self.term()?; } else { return Ok(value); } } } fn term(&mut self) -> Result { let mut value = self.factor()?; loop { self.whitespace(); if self.consume(b'*') { value *= self.factor()?; } else if self.consume(b'/') { value /= self.factor()?; } else { return Ok(value); } } } fn factor(&mut self) -> Result { self.whitespace(); if self.consume(b'(') { let value = self.expression()?; self.whitespace(); if !self.consume(b')') { bail!("missing closing parenthesis") } return Ok(value); } let negative = self.consume(b'-'); let start = self.position; while self.position < self.input.len() && (self.input[self.position].is_ascii_alphanumeric() || matches!(self.input[self.position], b'%' | b'.' | b'_')) { self.position += 1; } if start == self.position { bail!("expected number or variable") } let token = std::str::from_utf8(&self.input[start..self.position])?; let value = token .parse::() .ok() .or_else(|| (self.variable)(token)) .ok_or_else(|| anyhow::anyhow!("“{token}” is not numeric"))?; Ok(if negative { -value } else { value }) } fn whitespace(&mut self) { while self .input .get(self.position) .is_some_and(u8::is_ascii_whitespace) { self.position += 1; } } fn consume(&mut self, expected: u8) -> bool { if self.input.get(self.position) == Some(&expected) { self.position += 1; true } else { false } } } #[cfg(test)] mod tests { use super::*; fn definition(source: &str) -> MacroDef { MacroDef { id: 1, name: macro_name(source).unwrap(), source: source.into(), key_binding: String::new(), } } #[test] fn parses_and_runs_the_original_search_example() { let source = "{searchmac}\n{inputtext;Enter text to search for;%search}\n\ {altf6}\n{type;%search}\n{enter;2}"; let mut runtime = MacroRuntime::new("searchmac", &[definition(source)], HashMap::new()).unwrap(); let context = MacroContext::default(); let Some(MacroAction::Prompt { variable, .. }) = runtime.next_action(&context).unwrap() else { panic!("expected prompt"); }; runtime.resume_value(&variable, "quarterly".into()).unwrap(); assert_eq!( runtime.next_action(&context).unwrap(), Some(MacroAction::Key(KeyEvent::new( KeyCode::F(6), KeyModifiers::ALT ))) ); let mut typed = String::new(); for _ in 0..9 { let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected typed key"); }; if let KeyCode::Char(character) = key.code { typed.push(character); } } assert_eq!(typed, "quarterly"); } #[test] fn supports_variables_arithmetic_loops_and_branches() { let source = "{math}{defint;%i;%sum}{let;%sum;0}{for;%i;1;3;1;done}\ {let;%sum;(%sum+%i)}{label;done}{if;(%sum=6);good}\ x{quit}{label;good}y"; let mut runtime = MacroRuntime::new("math", &[definition(source)], HashMap::new()).unwrap(); let context = MacroContext::default(); let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected result key"); }; assert_eq!(key.code, KeyCode::Char('y')); } #[test] fn records_and_parses_key_bindings_in_agenda_notation() { let key = KeyEvent::new(KeyCode::F(3), KeyModifiers::ALT); assert_eq!(key_to_source(key), "{AltF3}"); assert_eq!( parse_key_binding("{CtlA}").unwrap().code, KeyCode::Char('a') ); assert_eq!( parse_key_binding("{CtlA}").unwrap().modifiers, KeyModifiers::CONTROL ); assert_eq!( parse_key_binding("{CtlPgUp}").unwrap(), KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL) ); assert_eq!( parse_key_binding("{ShfTab}").unwrap(), KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT) ); } #[test] fn supports_ifkey_special_ascii_and_setting_selection() { let source = "{control}{ifkey;pressed}x{quit}{label;pressed}\ {type;#ascii(65)}{selection;Priority}"; let mut runtime = MacroRuntime::new("control", &[definition(source)], HashMap::new()).unwrap(); let context = MacroContext::default(); let Some(MacroAction::IfKey { target }) = runtime.next_action(&context).unwrap() else { panic!("expected IFKEY check"); }; runtime.resume_if_key(&target, true).unwrap(); let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected #ASCII output"); }; assert_eq!(key.code, KeyCode::Char('A')); assert_eq!( runtime.next_action(&context).unwrap(), Some(MacroAction::Selection { prompt: "Priority".into() }) ); } #[test] fn onerror_and_onbreak_invoke_handler_macros() { let main = definition("{main}{onerror;handler}{unknown}x"); let mut handler = definition("{handler}y{return}"); handler.id = 2; let context = MacroContext::default(); let mut runtime = MacroRuntime::new("main", &[main.clone(), handler.clone()], HashMap::new()).unwrap(); let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected error handler output"); }; assert_eq!(key.code, KeyCode::Char('y')); let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected main macro to resume"); }; assert_eq!(key.code, KeyCode::Char('x')); let mut break_main = definition("{breakmain}{onbreak;handler}x"); break_main.id = 3; let mut runtime = MacroRuntime::new("breakmain", &[break_main, handler], HashMap::new()).unwrap(); assert_eq!( runtime.next_action(&context).unwrap().unwrap(), MacroAction::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) ); assert!(runtime.trigger_break().unwrap()); let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { panic!("expected break handler output"); }; assert_eq!(key.code, KeyCode::Char('y')); } }