From 567ca341f703a69159849d4fdc949281e6fe0db7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 3 Apr 2026 22:14:37 -0700 Subject: [PATCH] feat: Forth-style prefix command parser Replace JSON command syntax with prefix notation: `word arg1 arg2`. Multiple commands per line separated by `.`. Coordinate pairs use `Category/Item`. Quoted strings for multi-word values. set-cell uses value-first: `set-cell 100 Region/East Measure/Revenue`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/command/mod.rs | 2 + src/command/parse.rs | 403 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 28 ++- 3 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 src/command/parse.rs diff --git a/src/command/mod.rs b/src/command/mod.rs index 0caea49..db2f734 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -6,7 +6,9 @@ //! App also calls dispatch() for every user action that mutates state. pub mod dispatch; +pub mod parse; pub mod types; pub use dispatch::dispatch; +pub use parse::parse_line; pub use types::{Command, CommandResult}; diff --git a/src/command/parse.rs b/src/command/parse.rs new file mode 100644 index 0000000..f8dc113 --- /dev/null +++ b/src/command/parse.rs @@ -0,0 +1,403 @@ +//! Forth-style 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 std::path::PathBuf; + +use super::types::{CellValueArg, Command}; +use crate::view::Axis; + +/// Parse a line into one or more commands. +/// Commands are separated by `.` on a single line. +pub fn parse_line(line: &str) -> Result, 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; + } + commands.push(parse_command(segment)?); + } + Ok(commands) +} + +/// Split a line on `.` separators, respecting quoted strings. +fn split_on_dot(line: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut start = 0; + let mut in_quote = false; + + for (i, c) in line.char_indices() { + match c { + '"' => in_quote = !in_quote, + '.' if !in_quote => { + 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 { + 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 +} + +/// Parse a single command from tokens. +fn parse_command(segment: &str) -> Result { + let tokens = tokenize(segment); + if tokens.is_empty() { + return Err("Empty command".to_string()); + } + + let word = tokens[0].as_str(); + let args = &tokens[1..]; + + match word { + "add-category" => { + require_args(word, args, 1)?; + Ok(Command::AddCategory { + name: args[0].clone(), + }) + } + + "add-item" => { + require_args(word, args, 2)?; + Ok(Command::AddItem { + category: args[0].clone(), + item: args[1].clone(), + }) + } + + "add-item-in-group" => { + require_args(word, args, 3)?; + Ok(Command::AddItemInGroup { + category: args[0].clone(), + item: args[1].clone(), + group: args[2].clone(), + }) + } + + "set-cell" => { + if args.len() < 2 { + return Err( + "set-cell requires a value and at least one Cat/Item coordinate".to_string() + ); + } + let value = parse_cell_value(&args[0])?; + let coords = parse_coords(&args[1..])?; + Ok(Command::SetCell { coords, value }) + } + + "clear-cell" => { + if args.is_empty() { + return Err("clear-cell requires at least one Cat/Item coordinate".to_string()); + } + let coords = parse_coords(args)?; + Ok(Command::ClearCell { coords }) + } + + "add-formula" => { + require_args(word, args, 2)?; + Ok(Command::AddFormula { + target_category: args[0].clone(), + raw: args[1].clone(), + }) + } + + "remove-formula" => { + require_args(word, args, 2)?; + Ok(Command::RemoveFormula { + target_category: args[0].clone(), + target: args[1].clone(), + }) + } + + "create-view" => { + require_args(word, args, 1)?; + Ok(Command::CreateView { + name: args[0].clone(), + }) + } + + "delete-view" => { + require_args(word, args, 1)?; + Ok(Command::DeleteView { + name: args[0].clone(), + }) + } + + "switch-view" => { + require_args(word, args, 1)?; + Ok(Command::SwitchView { + name: args[0].clone(), + }) + } + + "set-axis" => { + require_args(word, args, 2)?; + let axis = parse_axis(&args[1])?; + Ok(Command::SetAxis { + category: args[0].clone(), + axis, + }) + } + + "set-page" => { + require_args(word, args, 2)?; + Ok(Command::SetPageSelection { + category: args[0].clone(), + item: args[1].clone(), + }) + } + + "toggle-group" => { + require_args(word, args, 2)?; + Ok(Command::ToggleGroup { + category: args[0].clone(), + group: args[1].clone(), + }) + } + + "hide-item" => { + require_args(word, args, 2)?; + Ok(Command::HideItem { + category: args[0].clone(), + item: args[1].clone(), + }) + } + + "show-item" => { + require_args(word, args, 2)?; + Ok(Command::ShowItem { + category: args[0].clone(), + item: args[1].clone(), + }) + } + + "save" => { + require_args(word, args, 1)?; + Ok(Command::Save { + path: args[0].clone(), + }) + } + + "load" => { + require_args(word, args, 1)?; + Ok(Command::Load { + path: args[0].clone(), + }) + } + + "export-csv" => { + require_args(word, args, 1)?; + Ok(Command::ExportCsv { + path: args[0].clone(), + }) + } + + "import-json" => { + if args.is_empty() { + return Err("import-json requires a path".to_string()); + } + Ok(Command::ImportJson { + path: PathBuf::from(&args[0]), + model_name: args.get(1).cloned(), + array_path: args.get(2).cloned(), + }) + } + + _ => Err(format!("Unknown command: {word}")), + } +} + +fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> { + if args.len() < n { + Err(format!("{word} requires {n} argument(s), got {}", args.len())) + } else { + Ok(()) + } +} + +fn parse_coords(args: &[String]) -> Result, String> { + args.iter() + .map(|s| { + let (cat, item) = s + .split_once('/') + .ok_or_else(|| format!("Expected Cat/Item coordinate, got: {s}"))?; + Ok([cat.to_string(), item.to_string()]) + }) + .collect() +} + +fn parse_cell_value(s: &str) -> Result { + if let Ok(n) = s.parse::() { + Ok(CellValueArg::Number { number: n }) + } else { + Ok(CellValueArg::Text { + text: s.to_string(), + }) + } +} + +fn parse_axis(s: &str) -> Result { + match s.to_lowercase().as_str() { + "row" => Ok(Axis::Row), + "column" | "col" => Ok(Axis::Column), + "page" => Ok(Axis::Page), + "none" => Ok(Axis::None), + _ => Err(format!("Unknown axis: {s} (expected row, column, page, none)")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::types::CellValueArg; + + #[test] + fn parse_add_category() { + let cmds = parse_line("add-category Region").unwrap(); + assert_eq!(cmds.len(), 1); + assert!(matches!(&cmds[0], Command::AddCategory { name } if name == "Region")); + } + + #[test] + fn parse_add_item() { + let cmds = parse_line("add-item Region East").unwrap(); + assert!(matches!(&cmds[0], Command::AddItem { category, item } + if category == "Region" && item == "East")); + } + + #[test] + fn parse_set_cell_number() { + let cmds = parse_line("set-cell 100 Region/East Measure/Revenue").unwrap(); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + Command::SetCell { coords, value } => { + assert_eq!(coords.len(), 2); + assert_eq!(coords[0], ["Region", "East"]); + assert_eq!(coords[1], ["Measure", "Revenue"]); + assert!(matches!(value, CellValueArg::Number { number } if *number == 100.0)); + } + _ => panic!("Expected SetCell"), + } + } + + #[test] + fn parse_set_cell_text() { + let cmds = parse_line("set-cell hello Region/East").unwrap(); + match &cmds[0] { + Command::SetCell { value, .. } => { + assert!(matches!(value, CellValueArg::Text { text } if text == "hello")); + } + _ => panic!("Expected SetCell"), + } + } + + #[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!(matches!(&cmds[0], Command::AddCategory { .. })); + assert!(matches!(&cmds[1], Command::AddItem { .. })); + } + + #[test] + fn parse_quoted_string() { + let cmds = parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap(); + match &cmds[0] { + Command::AddFormula { + target_category, + raw, + } => { + assert_eq!(target_category, "Measure"); + assert_eq!(raw, "Profit = Revenue - Cost"); + } + _ => panic!("Expected AddFormula"), + } + } + + #[test] + fn parse_set_axis() { + let cmds = parse_line("set-axis Payee row").unwrap(); + assert!(matches!(&cmds[0], Command::SetAxis { category, axis } + if category == "Payee" && *axis == Axis::Row)); + } + + #[test] + fn parse_set_axis_none() { + let cmds = parse_line("set-axis Date none").unwrap(); + assert!(matches!(&cmds[0], Command::SetAxis { axis, .. } if *axis == Axis::None)); + } + + #[test] + fn parse_clear_cell() { + let cmds = parse_line("clear-cell Region/East Measure/Revenue").unwrap(); + match &cmds[0] { + Command::ClearCell { coords } => { + assert_eq!(coords.len(), 2); + } + _ => panic!("Expected ClearCell"), + } + } + + #[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()); + } +} diff --git a/src/main.rs b/src/main.rs index 180e462..1c2c397 100644 --- a/src/main.rs +++ b/src/main.rs @@ -320,21 +320,23 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> let mut model = get_initial_model(file)?; let mut exit_code = 0; - for raw_cmd in cmds { - let parsed: command::Command = match serde_json::from_str(raw_cmd) { - Ok(c) => c, + for line in cmds { + let parsed = match command::parse_line(line) { + Ok(cmds) => cmds, Err(e) => { - let r = CommandResult::err(format!("JSON parse error: {e}")); + let r = CommandResult::err(format!("Parse error: {e}")); println!("{}", serde_json::to_string(&r)?); exit_code = 1; continue; } }; - let result = command::dispatch(&mut model, &parsed); - if !result.ok { - exit_code = 1; + for cmd in &parsed { + let result = command::dispatch(&mut model, cmd); + if !result.ok { + exit_code = 1; + } + println!("{}", serde_json::to_string(&result)?); } - println!("{}", serde_json::to_string(&result)?); } if let Some(path) = file { @@ -346,14 +348,8 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> fn run_headless_script(script_path: &PathBuf, file: &Option) -> Result<()> { let content = std::fs::read_to_string(script_path)?; - let cmds: Vec = content - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty() && !l.starts_with("//") && !l.starts_with('#')) - .map(String::from) - .collect(); - - run_headless_commands(&cmds, file) + let lines: Vec = content.lines().map(String::from).collect(); + run_headless_commands(&lines, file) } // ── Helpers ──────────────────────────────────────────────────────────────────