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 abef54ffd..39c725c17 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 13b47c41c..500dec2c5 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 67db0830d..535585f01 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 f3e36cdf4..61c21b4ea 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 51e5068c8..39f251154 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 b6143ba27..14b45d95d 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 f5014020c..b92e2e6b4 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 0aa223eba..12e785f95 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 ff84a5bb7..7ad826293 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 eee516b24..b4298c95d 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)");