1158 lines
40 KiB
Rust
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'));
|
|
}
|
|
}
|