237 lines
7.1 KiB
Rust
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(®istry, 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());
|
|
}
|
|
}
|