perf(calc): find the formulas a cleared cell may unblock from a set of #SPILL! cells (#2684)

Clearing a cell walked every formula in the graph, and each changed cell
scanned a 64 x 64 window of stored cells, to find formulas showing
#SPILL!. Calc now keeps the set of cells whose value is #SPILL! (filled
in rebuild, kept up to date by cells_changed and the write-back of a
run) and takes those candidates from it. The same set replaces the walk
over the graph in run for areas a formula stopped spilling over. Each
entry is checked against the workbook before it is used.

Fixes #2684

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-26 22:26:15 +01:00
1 parent d8c5f5885f
commit 72236e4aab
2 files changed
+85 -23

No files matched your search

+57 -23
View File
@@ -377,11 +377,23 @@ pub struct Calc {
/// Cells whose formulas have errors from cycles.
pub circular: Vec<Key>,
pub last_recalc_cells: usize,
/// The cells whose value is `#SPILL!`, kept so that finding the formulas an emptied cell or a
/// freed spill area may unblock doesn't walk every formula or window of cells. Filled in
/// `rebuild`, kept up to date by `cells_changed` and by the write-back of a run; an entry is
/// always checked against the workbook before it is used.
spill_errors: BTreeSet<Key>,
}
impl Default for Calc {
fn default() -> Self {
Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 }
Calc {
graph: Graph::default(),
rng: 0x9E37_79B9_7F4A_7C15,
fixed_now: None,
circular: vec![],
last_recalc_cells: 0,
spill_errors: BTreeSet::new(),
}
}
}
@@ -1007,8 +1019,12 @@ impl Calc {
pub fn rebuild(&mut self, wb: &Workbook) {
self.graph = Graph::default();
let mut circ = Vec::new();
self.spill_errors.clear();
for (si, sheet) in wb.sheets.iter().enumerate() {
for (c, cell) in sheet.cells.iter() {
if cell.value == Value::Error(CellError::Spill) {
self.spill_errors.insert((si, c));
}
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
{
@@ -1066,6 +1082,25 @@ impl Calc {
out
}
/// The cells of `sheet` in `rows` x `cols` (inclusive, `cols` a lower and an upper column) that
/// show `#SPILL!`, in row-major order.
fn spill_errors_in(&self, wb: &Workbook, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> Vec<CellRef> {
let Some(sh) = wb.sheet(sheet) else { return Vec::new() };
self.spill_errors
.range((sheet, CellRef::new(rows.0, 0))..=(sheet, CellRef::new(rows.1, u32::MAX)))
.filter(|(_, c)| c.col >= cols.0 && c.col <= cols.1)
.filter(|(_, c)| sh.cells.get(*c).is_some_and(|x| x.value == Value::Error(CellError::Spill)))
.map(|(_, c)| *c)
.collect()
}
/// The formulas showing `#SPILL!` on `sheet` at or above and to the left of `end`: what a
/// cell emptied or a spill area freed there may unblock.
fn unblock_candidates(&self, wb: &Workbook, sheet: usize, end: CellRef) -> Vec<Key> {
let cells = self.spill_errors_in(wb, sheet, (0, end.row), (0, end.col));
cells.into_iter().filter(|c| self.graph.nodes.contains_key(&(sheet, *c))).map(|c| (sheet, c)).collect()
}
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
pub fn recalc_all(&mut self, wb: &mut Workbook) {
self.rebuild(wb);
@@ -1087,6 +1122,11 @@ impl Calc {
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
if wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.value == Value::Error(CellError::Spill)) {
self.spill_errors.insert(k);
} else {
self.spill_errors.remove(&k);
}
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
match formula.and_then(|f| f.expr()) {
Some(e) => {
@@ -1109,6 +1149,7 @@ impl Calc {
&& cell.formula.is_some()
{
cell.value = Value::Error(CellError::Name);
self.spill_errors.remove(&k);
}
}
}
@@ -1131,10 +1172,9 @@ impl Calc {
seeds.push((k.0, *anchor));
}
}
for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) {
if c != k.1 && cell.value == Value::Error(CellError::Spill) {
seeds.push((k.0, c));
}
if !self.spill_errors.is_empty() {
let (r0, c0) = (k.1.row.saturating_sub(64), k.1.col.saturating_sub(64));
seeds.extend(self.spill_errors_in(wb, k.0, (r0, k.1.row), (c0, k.1.col)).into_iter().filter(|c| *c != k.1).map(|c| (k.0, c)));
}
if !sh.cells.has(k.1) {
match emptied.iter_mut().find(|x| x.0 == k.0) {
@@ -1146,11 +1186,7 @@ impl Calc {
}
// An emptied cell can unblock an anchor any distance above or to the left of it.
for (si, end) in emptied {
for &(s, c) in self.graph.nodes.keys() {
if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {
seeds.push((s, c));
}
}
seeds.extend(self.unblock_candidates(wb, si, end));
}
// Spill areas of changed anchors also change.
for &k in changed {
@@ -1254,10 +1290,15 @@ impl Calc {
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
for (k, v) in results {
let Some(sheet) = wb.sheet_mut(k.0) else { continue };
if let Some(cell) = sheet.cells.get_mut(k.1)
&& cell.value != v
{
cell.value = v;
if let Some(cell) = sheet.cells.get_mut(k.1) {
if v == Value::Error(CellError::Spill) {
self.spill_errors.insert(k);
} else {
self.spill_errors.remove(&k);
}
if cell.value != v {
cell.value = v;
}
}
}
for k in &dirty {
@@ -1293,6 +1334,7 @@ impl Calc {
if blocked {
if let Some(cell) = sheet.cells.get_mut(k.1) {
cell.value = Value::Error(CellError::Spill);
self.spill_errors.insert(*k);
}
// Not blocked while the pass ran (it would have been #SPILL! there), so
// blocked by another array of this pass: the formulas that read this one,
@@ -1338,15 +1380,7 @@ impl Calc {
}
// An area a formula no longer spills over may unblock another formula's array.
for (si, o) in freed {
for &(s, c) in self.graph.nodes.keys() {
if s == si
&& c.row <= o.end.row
&& c.col <= o.end.col
&& wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill))
{
spill_changes.push((s, c));
}
}
spill_changes.extend(self.unblock_candidates(wb, si, o.end));
}
if full {
// Everything was already evaluated once; spill dependents need another pass.
+28
View File
@@ -2274,3 +2274,31 @@ fn loan_schedule_residue_keeps_excels_sign() {
}
assert_eq!(t.get("P7"), Value::Text("".into()));
}
#[test]
fn clearing_a_cell_unblocks_the_arrays_above_and_left_of_it() {
let mut t = T::new();
for r in 1..=50 {
t.set(&format!("D{r}"), &format!("={r}*2"));
}
t.set("B2", "7");
t.set("B1", "=SEQUENCE(3)");
t.set("C1", "=SEQUENCE(1,3)");
t.set("A20", "=B1+1");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
assert_eq!(t.get("A20"), Value::Error(CellError::Spill));
// Emptied: the array above it spills.
t.wb.sheet_mut(0).unwrap().cells.remove(c("B2"));
t.calc.cells_changed(&mut t.wb, &[(0, c("B2"))]);
assert_eq!(t.num("B3"), 3.0);
assert_eq!(t.num("A20"), 2.0);
// Typing over the area blocks it again; far from it, nothing is disturbed.
t.set("B3", "9");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
t.set("D50", "1");
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
assert_eq!(t.num("D49"), 98.0);
// Clearing it again frees the array.
t.set("B3", "");
assert_eq!(t.num("B3"), 3.0);
}