Files
RogueAgenda/src/macro_lang.rs
2026-08-16 21:39:18 +00:00

1158 lines
40 KiB
Rust

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<String> },
}
#[derive(Debug, Clone)]
struct Program {
name: String,
tokens: Vec<Token>,
labels: HashMap<String, usize>,
}
#[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<String, String>,
arg_count: usize,
loops: Vec<LoopState>,
}
#[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<MenuChoice>,
},
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<String, Program>,
frames: Vec<Frame>,
globals: HashMap<String, String>,
pending_keys: VecDeque<KeyEvent>,
waiting: bool,
steps: usize,
error_handler: Option<String>,
break_handler: Option<String>,
last_error: Option<String>,
}
impl MacroRuntime {
pub fn new(entry: &str, macros: &[MacroDef], globals: HashMap<String, String>) -> Result<Self> {
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<String, String> {
&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<bool> {
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<Option<MacroAction>> {
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<Option<MacroAction>> {
if let Some(key) = named_key(name) {
let repeat = args
.first()
.map(|value| self.resolve(value, context))
.transpose()?
.and_then(|value| value.parse::<usize>().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::<f64>().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::<usize>()
.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::<Vec<_>>();
let (start, width) = if name == "MIDSTR" {
(
self.resolve(&args[2], context)?
.parse::<usize>()
.unwrap_or(1)
.saturating_sub(1),
self.resolve(&args[3], context)?
.parse::<usize>()
.unwrap_or(0),
)
} else {
let width = self
.resolve(&args[2], context)?
.parse::<usize>()
.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::<String>();
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::<usize>()
.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::<Vec<_>>();
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::<usize>()
.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::<Vec<_>>();
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<String> {
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<String> {
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::<u8>().ok()?;
char::from(number).to_string()
}
_ => return None,
})
}
fn resolve(&self, value: &str, context: &MacroContext) -> Result<String> {
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<String> {
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<bool> {
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::<f64>()
.ok()
.zip(right_text.parse::<f64>().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<f64> {
NumericParser::new(expression, |name| {
self.variable(name, context)
.and_then(|value| value.parse::<f64>().ok())
})
.parse()
}
}
fn parse(source: &str) -> Result<Program> {
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<String> {
Ok(parse(source)?.name)
}
pub fn parse_key_binding(binding: &str) -> Result<KeyEvent> {
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<KeyEvent> {
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<f64>> NumericParser<'a, F> {
fn new(input: &'a str, variable: F) -> Self {
Self {
input: input.as_bytes(),
position: 0,
variable,
}
}
fn parse(mut self) -> Result<f64> {
let value = self.expression()?;
self.whitespace();
if self.position != self.input.len() {
bail!("invalid numeric expression")
}
Ok(value)
}
fn expression(&mut self) -> Result<f64> {
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<f64> {
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<f64> {
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::<f64>()
.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'));
}
}