From 9c29ef3fffc0834bf30d22d3ecdff5c44df9015e Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:04:10 -0500 Subject: [PATCH] Formulas: the spill range operator A1# formula::parse("Sheet1!$D$1#") failed with "unknown error literal". A # right after a cell reference is now the spill range operator: it evaluates to the anchor's current spill range, or #REF! when the cell doesn't spill (or its spill is blocked). Its users depend on the anchor, so they recalculate when the spill grows or shrinks. Names can use it. Files have no # operator: like Excel, the writer stores A1# as _xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#. --- crates/calc/src/eval.rs | 19 ++++++++++++++++++- crates/calc/src/recalc.rs | 20 ++++++++++++++++++++ crates/calc/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 17 +++++++++++++++++ crates/formula/src/ast.rs | 3 +++ crates/formula/src/lexer.rs | 5 +++++ crates/formula/src/parser.rs | 16 ++++++++++++++-- crates/formula/src/printer.rs | 7 +++++++ crates/formula/src/tests.rs | 16 ++++++++++++++++ crates/xlsx/src/fmla.rs | 9 +++++++++ 10 files changed, 143 insertions(+), 3 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index abef54f..39c725c 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -54,6 +54,10 @@ pub trait Host { fn random(&mut self) -> f64; /// Called when a volatile or dynamic-reference function is used. fn note_volatile(&mut self) {} + /// The spill range of the dynamic array anchored at `anchor` (`None`: it doesn't spill). + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + self.workbook().sheet(sheet)?.spill_ranges.get(&anchor).copied() + } /// Values of a block, row-major. Hosts can override with a sparse fast path. fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { range.iter().map(|c| self.cell_value(sheet, c)).collect() @@ -322,6 +326,19 @@ impl<'h> Evaluator<'h> { } fn unary(&mut self, op: UnOp, x: &Expr) -> Ev { + if op == UnOp::Spill { + return match self.eval(x) { + Ev::R(areas) => match areas.as_slice() { + [a] if a.range.is_single() => match self.host.spill_range(a.sheet, a.range.start) { + Some(range) => Ev::R(vec![Area { sheet: a.sheet, range }]), + None => err(CellError::Ref), + }, + _ => err(CellError::Ref), + }, + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Ref), + }; + } if op == UnOp::At { let ev = self.eval(x); return match ev { @@ -343,7 +360,7 @@ impl<'h> Evaluator<'h> { UnOp::Neg => Value::number(-n), UnOp::Plus => Value::number(n), UnOp::Percent => Value::number(n / 100.0), - UnOp::At => Value::number(n), + UnOp::At | UnOp::Spill => Value::number(n), }, op == UnOp::Plus, )) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 13b47c4..500dec2 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -225,6 +225,26 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { let k = (sheet, c); if self.pending.contains(&k) || self.results.contains_key(&k) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 67db083..535585f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -33,6 +33,40 @@ impl T { } } +#[test] +fn spill_range_operator() { + let mut t = T::new(); + t.set("A1", "3"); + t.set("B1", "=SEQUENCE(A1)"); + t.set("C1", "=SUM(B1#)"); + t.set("C2", "=ROWS(B1#)"); + assert_eq!((t.num("C1"), t.num("C2")), (6.0, 3.0)); + // Growing and shrinking the spill recalculates its users. + t.set("A1", "5"); + assert_eq!((t.num("C1"), t.num("C2")), (15.0, 5.0)); + t.set("A1", "2"); + assert_eq!((t.num("C1"), t.num("C2")), (3.0, 2.0)); + // A blocked spill, or a cell that doesn't spill, gives #REF!. + t.set("B2", "x"); + assert_eq!(t.get("C1"), Value::Error(CellError::Ref)); + t.set("B2", ""); + assert_eq!(t.num("C1"), 3.0); + t.set("D1", "=SUM(A1#)"); + assert_eq!(t.get("D1"), Value::Error(CellError::Ref)); + // Through a defined name. + t.wb.names.push(gridcraft_model::DefinedName { + name: "Seq".into(), + scope: None, + formula: "Sheet1!$B$1#".into(), + comment: String::new(), + hidden: false, + }); + t.set("E1", "=SUM(Seq)*10"); + assert_eq!(t.num("E1"), 30.0); + t.set("A1", "4"); + assert_eq!(t.num("E1"), 100.0); +} + #[test] fn arithmetic_and_dependencies() { let mut t = T::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..61c21b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -110,6 +110,23 @@ fn formatting_and_styles() { s.execute("home.cellStyle", json!({"range": "D1", "name": "Good"})).unwrap(); } +#[test] +fn spill_references_roundtrip_through_xlsx() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SEQUENCE(4)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=sum(b1#)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Seq", "refersTo": "=Sheet1!$B$1#"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=MAX(Seq)"})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + let b64 = r["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); + assert_eq!(s.execute("cell.get", json!({"cell": "C1"})).unwrap()["formula"], "=SUM(B1#)"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$B$1#"); + assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0))); +} + #[test] fn tables_and_structured_refs() { let mut s = s(); diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 51e5068..39f2511 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -88,6 +88,9 @@ pub enum UnOp { Percent, /// Implicit intersection `@`. At, + /// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the + /// cell (`ANCHORARRAY` in files). + Spill, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index b6143ba..14b45d9 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result, LexError> { push(&mut out, Tok::Sheet(a, b), j + 1); i = j + 1; } + '#' if out.last().is_some_and(|t: &Token| t.end == start && matches!(t.tok, Tok::Word(_))) => { + // Spill range operator right after a reference: `A1#`. + push(&mut out, Tok::Op("#"), i + 1); + i += 1; + } '#' => { // Error literal, or #-prefixed struct specifier (only inside [] which we lex as Struct). let rest = src.get(start..).unwrap_or(""); diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index f501402..b92e2e6 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -223,7 +223,14 @@ impl Parser { Tok::LBrace => self.array(), Tok::Func(name) => { let name = normalize_function_name(&name); - let args = self.args()?; + let mut args = self.args()?; + // Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`. + if name == "ANCHORARRAY" + && let [Expr::Ref(Reference { kind: RefKind::Cell(_), .. })] = args.as_slice() + && let Some(r) = args.pop() + { + return Ok(Expr::Unary(UnOp::Spill, Box::new(r))); + } Ok(Expr::Call(name, args)) } Tok::Sheet(a, b) => { @@ -263,7 +270,12 @@ impl Parser { self.next(); return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) })); } - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) })); + let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }); + if *self.peek() == Tok::Op("#") { + self.next(); + return Ok(Expr::Unary(UnOp::Spill, Box::new(cell))); + } + return Ok(cell); } let second = match self.peek_at(1) { Tok::Word(w2) => Some(w2.clone()), diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 0aa223e..12e785f 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { write_expr(s, x, r1c1); s.push('%'); } + UnOp::Spill => { + write_expr(s, x, r1c1); + // A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`. + if !matches!(**x, Expr::Error(_)) { + s.push('#'); + } + } }, Expr::Binary(op, a, b) => { write_expr(s, a, r1c1); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index ff84a5b..7ad8262 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -52,6 +52,22 @@ fn normalises() { assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1"); } +#[test] +fn spill_operator() { + for f in ["A1#", "Sheet1!$D$1#", "SUM(B2#)", "'My Sheet'!A1#*2", "ROWS(A1#)+1", "IF(A1,#N/A,#REF!)"] { + assert_eq!(rt(f), f); + } + assert!(matches!(parse("A1#").unwrap(), Expr::Unary(UnOp::Spill, _))); + // Files spell it ANCHORARRAY. + assert_eq!(rt("_xlfn.ANCHORARRAY(Sheet1!$D$1)"), "Sheet1!$D$1#"); + assert_eq!(rt("SUM(ANCHORARRAY(B2))"), "SUM(B2#)"); + // Only a cell can spill. + assert!(parse("A1:B2#").is_err()); + assert!(parse("Name#").is_err()); + // Deleting the anchor's sheet leaves #REF!. + assert_eq!(print(&crate::adjust::delete_sheet(parse("SUM(Data!A1#)").unwrap(), "Data")), "SUM(#REF!)"); +} + #[test] fn precedence() { let e = parse("1+2*3").unwrap(); diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index eee516b..b4298c9 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -27,6 +27,8 @@ fn expr_to_file(e: Expr) -> String { Some(n) => Expr::Call(n, args), None => Expr::Call(name, args), }, + // Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`. + Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), other => other, }); gridcraft_formula::print(&e) @@ -45,6 +47,13 @@ pub fn from_file(text: &str) -> String { mod tests { use super::*; + #[test] + fn spill_references() { + assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"); + assert_eq!(text_to_file("=Sheet1!$D$1#"), "_xlfn.ANCHORARRAY(Sheet1!$D$1)"); + assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#"); + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");