From d833ffe1aee7476eccd7c379c67e122b9b7df6f3 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 26 Aug 2026 18:25:55 +0200 Subject: [PATCH] cmd: quoted sheet targets, '=' separator, and 2D lists for set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - split_word is quote-aware, so 'My Sheet'!A1 (which help already advertised) parses instead of failing on "'My". - A '=' followed by whitespace after the target is the separator, not the value: set A1 = =SUM(..) is a formula, set A1 = 2025-10-26 a literal. Before, the scalar path passed '= …' to the engine as a formula. - set = [[row], [row]] writes a table in one command. Co-Authored-By: Claude Fable 5 --- crates/sheetkit/src/cmd.rs | 139 ++++++++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 17 deletions(-) diff --git a/crates/sheetkit/src/cmd.rs b/crates/sheetkit/src/cmd.rs index b01c452..c08bddc 100644 --- a/crates/sheetkit/src/cmd.rs +++ b/crates/sheetkit/src/cmd.rs @@ -167,7 +167,8 @@ commands (one per line, # comments): sketch · sheets · regions · names · checkpoints · highlights · help view [mode=dense|agg|sparse] [budget=] set set = [v1, v2, …] - set (formula re-anchors per row) + set set = [[row1…], [row2…]] + (a range formula re-anchors per row; the "=" before a value is optional) fill -> clear [contents|formats|all] insert rows [count ] [in ] insert cols [count ] [in ] @@ -184,11 +185,37 @@ targets: A1 · A1:C10 · C:E · 5:20 · Sheet2!B3 · 'My Sheet'!A1 · region or // ---- helpers --------------------------------------------------------------- +/// Split off the first word, where whitespace inside `"…"` / `'…'` does not +/// end it — so a target like `'My Sheet'!A1` stays whole. fn split_word(s: &str) -> (&str, &str) { let s = s.trim(); - match s.find(char::is_whitespace) { - Some(i) => (&s[..i], s[i..].trim_start()), - None => (s, ""), + let mut quote: Option = None; + for (i, c) in s.char_indices() { + match quote { + Some(q) => { + if c == q { + quote = None; + } + } + None => match c { + '"' | '\'' => quote = Some(c), + c if c.is_whitespace() => return (&s[..i], s[i..].trim_start()), + _ => {} + }, + } + } + (s, "") +} + +/// `set = `: a `=` followed by whitespace is the optional +/// separator, not part of the value. Formulas are written `=SUM(…)` with no +/// space, so `= =SUM(…)` is a formula and `= 2025-10-26` a literal date +/// (rather than a formula evaluating to 1989). +fn strip_separator(raw: &str) -> &str { + let trimmed = raw.trim(); + match trimmed.strip_prefix('=') { + Some(rest) if rest.starts_with(char::is_whitespace) => rest.trim_start(), + _ => trimmed, } } @@ -384,19 +411,46 @@ fn cmd_set(session: &mut Session, rest: &str) -> Result { let sheet = resolved.sheet_index; let range = resolved.range; - // Batch list form: `set B2:B4 = [2, 10, 1]` - let trimmed = raw.trim(); - let list = trimmed - .strip_prefix('=') - .map(str::trim) - .filter(|t| t.starts_with('[')) - .or_else(|| Some(trimmed).filter(|t| t.starts_with('['))); - if let Some(list) = list { - let inner = list + // Batch list forms: `set B2:B4 = [2, 10, 1]` (cells in row-major order) + // and `set A2:C3 = [[1, 2, 3], [4, 5, 6]]` (one inner list per row). + let raw = strip_separator(raw); + if raw.starts_with('[') { + let inner = raw .strip_prefix('[') .and_then(|s| s.strip_suffix(']')) - .ok_or_else(|| Error::from("list must be [a, b, c]"))?; - let values: Vec = split_list(inner).iter().map(|v| unquote(v)).collect(); + .ok_or_else(|| Error::from("list must be [a, b, c] or [[row], [row]]"))?; + let values: Vec = if inner.trim_start().starts_with('[') { + let rows = split_list(inner); + let width = range.width() as usize; + if rows.len() as i32 != range.height() { + return Err(Error::from(format!( + "list has {} rows but {} has {} rows", + rows.len(), + range.a1(), + range.height() + ))); + } + let mut values = Vec::with_capacity(rows.len() * width); + for (i, row) in rows.iter().enumerate() { + let cells = row + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| Error::from(format!("row {} must be [a, b, c]", i + 1)))?; + let cells = split_list(cells); + if cells.len() != width { + return Err(Error::from(format!( + "row {} has {} values but {} is {width} wide", + i + 1, + cells.len(), + range.a1() + ))); + } + values.extend(cells.iter().map(|v| unquote(v))); + } + values + } else { + split_list(inner).iter().map(|v| unquote(v)).collect() + }; let count = range.cell_count(); if values.len() as i64 != count { return Err(Error::from(format!( @@ -459,11 +513,12 @@ fn cmd_set(session: &mut Session, rest: &str) -> Result { )) } -/// Split `a, b, "c,d", e` on top-level commas. +/// Split `a, b, "c,d", e` on top-level commas (outside quotes and brackets). fn split_list(s: &str) -> Vec { let mut parts = Vec::new(); let mut cur = String::new(); let mut quote: Option = None; + let mut depth = 0usize; for c in s.chars() { match quote { Some(q) => { @@ -477,7 +532,15 @@ fn split_list(s: &str) -> Vec { quote = Some(c); cur.push(c); } - ',' => parts.push(std::mem::take(&mut cur)), + '[' => { + depth += 1; + cur.push(c); + } + ']' => { + depth = depth.saturating_sub(1); + cur.push(c); + } + ',' if depth == 0 => parts.push(std::mem::take(&mut cur)), _ => cur.push(c), }, } @@ -1337,6 +1400,48 @@ mod tests { assert!(!out.ok()); } + #[test] + fn set_rows_batch() { + let mut s = orders_session(); + let out = run( + &mut s, + "set A5:C6 = [[\"Dog\", 3, 1.5], [\"Eel, the\", 4, =B6*2]]", + ); + assert!(out.ok(), "{:?}", out.failed); + assert_eq!(s.book.value(0, 5, 1), Value::Text("Dog".into())); + assert_eq!(s.book.value(0, 6, 1), Value::Text("Eel, the".into())); + assert_eq!(s.book.value(0, 6, 3), Value::Number(8.0)); + // shape mismatches fail + assert!(!run(&mut s, "set A5:C6 = [[1, 2, 3]]").ok()); + assert!(!run(&mut s, "set A5:C6 = [[1, 2], [3, 4]]").ok()); + } + + #[test] + fn set_separator_is_not_part_of_the_value() { + let mut s = orders_session(); + let out = run( + &mut s, + "set D2 = =B2*C2\nset E2 = 2025-10-26\nset F2 = \"quoted text\"\nset G2 =B2+1", + ); + assert!(out.ok(), "{:?}", out.failed); + assert_eq!(s.book.value(0, 2, 4), Value::Number(4.0)); + // A literal date, not the formula 2025-10-26 = 1989. + assert_eq!(s.book.formula(0, 2, 5).unwrap(), None); + assert_eq!(s.book.value(0, 2, 6), Value::Text("quoted text".into())); + assert_eq!(s.book.value(0, 2, 7), Value::Number(11.0)); + } + + #[test] + fn quoted_sheet_names_with_spaces_are_targets() { + let mut s = orders_session(); + let out = run( + &mut s, + "sheet new \"Inv 343696 legs\"\nset 'Inv 343696 legs'!A1 = \"hello\"\nset 'Inv 343696 legs'!B1:C1 = [1, 2]\nexpect 'Inv 343696 legs'!C1 == 2", + ); + assert!(out.ok(), "{:?}", out.failed); + assert_eq!(s.book.value(1, 1, 1), Value::Text("hello".into())); + } + #[test] fn sort_by_header_reanchors_formulas() { let mut s = orders_session();