Recalc: formulas over a blocked spill see #SPILL!

A dynamic array that couldn't spill was only rejected when results were
written back, after formulas evaluated later in the same pass had read
the array as if it had spilled. With 1s in A1:C3 except B2 =
SEQUENCE(2,2,9) (blocked), =SUM(A1:C3) gave 47 after a full
recalculation (opening a file, Calculate Now) instead of #SPILL!; a
reader through OFFSET kept the wrong value even when entered one cell
at a time. The spill area is now checked as soon as the array is
computed, so the anchor is #SPILL! for everything after it.
This commit is contained in:
nihilist-narwhal
2026-10-10 04:45:41 -05:00
parent 13664fe66b
commit be8e379ca0
2 changed files with 67 additions and 0 deletions
+28
View File
@@ -184,6 +184,26 @@ struct PassHost<'a> {
depth: usize,
}
/// Whether an array result at `k` can't spill: its area runs off the sheet, overlaps merged cells,
/// holds other cells, or another formula's spill (unless that formula is recalculated in this
/// pass, and so may spill elsewhere now).
fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
let Some(sh) = host.wb.sheet(sheet) else { return false };
let end =
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
let range = RangeRef::new(anchor, end);
let own = sh.spill_ranges.get(&anchor);
let spilled_over = |c: CellRef| {
!own.is_some_and(|o| o.contains(c))
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
};
end.row >= gridcraft_core::MAX_ROWS
|| end.col >= gridcraft_core::MAX_COLS
|| sh.merges.iter().any(|m| m.intersects(&range))
|| sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some()))
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
}
impl PassHost<'_> {
fn compute(&mut self, k: Key) -> Value {
if let Some(v) = self.results.get(&k) {
@@ -216,6 +236,14 @@ impl PassHost<'_> {
Value::Empty => Value::Number(0.0),
v => v,
};
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
let v = match self.spills.get(&k) {
Some(a) if spill_blocked(self, k, a) => {
self.spills.remove(&k);
Value::Error(CellError::Spill)
}
_ => v,
};
self.results.insert(k, v.clone());
v
}
+39
View File
@@ -105,6 +105,45 @@ fn operators() {
assert_eq!(t.num("A7"), 0.0);
}
#[test]
fn blocked_spill_reads_as_spill_error() {
// Calculated in the same pass, a formula over a blocked spill read the array that couldn't
// spill.
let mut t = T::new();
let s = t.wb.sheet_mut(0).unwrap();
for a in ["A1", "B1", "C1", "A2", "C2", "A3", "B3", "C3"] {
s.cells.set(c(a), Cell::value(Value::Number(1.0)));
}
s.cells.set(c("B2"), Cell::formula(Formula::new("=SEQUENCE(2,2,9)")));
s.cells.set(c("E1"), Cell::formula(Formula::new("=SUM(A1:C3)")));
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("B2"), Value::Error(CellError::Spill));
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
assert_eq!(t.num("C3"), 1.0);
}
#[test]
fn blocked_spill_read_through_offset() {
// The reader's reference is only known while evaluating, so it isn't in the dependency graph.
let mut t = T::new();
t.set("E4", "5");
t.set("E8", "=SUM(OFFSET(E2,0,0,2,2))");
t.set("E3", "=SEQUENCE(2,2,3)");
t.calc.recalc_all(&mut t.wb);
assert_eq!(t.get("E3"), Value::Error(CellError::Spill));
assert_eq!(t.get("E8"), Value::Error(CellError::Spill));
}
#[test]
fn spill_into_the_old_area_of_a_blocked_neighbour() {
// C7's array becomes blocked by C8, whose array then takes over C7's old area.
let mut t = T::new();
t.set("C7", "=SEQUENCE(2,2)");
t.set("C8", "=SEQUENCE(2,2)");
assert_eq!(t.get("C7"), Value::Error(CellError::Spill));
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn ifs_and_lazy() {
let mut t = T::new();