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#.
This commit is contained in:
nihilist-narwhal 2026-10-09 23:04:10 -05:00
parent 13664fe66b
commit 9c29ef3fff
10 changed files with 143 additions and 3 deletions

View File

@ -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<RangeRef> {
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<Value> {
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,
))

View File

@ -225,6 +225,26 @@ impl Host for PassHost<'_> {
fn workbook(&self) -> &Workbook {
self.wb
}
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
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) {

View File

@ -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();

View File

@ -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();

View File

@ -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)]

View File

@ -124,6 +124,11 @@ pub fn tokenize(src: &str) -> Result<Vec<Token>, 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("");

View File

@ -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()),

View File

@ -186,6 +186,13 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
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);

View File

@ -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();

View File

@ -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)");