Recalc: linear in the number of range formulas

Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s
(1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000
VLOOKUPs read froze the app for 40 s or more. Two things grew with the
number of ranges:

- Evaluating a range trims it to the sheet's used range, and
  Sheet::used_range walks every stored row. It ran once per range read,
  so a full recalc was rows x range formulas. The pass's host now works
  it out once per sheet (the workbook doesn't change during a pass);
  Host::used_range keeps the old behaviour for other hosts.
- The reverse index kept each column's range precedents in a Vec:
  finding a cell's dependents scanned every range in the column, and
  re-entering a formula ran a retain over each of its columns. Pasting
  a column, filling formulas down or sorting was quadratic. Each column
  (and each sheet's wide ranges) now has an interval index: sorted by
  start, with a segment tree of the largest end, so a lookup costs
  O(log n + matches). Removals mark entries dead and insertions wait in
  a small sorted set; Graph::settle merges them after each batch, so
  edits cost O(log n) amortised. Single-cell dependents are a set, so
  removing one is O(1). dependents_of_range asks each column once
  instead of once per cell.

Semantics are unchanged: whole rows and columns, other sheets, names,
tables, spills and volatile functions go through the same paths.

Release CLI, open + full recalc: 12.5k rows 0.39 s -> 0.06 s, 25k 1.83 s
-> 0.14 s, 50k 9.3 s -> 0.28 s. The 50k x 20 bug-hunt workbook: recalc
on open 87 s -> 1.7 s, editing C2 (100k dependents) 51 s -> 1.3 s;
filling 50k SUMs down 17 s -> 0.18 s, sorting 50k rows 9 s -> 0.14 s.

Tests: the interval index against a linear scan (inserts, removals,
revivals, merges) and its query cost in tree nodes; range, wide,
whole-column, whole-row and cross-sheet dependents after edits; and a
scaling test (open, change a column, re-enter every formula) that
allows 8x the time for 4x the rows. Before the fix it took 16x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd 2026-10-09 20:51:15 +02:00
parent 51b531be3e
commit 631a49ec30
2 changed files with 468 additions and 45 deletions

View File

@ -58,6 +58,11 @@ pub trait Host {
fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec<Value> {
range.iter().map(|c| self.cell_value(sheet, c)).collect()
}
/// The sheet's used range (`Sheet::used_range`, which walks every stored row). Hosts that
/// keep the workbook unchanged while evaluating can remember it.
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
self.workbook().sheet(sheet).and_then(|s| s.used_range())
}
}
pub struct Evaluator<'h> {
@ -140,9 +145,11 @@ impl<'h> Evaluator<'h> {
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
/// positions stay right).
pub fn trim(&self, a: &Area) -> RangeRef {
let Some(sheet) = self.host.workbook().sheet(a.sheet) else { return a.range };
let used = sheet.used_range();
pub fn trim(&mut self, a: &Area) -> RangeRef {
if self.host.workbook().sheet(a.sheet).is_none() {
return a.range;
}
let used = self.host.used_range(a.sheet);
let (mut r1, mut c1) = (a.range.end.row, a.range.end.col);
match used {
Some(u) => {
@ -881,7 +888,7 @@ impl<'h> Evaluator<'h> {
}
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
fn trim_lines(&self, a: &Area) -> RangeRef {
fn trim_lines(&mut self, a: &Area) -> RangeRef {
if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range }
}

View File

@ -1,13 +1,14 @@
//! Dependency graph and recalculation.
//!
//! The graph maps every formula cell to its static precedents (resolved areas). A reverse index
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges in
//! per-column buckets (wide ranges in a separate list). After an edit, the dirty set is the
//! answers "which formulas read this cell?": single-cell precedents in a hash map, ranges as row
//! intervals per column (wide ranges per sheet), each an interval index so a lookup costs
//! O(log n + matches) however many ranges share the column. After an edit, the dirty set is the
//! transitive closure of dependents; it is evaluated in topological order, with on-demand
//! evaluation as a safety net for dynamic references. Cycles produce `#CIRC!` unless iterative
//! calculation is on.
use std::collections::VecDeque;
use std::collections::{BTreeSet, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
@ -51,19 +52,147 @@ struct Node {
dynamic: bool,
}
/// Ranges wider than this many columns go to the wide list instead of per-column buckets.
/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones.
const WIDE: u32 = 64;
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
const PENDING: usize = 32;
/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows
/// `a..=b`?" in O(log n + k).
///
/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query
/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one
/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops
/// dead entries once either grows, so mutations cost O(log n) amortised.
#[derive(Clone, Debug)]
struct Intervals<T> {
/// Sorted, without duplicates.
items: Vec<(u32, u32, T)>,
dead: Vec<bool>,
dead_count: usize,
/// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at
/// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included).
max_end: Vec<u32>,
/// Inserted since the last merge.
pending: BTreeSet<(u32, u32, T)>,
}
impl<T> Default for Intervals<T> {
fn default() -> Self {
Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() }
}
}
impl<T: Copy + Ord> Intervals<T> {
fn is_empty(&self) -> bool {
self.items.len() == self.dead_count && self.pending.is_empty()
}
fn insert(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& *d
{
*d = false;
self.dead_count -= 1;
}
return;
}
self.pending.insert(it);
}
fn remove(&mut self, r0: u32, r1: u32, t: T) {
let it = (r0, r1, t);
if let Ok(i) = self.items.binary_search(&it) {
if let Some(d) = self.dead.get_mut(i)
&& !*d
{
*d = true;
self.dead_count += 1;
}
return;
}
self.pending.remove(&it);
}
/// Merges pending insertions and drops dead entries when there are enough of them.
fn settle(&mut self) {
if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) {
return;
}
let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len());
all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it));
all.extend(std::mem::take(&mut self.pending));
// Two sorted runs: the stable sort merges them in linear time.
all.sort();
all.dedup();
let size = all.len().next_power_of_two();
let mut max_end = vec![0; 2 * size];
if let Some(leaves) = max_end.get_mut(size..) {
for (slot, it) in leaves.iter_mut().zip(&all) {
*slot = it.1;
}
}
for i in (1..size).rev() {
let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0));
if let Some(slot) = max_end.get_mut(i) {
*slot = m;
}
}
self.dead = vec![false; all.len()];
self.dead_count = 0;
self.items = all;
self.max_end = max_end;
}
/// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the
/// number of tree nodes visited.
fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize {
for it in &self.pending {
if it.0 <= b && it.1 >= a {
f(&it.2);
}
}
// Entries before `p` start at or before row `b`.
let p = self.items.partition_point(|it| it.0 <= b);
if p == 0 {
return 0;
}
self.walk(1, 0, self.max_end.len() / 2, p, a, f)
}
fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize {
if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) {
return 1;
}
if hi - lo <= 1 {
if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo))
&& it.1 >= a
{
f(&it.2);
}
return 1;
}
let mid = lo + (hi - lo) / 2;
1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f)
}
}
#[derive(Clone, Debug, Default)]
pub struct Graph {
nodes: HashMap<Key, Node>,
/// single cell → dependents
cell_deps: HashMap<Key, Vec<Key>>,
/// (sheet, col) → [(r0, r1, dependent)]
col_deps: HashMap<(usize, u32), Vec<(u32, u32, Key)>>,
/// (sheet, range, dependent)
wide_deps: Vec<(usize, RangeRef, Key)>,
cell_deps: HashMap<Key, HashSet<Key>>,
/// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column
col_deps: HashMap<(usize, u32), Intervals<Key>>,
/// sheet → row intervals of wider ranges, with their columns: (first, last, dependent)
wide_deps: HashMap<usize, Intervals<(u32, u32, Key)>>,
dynamic: HashSet<Key>,
/// Range indexes changed since the last [`Graph::settle`].
unsettled_cols: HashSet<(usize, u32)>,
unsettled_wide: HashSet<usize>,
}
impl Graph {
@ -74,19 +203,44 @@ impl Graph {
self.nodes.is_empty()
}
fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
for a in &node.areas {
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
self.cell_deps.entry((a.sheet, a.range.start)).or_default().push(k);
cells.push((a.sheet, a.range.start));
} else if a.range.width() > WIDE {
self.wide_deps.push((a.sheet, a.range, k));
wide.push((a.sheet, a.range));
} else {
for c in a.range.start.col..=a.range.end.col {
self.col_deps.entry((a.sheet, c)).or_default().push((a.range.start.row, a.range.end.row, k));
cols.push((a.sheet, c, a.range.start.row, a.range.end.row));
}
}
}
cells.sort_unstable();
cells.dedup();
cols.sort_unstable();
cols.dedup();
wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end));
wide.dedup();
(cells, cols, wide)
}
fn insert(&mut self, k: Key, node: Node) {
self.remove(k);
let (cells, cols, wide) = Self::entries(&node.areas);
for c in cells {
self.cell_deps.entry(c).or_default().insert(k);
}
for (s, c, r0, r1) in cols {
self.col_deps.entry((s, c)).or_default().insert(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
for (s, r) in wide {
self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
if node.dynamic {
self.dynamic.insert(k);
}
@ -95,54 +249,104 @@ impl Graph {
fn remove(&mut self, k: Key) {
let Some(old) = self.nodes.remove(&k) else { return };
for a in &old.areas {
if a.range.is_single() {
if let Some(v) = self.cell_deps.get_mut(&(a.sheet, a.range.start)) {
v.retain(|x| *x != k);
}
} else if a.range.width() > WIDE {
self.wide_deps.retain(|(_, _, d)| *d != k);
} else {
for c in a.range.start.col..=a.range.end.col {
if let Some(v) = self.col_deps.get_mut(&(a.sheet, c)) {
v.retain(|(_, _, d)| *d != k);
}
let (cells, cols, wide) = Self::entries(&old.areas);
for c in cells {
if let Some(v) = self.cell_deps.get_mut(&c) {
v.remove(&k);
if v.is_empty() {
self.cell_deps.remove(&c);
}
}
}
for (s, c, r0, r1) in cols {
if let Some(v) = self.col_deps.get_mut(&(s, c)) {
v.remove(r0, r1, k);
self.unsettled_cols.insert((s, c));
}
}
for (s, r) in wide {
if let Some(v) = self.wide_deps.get_mut(&s) {
v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k));
self.unsettled_wide.insert(s);
}
}
self.dynamic.remove(&k);
}
/// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it,
/// just slower).
fn settle(&mut self) {
for key in std::mem::take(&mut self.unsettled_cols) {
if let Some(v) = self.col_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.col_deps.remove(&key);
}
}
}
for key in std::mem::take(&mut self.unsettled_wide) {
if let Some(v) = self.wide_deps.get_mut(&key) {
v.settle();
if v.is_empty() {
self.wide_deps.remove(&key);
}
}
}
}
/// Formulas that read cell `c` of `sheet`.
pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec<Key>) {
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend_from_slice(v);
out.extend(v.iter().copied());
}
if let Some(v) = self.col_deps.get(&(sheet, c.col)) {
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= c.row && c.row <= *r1).map(|(_, _, k)| *k));
v.query(c.row, c.row, &mut |k| out.push(*k));
}
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(c.row, c.row, &mut |(c0, c1, k)| {
if *c0 <= c.col && c.col <= *c1 {
out.push(*k);
}
});
}
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.contains(c)).map(|(_, _, k)| *k));
}
/// Formulas that read any cell of `range`.
/// Formulas that read any cell of `range` (a formula can be listed more than once).
pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec<Key>) {
if range.count() <= 4096 {
for c in range.iter() {
self.dependents(sheet, c, out);
if let Some(v) = self.cell_deps.get(&(sheet, c)) {
out.extend(v.iter().copied());
}
}
return;
}
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend_from_slice(v);
} else {
for ((s, c), v) in &self.cell_deps {
if *s == sheet && range.contains(*c) {
out.extend(v.iter().copied());
}
}
}
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
out.extend(v.iter().filter(|(r0, r1, _)| *r0 <= range.end.row && range.start.row <= *r1).map(|(_, _, k)| *k));
let (r0, r1) = (range.start.row, range.end.row);
if (range.width() as usize) <= self.col_deps.len() {
for col in range.start.col..=range.end.col {
if let Some(v) = self.col_deps.get(&(sheet, col)) {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
} else {
for ((s, col), v) in &self.col_deps {
if *s == sheet && range.start.col <= *col && *col <= range.end.col {
v.query(r0, r1, &mut |k| out.push(*k));
}
}
}
out.extend(self.wide_deps.iter().filter(|(s, r, _)| *s == sheet && r.intersects(&range)).map(|(_, _, k)| *k));
if let Some(v) = self.wide_deps.get(&sheet) {
v.query(r0, r1, &mut |(c0, c1, k)| {
if *c0 <= range.end.col && range.start.col <= *c1 {
out.push(*k);
}
});
}
}
/// Precedent areas of a formula cell (for Trace Precedents).
@ -177,6 +381,9 @@ struct PassHost<'a> {
in_progress: HashSet<Key>,
/// New spills found during the pass: anchor → array.
spills: HashMap<Key, std::sync::Arc<Array>>,
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
/// every row, so once per sheet rather than once per range read.
used: HashMap<usize, Option<RangeRef>>,
rng: &'a mut u64,
now: f64,
cycle: bool,
@ -301,6 +508,10 @@ impl Host for PassHost<'_> {
}
out
}
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
let wb = self.wb;
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
}
fn now_serial(&self) -> f64 {
self.now
}
@ -340,6 +551,7 @@ impl Calc {
}
}
}
self.graph.settle();
}
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
@ -369,6 +581,7 @@ impl Calc {
}
}
}
self.graph.settle();
if wb.calc.mode == CalcMode::Manual {
// Only the edited formulas themselves are evaluated.
let own: Vec<Key> = changed.iter().copied().filter(|k| self.graph.nodes.contains_key(k)).collect();
@ -466,6 +679,7 @@ impl Calc {
pending: exprs.keys().copied().collect(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now,
cycle: false,
@ -582,6 +796,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V
pending: HashSet::default(),
in_progress: HashSet::default(),
spills: HashMap::default(),
used: HashMap::default(),
rng: &mut rng,
now: now_serial(),
cycle: false,
@ -602,3 +817,204 @@ fn prof_now() -> f64 {
0.0
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
use super::{Calc, Intervals, Key};
/// xorshift64 for repeatable pseudo-random cases.
fn rng(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
fn found(iv: &Intervals<u32>, a: u32, b: u32) -> Vec<u32> {
let mut v = Vec::new();
iv.query(a, b, &mut |t| v.push(*t));
v.sort_unstable();
v
}
#[test]
fn intervals_match_a_linear_scan() {
let mut iv: Intervals<u32> = Intervals::default();
let mut model: Vec<(u32, u32, u32)> = Vec::new();
let mut s = 0x9E37_79B9_7F4A_7C15;
for step in 0..20_000u32 {
let roll = rng(&mut s) % 10;
if roll < 6 || model.is_empty() {
let r0 = (rng(&mut s) % 500) as u32;
let r1 = r0 + (rng(&mut s) % 40) as u32;
if !model.contains(&(r0, r1, step)) {
iv.insert(r0, r1, step);
model.push((r0, r1, step));
}
} else {
let i = (rng(&mut s) % model.len() as u64) as usize;
let (r0, r1, t) = model.swap_remove(i);
iv.remove(r0, r1, t);
if roll == 9 {
// Removed and put back before the next merge.
iv.insert(r0, r1, t);
model.push((r0, r1, t));
}
}
if rng(&mut s).is_multiple_of(50) {
iv.settle();
}
if step.is_multiple_of(97) {
let a = (rng(&mut s) % 560) as u32;
let b = a + (rng(&mut s) % 30) as u32;
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= b && *r1 >= a).map(|(_, _, t)| *t).collect();
want.sort_unstable();
assert_eq!(found(&iv, a, b), want, "rows {a}..={b} at step {step}");
}
}
iv.settle();
for row in 0..560 {
let mut want: Vec<u32> = model.iter().filter(|(r0, r1, _)| *r0 <= row && row <= *r1).map(|(_, _, t)| *t).collect();
want.sort_unstable();
assert_eq!(found(&iv, row, row), want, "row {row}");
}
for (r0, r1, t) in std::mem::take(&mut model) {
iv.remove(r0, r1, t);
}
iv.settle();
assert!(iv.is_empty());
}
#[test]
fn interval_queries_cost_log_n_plus_matches() {
// 40k one-row ranges (like =SUM(E1:J1) down a column) and 1k ranges over everything:
// a point query visits O(log n) tree nodes per match, not every range in the column.
let n = 40_000u32;
let mut iv: Intervals<u32> = Intervals::default();
for r in 0..n {
iv.insert(r, r, r);
}
for t in 0..1_000 {
iv.insert(0, n, n + t);
}
iv.settle();
let mut worst = 0;
for row in (0..n).step_by(7) {
let mut k = 0;
let visited = iv.query(row, row, &mut |_| k += 1);
assert_eq!(k, 1_001);
worst = worst.max(visited);
}
// 1,001 matches in a tree of depth 16: well under 4 nodes per match plus the path.
assert!(worst < 4 * 1_001 + 64, "visited {worst} nodes");
let mut k = 0;
let visited = iv.query(n + 1, n + 5, &mut |_| k += 1);
assert_eq!(k, 0);
assert!(visited < 64, "visited {visited} nodes for no match");
}
/// `rows` rows of six numbers (E:J) and `=SUM(E{r}:J{r})` in K.
fn sum_rows(rows: u32) -> Workbook {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
for r in 0..rows {
for c in 4..10 {
sh.cells.set(CellRef::new(r, c), Cell::value(Value::Number(f64::from(r % 97 + c))));
}
sh.cells.set(CellRef::new(r, 10), Cell::formula(Formula::new(&format!("=SUM(E{0}:J{0})", r + 1))));
}
}
wb
}
/// Opening (full recalc), changing every value of a column, and re-entering every formula.
fn open_and_bulk_edit(rows: u32) -> Duration {
let mut wb = sum_rows(rows);
let mut calc = Calc::new();
let t = Instant::now();
calc.recalc_all(&mut wb);
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
if let Some(sh) = wb.sheet_mut(0) {
for (_, c) in &values {
sh.cells.set(*c, Cell::value(Value::Number(1.0)));
}
}
calc.cells_changed(&mut wb, &values);
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
calc.cells_changed(&mut wb, &formulas);
let elapsed = t.elapsed();
let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10)));
let r = rows - 1;
assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::<u32>()))));
elapsed
}
#[test]
fn recalc_with_range_formulas_scales_linearly() {
// Each row's SUM reads a range in the same columns. Recalculating on open used to walk
// every row per range read (the used range), and finding a cell's dependents scanned
// every range in its column: 4x the rows took 16x the time. Linear is 4x; allow 8x.
let best = |rows| (0..3).map(|_| open_and_bulk_edit(rows)).min().unwrap_or_default();
let small = best(5_000);
let large = best(20_000);
assert!(large < small * 8 + Duration::from_millis(100), "5k rows {small:?}, 20k rows {large:?}");
}
#[test]
fn range_dependents_after_edits() {
// Formulas that read ranges, wide ranges and whole columns, found again after edits.
let mut wb = Workbook::new();
let mut calc = Calc::new();
let set = |wb: &mut Workbook, calc: &mut Calc, at: &str, input: &str| {
let c = CellRef::parse(at).unwrap();
let cell = if input.starts_with('=') { Cell::formula(Formula::new(input)) } else { Cell::value(Value::Number(input.parse().unwrap())) };
wb.sheet_mut(0).unwrap().cells.set(c, cell);
calc.cells_changed(wb, &[(0, c)]);
};
let num = |wb: &Workbook, at: &str| {
let v = wb.sheet(0).unwrap().value(CellRef::parse(at).unwrap());
v.as_f64().unwrap_or_else(|| panic!("{at} = {v:?}"))
};
set(&mut wb, &mut calc, "A1", "1");
set(&mut wb, &mut calc, "B2", "2");
set(&mut wb, &mut calc, "CZ3", "3");
set(&mut wb, &mut calc, "DA5", "=SUM(A1:B2)");
set(&mut wb, &mut calc, "DA6", "=SUM(A1:CZ3)");
set(&mut wb, &mut calc, "DA7", "=SUM(B:B)");
set(&mut wb, &mut calc, "DA8", "=SUM(2:2)");
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (3.0, 6.0, 2.0, 2.0));
set(&mut wb, &mut calc, "B2", "20");
assert_eq!((num(&wb, "DA5"), num(&wb, "DA6"), num(&wb, "DA7"), num(&wb, "DA8")), (21.0, 24.0, 20.0, 20.0));
// Re-entering a formula with another range drops the old one.
set(&mut wb, &mut calc, "DA5", "=SUM(A1:A2)");
set(&mut wb, &mut calc, "B2", "5");
assert_eq!(num(&wb, "DA5"), 1.0);
let mut deps = Vec::new();
calc.graph.dependents(0, CellRef::parse("B2").unwrap(), &mut deps);
deps.sort_unstable();
let at = |s: &str| (0, CellRef::parse(s).unwrap());
assert_eq!(deps, vec![at("DA6"), at("DA7"), at("DA8")]);
deps.clear();
calc.graph.dependents_of_range(0, RangeRef::new(CellRef::parse("A1").unwrap(), CellRef::parse("A9").unwrap()), &mut deps);
deps.sort_unstable();
deps.dedup();
assert_eq!(deps, vec![at("DA5"), at("DA6"), at("DA8")]);
set(&mut wb, &mut calc, "CZ3", "30");
assert_eq!(num(&wb, "DA6"), 36.0);
// Ranges on another sheet.
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
set(&mut wb, &mut calc, "DA9", "=SUM(Data!A1:A3)+SUM(Data!A:CZ)");
assert_eq!(num(&wb, "DA9"), 0.0);
wb.sheet_mut(1).unwrap().cells.set(CellRef::parse("A2").unwrap(), Cell::value(Value::Number(4.0)));
calc.cells_changed(&mut wb, &[(1, CellRef::parse("A2").unwrap())]);
assert_eq!(num(&wb, "DA9"), 8.0);
deps.clear();
calc.graph.dependents(1, CellRef::parse("A2").unwrap(), &mut deps);
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
}
}