Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 122 additions & 17 deletions crates/sheetkit/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ commands (one per line, # comments):
sketch · sheets · regions · names · checkpoints · highlights · help
view <target> [mode=dense|agg|sparse] [budget=<tokens>]
set <cell> <value or =formula> set <range> = [v1, v2, …]
set <range> <value or =formula> (formula re-anchors per row)
set <range> <value or =formula> set <range> = [[row1…], [row2…]]
(a range formula re-anchors per row; the "=" before a value is optional)
fill <source> -> <target-range>
clear <target> [contents|formats|all]
insert rows <at> [count <n>] [in <sheet>] insert cols <C> [count <n>] [in <sheet>]
Expand All @@ -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<char> = 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 <target> = <value>`: 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,
}
}
Comment on lines +214 to 220

Expand Down Expand Up @@ -384,19 +411,46 @@ fn cmd_set(session: &mut Session, rest: &str) -> Result<String> {
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<String> = 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<String> = 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!(
Expand Down Expand Up @@ -459,11 +513,12 @@ fn cmd_set(session: &mut Session, rest: &str) -> Result<String> {
))
}

/// 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<String> {
let mut parts = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut depth = 0usize;
for c in s.chars() {
match quote {
Some(q) => {
Expand All @@ -477,7 +532,15 @@ fn split_list(s: &str) -> Vec<String> {
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),
},
}
Expand Down Expand Up @@ -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();
Expand Down