Files
improvise/src/command/parse.rs
Edward Langley 6370f8b19f chore: format
2026-04-13 21:30:37 -07:00

237 lines
7.1 KiB
Rust

//! Quasi-lisp prefix command parser.
//!
//! Syntax: `word arg1 arg2 ...`
//! Multiple commands on one line separated by `.`
//! Coordinate pairs use `/`: `Category/Item`
//! Quoted strings supported: `"Profit = Revenue - Cost"`
use super::cmd::{Cmd, CmdRegistry, default_registry};
/// Parse a line into commands using the default registry.
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let registry = default_registry();
parse_line_with(&registry, line)
}
/// Parse a line into commands using a given registry.
pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
return Ok(vec![]);
}
let mut commands = Vec::new();
for segment in split_on_dot(line) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let tokens = tokenize(segment);
if tokens.is_empty() {
continue;
}
let word = &tokens[0];
let args = &tokens[1..];
commands.push(registry.parse(word, args)?);
}
Ok(commands)
}
/// Split a line on ` . ` separators (dot must be a standalone word,
/// surrounded by whitespace or at line boundaries). Respects quoted strings.
fn split_on_dot(line: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let mut in_quote = false;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
match c {
'"' => in_quote = !in_quote,
'.' if !in_quote => {
let before_ws = i == 0 || bytes[i - 1].is_ascii_whitespace();
let after_ws = i + 1 >= bytes.len() || bytes[i + 1].is_ascii_whitespace();
if before_ws && after_ws {
segments.push(&line[start..i]);
start = i + 1;
}
}
_ => {}
}
}
segments.push(&line[start..]);
segments
}
/// Tokenize a command segment into words, handling quoted strings.
fn tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
chars.next(); // consume opening quote
let mut s = String::new();
for ch in chars.by_ref() {
if ch == '"' {
break;
}
s.push(ch);
}
tokens.push(s);
} else {
let mut s = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
break;
}
s.push(ch);
chars.next();
}
tokens.push(s);
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_add_category() {
let cmds = parse_line("add-category Region").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-category");
}
#[test]
fn parse_add_item() {
let cmds = parse_line("add-item Region East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-item");
}
#[test]
fn parse_set_cell_number() {
let cmds = parse_line("set-cell 100 Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_set_cell_text() {
let cmds = parse_line("set-cell hello Region/East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_multiple_commands_dot_separated() {
let cmds = parse_line("add-category Region . add-item Region East").unwrap();
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name(), "add-category");
assert_eq!(cmds[1].name(), "add-item");
}
#[test]
fn parse_quoted_string() {
let cmds = parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-formula");
}
#[test]
fn parse_set_axis() {
let cmds = parse_line("set-axis Payee row").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_set_axis_none() {
let cmds = parse_line("set-axis Date none").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_clear_cell() {
let cmds = parse_line("clear-cell Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "clear-cell");
}
#[test]
fn parse_comments_and_blank_lines() {
assert!(parse_line("").unwrap().is_empty());
assert!(parse_line("# comment").unwrap().is_empty());
assert!(parse_line("// comment").unwrap().is_empty());
}
#[test]
fn parse_unknown_command_errors() {
assert!(parse_line("frobnicate foo").is_err());
}
#[test]
fn parse_missing_args_errors() {
assert!(parse_line("add-category").is_err());
assert!(parse_line("set-cell 100").is_err());
}
// ── Alias resolution ────────────────────────────────────────────────
#[test]
fn alias_add_cat_resolves_to_add_category() {
let cmds = parse_line("add-cat Region").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-category");
}
#[test]
fn alias_formula_resolves_to_add_formula() {
let cmds = parse_line(r#"formula Product "Total = A + B""#).unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-formula");
}
#[test]
fn alias_add_view_resolves_to_create_view() {
let cmds = parse_line("add-view MyView").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "create-view");
}
#[test]
fn alias_q_bang_resolves_to_force_quit() {
let cmds = parse_line("q!").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "force-quit");
}
#[test]
fn alias_does_not_interfere_with_canonical_q() {
let cmds = parse_line("q").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "q");
}
// ── add-items command ───────────────────────────────────────────────
#[test]
fn parse_add_items_multiple() {
let cmds = parse_line("add-items Region North South East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-items");
}
#[test]
fn add_items_requires_at_least_two_args() {
assert!(parse_line("add-items").is_err());
assert!(parse_line("add-items Region").is_err());
}
}