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) <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,9 @@
|
|||||||
//! App also calls dispatch() for every user action that mutates state.
|
//! App also calls dispatch() for every user action that mutates state.
|
||||||
|
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
|
pub mod parse;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use dispatch::dispatch;
|
pub use dispatch::dispatch;
|
||||||
|
pub use parse::parse_line;
|
||||||
pub use types::{Command, CommandResult};
|
pub use types::{Command, CommandResult};
|
||||||
|
|||||||
403
src/command/parse.rs
Normal file
403
src/command/parse.rs
Normal file
@ -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<Vec<Command>, 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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single command from tokens.
|
||||||
|
fn parse_command(segment: &str) -> Result<Command, String> {
|
||||||
|
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<Vec<[String; 2]>, 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<CellValueArg, String> {
|
||||||
|
if let Ok(n) = s.parse::<f64>() {
|
||||||
|
Ok(CellValueArg::Number { number: n })
|
||||||
|
} else {
|
||||||
|
Ok(CellValueArg::Text {
|
||||||
|
text: s.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_axis(s: &str) -> Result<Axis, String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main.rs
28
src/main.rs
@ -320,21 +320,23 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
|
|||||||
let mut model = get_initial_model(file)?;
|
let mut model = get_initial_model(file)?;
|
||||||
let mut exit_code = 0;
|
let mut exit_code = 0;
|
||||||
|
|
||||||
for raw_cmd in cmds {
|
for line in cmds {
|
||||||
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
|
let parsed = match command::parse_line(line) {
|
||||||
Ok(c) => c,
|
Ok(cmds) => cmds,
|
||||||
Err(e) => {
|
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)?);
|
println!("{}", serde_json::to_string(&r)?);
|
||||||
exit_code = 1;
|
exit_code = 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let result = command::dispatch(&mut model, &parsed);
|
for cmd in &parsed {
|
||||||
if !result.ok {
|
let result = command::dispatch(&mut model, cmd);
|
||||||
exit_code = 1;
|
if !result.ok {
|
||||||
|
exit_code = 1;
|
||||||
|
}
|
||||||
|
println!("{}", serde_json::to_string(&result)?);
|
||||||
}
|
}
|
||||||
println!("{}", serde_json::to_string(&result)?);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path) = file {
|
if let Some(path) = file {
|
||||||
@ -346,14 +348,8 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
|
|||||||
|
|
||||||
fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<()> {
|
fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<()> {
|
||||||
let content = std::fs::read_to_string(script_path)?;
|
let content = std::fs::read_to_string(script_path)?;
|
||||||
let cmds: Vec<String> = content
|
let lines: Vec<String> = content.lines().map(String::from).collect();
|
||||||
.lines()
|
run_headless_commands(&lines, file)
|
||||||
.map(|l| l.trim())
|
|
||||||
.filter(|l| !l.is_empty() && !l.starts_with("//") && !l.starts_with('#'))
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
run_headless_commands(&cmds, file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user