Files
improvise/crates/improvise-command/src/command/parse.rs
T
Claude (cmdlang) 8fa504fbdc Merge gh/main into fix/31oe-cmdlang; renumber cli.md §13/§14 to §14/§15
#321 added its own cli.md section 13, so this PR's sections become 14 (pipe
quoting, surplus arguments) and 15 (names the document lacks), with every
citation renumbered. The stale descriptions of the deleted unknown_* helpers in
cli.md, command.md and the test evidence now describe RequireKnown, and section
15 says a refused name stops the rest of a `:` line but not a headless one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-02 22:00:38 -07:00

474 lines
16 KiB
Rust

//! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands,
//! `Category/Item` coordinate pairs, `"quoted strings"` and `|pipe-quoted names|`.
use std::iter::Peekable;
use std::str::CharIndices;
use improvise_formula::quote::unescape_pipe_body;
use super::cmd::{Cmd, CmdContext, 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(&registry, 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> {
groups(line.trim())?
.iter()
.map(|words| registry.parse(&words[0].text, &texts(&words[1..])))
.collect()
}
/// The command segments of a line, with comments and blank segments dropped.
pub fn segments(line: &str) -> Result<Vec<&str>, String> {
let line = line.trim();
Ok(groups(line)?
.iter()
.map(|words| &line[words[0].start..words[words.len() - 1].end])
.collect())
}
/// Build one segment's command against a LIVE context — `context/code/cli.md`.
pub fn parse_segment_interactive(
segment: &str,
ctx: &CmdContext,
) -> Result<Option<Box<dyn Cmd>>, String> {
let tokens = texts(&lex(segment.trim())?);
let Some(word) = tokens.first() else {
return Ok(None);
};
ctx.registry
.interactive(word, &tokens[1..], ctx)
.map(Some)
.map_err(|err| {
if err.starts_with("Unknown command") {
err
} else {
format!("{word}: {err}")
}
})
}
/// One word of a line and the byte span it was read from. A `separator` is a
/// bare `.`; a quoted one is an argument like any other.
struct Word {
text: String,
start: usize,
end: usize,
separator: bool,
}
fn texts(words: &[Word]) -> Vec<String> {
words.iter().map(|w| w.text.clone()).collect()
}
/// The words of a trimmed line, split at separators, blank commands and
/// comment lines dropped.
fn groups(line: &str) -> Result<Vec<Vec<Word>>, String> {
if line.starts_with('#') || line.starts_with("//") {
return Ok(Vec::new());
}
let mut groups: Vec<Vec<Word>> = vec![Vec::new()];
for word in lex(line)? {
match word.separator {
true => groups.push(Vec::new()),
false => groups.last_mut().expect("never empty").push(word),
}
}
groups.retain(|g| !g.is_empty());
Ok(groups)
}
/// Split `line` into words. The quoting rules: `context/code/cli.md` §14.
fn lex(line: &str) -> Result<Vec<Word>, String> {
let mut words = Vec::new();
let mut chars = line.char_indices().peekable();
while let Some(&(start, c)) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
let (text, bare) = match c {
'"' => (double_quoted(&mut chars), false),
_ => bare_word(&mut chars, line)?,
};
let end = chars.peek().map_or(line.len(), |&(i, _)| i);
words.push(Word {
separator: bare && text == ".",
text,
start,
end,
});
}
Ok(words)
}
/// `"…"` read verbatim: no escapes, and a `|` inside stays a `|`.
fn double_quoted(chars: &mut Peekable<CharIndices>) -> String {
chars.next();
chars
.by_ref()
.map(|(_, c)| c)
.take_while(|&c| c != '"')
.collect()
}
/// Text up to whitespace, each `|…|` piece unquoted in place; `bare` is false
/// once a piece was quoted, so a quoted `|.|` is an argument and not a separator.
fn bare_word(chars: &mut Peekable<CharIndices>, line: &str) -> Result<(String, bool), String> {
let mut text = String::new();
let mut bare = true;
while let Some((at, c)) = chars.next_if(|&(_, c)| !c.is_whitespace()) {
match c {
'|' => {
text.push_str(&pipe_body(chars, line, at)?);
bare = false;
}
_ => text.push(c),
}
}
Ok((text, bare))
}
/// The body of the `|…|` opened at `open`, escapes applied by the one shared
/// reader (gotcha 34).
fn pipe_body(chars: &mut Peekable<CharIndices>, line: &str, open: usize) -> Result<String, String> {
let mut body = String::new();
while let Some((_, c)) = chars.next() {
match c {
'|' => return Ok(unescape_pipe_body(&body)),
'\\' => body.extend(std::iter::once(c).chain(chars.next().map(|(_, e)| e))),
_ => body.push(c),
}
}
Err(format!("unterminated |…| in `{}`", &line[open..]))
}
#[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());
}
// ── a segment is built against the cursor it will run against ────────
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_segment_is_built_against_the_cursor_it_will_run_against() {
use crate::command::cmd::test_helpers::*;
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.selected = (1, 0);
assert!(ctx.row_count() >= 2 && ctx.col_count() >= 2);
let built = parse_segment_interactive("move-selection 0 1", &ctx)
.expect("move-selection parses")
.expect("a segment with a word builds a command");
let dbg = format!("{:?}", built.execute(&ctx));
assert!(
dbg.contains("SetSelected(1, 1)"),
"a segment built against the live cursor must move FROM it, not \
from the origin (improvise-6uva); got: {dbg}"
);
// The pure arm is what a script used to go through, and it is what the
// interactive arm must not be mistaken for.
let pure = reg
.parse("move-selection", &["0".to_string(), "1".to_string()])
.expect("the pure arm parses too");
assert!(
format!("{:?}", pure.execute(&ctx)).contains("SetSelected(0, 0)"),
"if the pure arm ever learns the cursor, the assertion above stops \
discriminating"
);
}
#[test]
fn segments_drops_comments_and_blanks() {
assert_eq!(segments("# a comment"), Ok(Vec::<&str>::new()));
assert_eq!(segments("// also a comment"), Ok(Vec::<&str>::new()));
assert_eq!(segments(" "), Ok(Vec::<&str>::new()));
assert_eq!(segments("a . . b"), Ok(vec!["a", "b"]));
assert_eq!(
segments("move-selection 2 0"),
Ok(vec!["move-selection 2 0"])
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_pure_commands_two_arms_agree_on_no_arguments() {
use crate::command::cmd::test_helpers::*;
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
// `w` with no path saves to the current document. The interactive arm
// used to refuse exactly that, which is the same demotion one level down.
let by_parse = reg
.parse("w", &[])
.expect("the pure arm accepts a bare `w`");
let by_ctx = parse_segment_interactive("w", &ctx)
.expect("so must the interactive arm")
.expect("a segment with a word builds a command");
assert_eq!(by_parse.name(), by_ctx.name());
assert_eq!(
format!("{:?}", by_parse.execute(&ctx)),
format!("{:?}", by_ctx.execute(&ctx)),
"the two arms of a pure command must build the same thing"
);
}
#[test]
fn an_unknown_segment_is_an_error_not_a_silent_skip() {
use crate::command::cmd::test_helpers::*;
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let err = parse_segment_interactive("no-such-command", &ctx)
.expect_err("an unknown word is refused");
assert!(err.contains("no-such-command"), "got: {err}");
}
// ── pipe quoting and arity (improvise-31oe.3) ─────────────────────────
/// The one command `line` builds, as its `Debug` shows it.
fn built(line: &str) -> String {
let cmds = parse_line(line).unwrap_or_else(|e| panic!("`{line}` must parse: {e}"));
assert_eq!(cmds.len(), 1, "`{line}` is one command");
format!("{:?}", cmds[0])
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_pipe_quoted_argument_is_one_argument_without_its_pipes() {
assert_eq!(
built("create-view |By area|"),
r#"CreateViewCmd(["By area"])"#
);
assert_eq!(
built("set-page |Sales Region| |East Coast|"),
r#"SetPageCmd(["Sales Region", "East Coast"])"#
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_pipe_quoted_argument_reads_the_escapes_improv_does() {
assert_eq!(built(r"create-view |a\|b|"), r#"CreateViewCmd(["a|b"])"#);
assert_eq!(
built(r"create-view |back\\slash|"),
r#"CreateViewCmd(["back\\slash"])"#
);
assert_eq!(
built(r"create-view |two\nlines|"),
r#"CreateViewCmd(["two\nlines"])"#
);
}
/// A coordinate quotes each half, as a `.improv` data line does.
#[test]
fn a_coordinate_may_pipe_quote_either_half() {
assert_eq!(
built("set-cell 5 |Sales Region|/|East Coast| Measure/Revenue"),
r#"SetCellCmd(["5", "Sales Region/East Coast", "Measure/Revenue"])"#
);
}
#[test]
fn a_dot_inside_pipes_does_not_split_the_line() {
assert_eq!(built("create-view |a . b|"), r#"CreateViewCmd(["a . b"])"#);
assert_eq!(
segments("create-view |a . b|"),
Ok(vec!["create-view |a . b|"])
);
}
/// Double quotes stay verbatim: formula text carries its own pipes.
#[test]
fn a_double_quoted_argument_keeps_the_pipes_inside_it() {
assert_eq!(
built(r#"add-formula Measure "Net = |Gross Rev| - Cost""#),
r#"AddFormulaCmd(["Measure", "Net = |Gross Rev| - Cost"])"#
);
}
/// `context/code/cli.md` §14: only a BARE `.` separates commands. A quoted one
/// is an argument, so `|.|` and `"."` name a thing called `.`.
#[test]
fn a_quoted_dot_is_an_argument_not_a_separator() {
for line in ["create-view |.|", "create-view \".\""] {
let groups = groups(line).expect("lexes");
assert_eq!(groups.len(), 1, "`{line}` split into commands");
assert_eq!(texts(&groups[0]), ["create-view", "."], "`{line}`");
}
assert_eq!(groups("create-view A . create-view B").unwrap().len(), 2);
}
#[test]
fn an_unterminated_pipe_is_refused() {
let err = parse_line("create-view |By area").expect_err("an open pipe is refused");
assert!(
err.contains("|By area"),
"the refusal names the text: {err}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_command_given_more_arguments_than_it_takes_is_refused() {
let err = parse_line("create-view By area").expect_err("two words for one argument");
assert!(err.starts_with("create-view:"), "names the command: {err}");
assert!(err.contains("\"area\""), "names the extra argument: {err}");
let err = parse_line("set-axis Type row sideways").expect_err("a third argument");
assert!(
err.contains("\"sideways\""),
"names the extra argument: {err}"
);
assert!(
parse_line("create-view |By area|").is_ok(),
"the control parses"
);
}
}