//! 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>, 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>, 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 { 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()); } }