refactor(command): update parsing to use registry-based system
Update the command parsing layer to use the new CmdRegistry: - parse_line() now uses default_registry() and returns Vec<Box<dyn Cmd>> - parse_line_with() accepts a registry parameter for custom registries - Tokenization replaced direct Command construction with registry.parse() - Updated tests to verify command names instead of struct fields - Removed parse_command() and helper functions (require_args, parse_coords, etc.) The parser now delegates command construction to the registry, which allows commands to be defined and registered in one place. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
@ -1,16 +1,12 @@
|
||||
//! Command layer — all model mutations go through this layer so they can be
|
||||
//! replayed, scripted, and tested without the TUI.
|
||||
//!
|
||||
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
|
||||
//! The headless CLI (--cmd / --script) routes through here, and the TUI
|
||||
//! App also calls dispatch() for every user action that mutates state.
|
||||
//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`).
|
||||
//! The headless CLI (--cmd / --script) parses Forth-style text into effects
|
||||
//! and applies them directly.
|
||||
|
||||
pub mod cmd;
|
||||
pub mod dispatch;
|
||||
pub mod keymap;
|
||||
pub mod parse;
|
||||
pub mod types;
|
||||
|
||||
pub use dispatch::dispatch;
|
||||
pub use parse::parse_line;
|
||||
pub use types::{Command, CommandResult};
|
||||
|
||||
@ -5,14 +5,16 @@
|
||||
//! Coordinate pairs use `/`: `Category/Item`
|
||||
//! Quoted strings supported: `"Profit = Revenue - Cost"`
|
||||
|
||||
use std::path::PathBuf;
|
||||
use super::cmd::{default_registry, Cmd, CmdRegistry};
|
||||
|
||||
use super::types::{CellValueArg, Command};
|
||||
use crate::view::Axis;
|
||||
/// 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 one or more commands.
|
||||
/// Commands are separated by `.` on a single line.
|
||||
pub fn parse_line(line: &str) -> Result<Vec<Command>, String> {
|
||||
/// 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![]);
|
||||
@ -24,7 +26,13 @@ pub fn parse_line(line: &str) -> Result<Vec<Command>, String> {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
commands.push(parse_command(segment)?);
|
||||
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)
|
||||
}
|
||||
@ -84,308 +92,71 @@ fn tokenize(input: &str) -> Vec<String> {
|
||||
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"));
|
||||
assert_eq!(cmds[0].name(), "add-category");
|
||||
}
|
||||
|
||||
#[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"));
|
||||
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);
|
||||
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"),
|
||||
}
|
||||
assert_eq!(cmds[0].name(), "set-cell");
|
||||
}
|
||||
|
||||
#[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"),
|
||||
}
|
||||
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!(matches!(&cmds[0], Command::AddCategory { .. }));
|
||||
assert!(matches!(&cmds[1], Command::AddItem { .. }));
|
||||
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();
|
||||
match &cmds[0] {
|
||||
Command::AddFormula {
|
||||
target_category,
|
||||
raw,
|
||||
} => {
|
||||
assert_eq!(target_category, "Measure");
|
||||
assert_eq!(raw, "Profit = Revenue - Cost");
|
||||
}
|
||||
_ => panic!("Expected AddFormula"),
|
||||
}
|
||||
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!(matches!(&cmds[0], Command::SetAxis { category, axis }
|
||||
if category == "Payee" && *axis == Axis::Row));
|
||||
assert_eq!(cmds[0].name(), "set-axis");
|
||||
}
|
||||
|
||||
#[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));
|
||||
assert_eq!(cmds[0].name(), "set-axis");
|
||||
}
|
||||
|
||||
#[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"),
|
||||
}
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].name(), "clear-cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user