Skip to main content

truecalc_workbook/
recalc.rs

1//! The recalc engine (plan item 3.3, issue #535): the layer that makes a
2//! [`Workbook`] actually recompute.
3//!
4//! A workbook stores formulas verbatim with their last evaluated result
5//! ([`Value::Empty`] until first recalc, P3.4). Recalc walks the dependency
6//! graph (P3.2), evaluates every formula cell in dependency order through a
7//! grid-backed [`Resolver`] (the core P1.3 seam), and writes each new result
8//! back into the grid — returning the ordered list of [`Change`]s it made.
9//!
10//! # Two modes, one result
11//!
12//! - [`Workbook::recalc`] is a **full** recalc: it evaluates every formula
13//!   cell in topological order.
14//! - [`Workbook::recalc_incremental`] is an **incremental** recalc: given the
15//!   cells an edit touched, it recomputes only their transitive dependents
16//!   (plus all volatile cells, which are always dirty — scope ADR Decision 3),
17//!   reusing the stored results of everything outside that closure.
18//!
19//! Both produce the same grid for the same workbook + context: incremental
20//! recalc is full recalc restricted to the dirty closure, and the property
21//! `recalc_incremental(edits) ≡ recalc()` is asserted by the test suite (the
22//! issue's acceptance criterion).
23//!
24//! # Determinism and `RecalcContext`
25//!
26//! Recalc takes an explicit [`RecalcContext`] (scope ADR Decision 3): the same
27//! workbook + same context produces a byte-identical grid. The context pins the
28//! volatile date functions (`NOW`/`TODAY`) to a fixed instant via core's
29//! `evaluate_with_resolver_at` `now_serial` hook, with the UTC→local serial
30//! conversion done against a **vendored** IANA timezone database (`chrono-tz`),
31//! never the host clock or OS tz tables. See [`RecalcContext`] for the RNG
32//! caveat.
33//!
34//! # Cycles
35//!
36//! A formula cell on a dependency cycle (and any cell the cycle taints) cannot
37//! be evaluated in order; recalc assigns it the Sheets circular-dependency
38//! error without looping forever. Cycle membership comes from the graph's
39//! Tarjan SCC pass ([`DependencyGraph::cycle_cells`]); see [`CIRCULAR_ERROR`].
40
41use std::collections::{BTreeMap, BTreeSet, VecDeque};
42
43use chrono::{NaiveDate, TimeZone, Timelike, Utc};
44use chrono_tz::Tz;
45use icu_casemap::CaseMapperBorrowed;
46use truecalc_core::{Engine, EngineFlavor, ErrorKind, Ref, Resolver, Value as CoreValue};
47
48use crate::address::Address;
49use crate::casefold::simple_fold;
50use crate::cell::Cell;
51use crate::depgraph::{CellRef, DependencyGraph, Precedent, RangeRef};
52use crate::spill::{spill_rect, SpillRect, BLOCKED_SPILL_ERROR};
53use crate::value::Value;
54use crate::workbook::Workbook;
55
56/// The error a cell on (or downstream of) a circular dependency takes.
57///
58/// Google Sheets reports a circular dependency as `#REF!` (surfaced in the UI
59/// as "Circular dependency detected"). A dedicated workbook-level cycle
60/// fixture is not yet in the repo (the P3.6 set covers cross-sheet, named
61/// ranges, and date-type), so this exact code is **not** fixture-pinned here;
62/// the in-repo cycle tests assert the engine's behavior (a cycle is detected,
63/// every cell on it takes this error, and recalc terminates), and the code is
64/// re-verified once a `cycles` fixture lands (issue note).
65pub const CIRCULAR_ERROR: &str = "#REF!";
66
67/// The deterministic context a recalc evaluates against (scope ADR Decision 3).
68///
69/// Same workbook + same `RecalcContext` ⇒ byte-identical recomputed grid. The
70/// context is an **input to recalc**, never part of the workbook value or its
71/// JSON (value-object ADR): two recalcs with different contexts legitimately
72/// differ, and the property tests compare like-context runs only.
73///
74/// # Volatile pinning
75///
76/// - **`NOW()` / `TODAY()`** are pinned: [`timestamp_ms`](Self::timestamp_ms)
77///   (a UTC instant) is converted to a local spreadsheet serial against the
78///   **vendored** [`timezone`](Self::timezone) (`chrono-tz`, not the host tz
79///   database), and that serial is passed to core's
80///   `evaluate_with_resolver_at`. The conversion is the determinism envelope:
81///   same instant + same timezone + same truecalc version ⇒ same serial.
82/// - **`RAND()` / `RANDBETWEEN()` / `RANDARRAY()`** carry a
83///   [`rng_seed`](Self::rng_seed) and a per-cell key helper ([`Self::rng_key`])
84///   implementing the ADR's `prf(seed, sheet_index, row, col, draw_index)`
85///   scheme. **Caveat:** core's RNG functions presently read the system clock
86///   directly and take no per-cell key (`crates/core/.../math/rand`), so the
87///   workbook layer cannot yet inject this seed into them — full PRF-keyed RNG
88///   determinism requires a core change and is tracked for P4. `rng_seed` is
89///   carried now so the API is stable; recalc therefore guarantees determinism
90///   for non-RNG workbooks (which is every P3.6 fixture).
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RecalcContext {
93    /// The evaluation instant, in milliseconds since the Unix epoch (UTC).
94    /// `NOW()`/`TODAY()` derive from this.
95    timestamp_ms: i64,
96    /// The IANA timezone the instant is rendered into a local serial against,
97    /// from the vendored `chrono-tz` snapshot.
98    timezone: Tz,
99    /// Keys the deterministic per-cell RNG draws (ADR `prf(...)`); see the
100    /// type-level caveat about core support.
101    rng_seed: u64,
102}
103
104impl RecalcContext {
105    /// Builds a context from a UTC instant (Unix milliseconds), an IANA
106    /// timezone id (e.g. `"Etc/GMT"`, `"America/New_York"`), and an RNG seed.
107    ///
108    /// Returns `None` if `tz` is not a known IANA id in the vendored database.
109    pub fn new(timestamp_ms: i64, tz: &str, rng_seed: u64) -> Option<Self> {
110        let timezone: Tz = tz.parse().ok()?;
111        Some(Self {
112            timestamp_ms,
113            timezone,
114            rng_seed,
115        })
116    }
117
118    /// The UTC instant this context pins volatile time to (Unix milliseconds).
119    pub fn timestamp_ms(&self) -> i64 {
120        self.timestamp_ms
121    }
122
123    /// The vendored IANA timezone the instant is localized against.
124    pub fn timezone(&self) -> Tz {
125        self.timezone
126    }
127
128    /// The RNG seed keying deterministic per-cell draws.
129    pub fn rng_seed(&self) -> u64 {
130        self.rng_seed
131    }
132
133    /// The local spreadsheet serial datetime this context pins `NOW()`/`TODAY()`
134    /// to: the UTC `timestamp_ms` rendered into `timezone`, expressed as days
135    /// since the 1899-12-30 epoch (integer part) plus time-of-day (fraction) —
136    /// the `now_serial` core's `evaluate_at` family consumes.
137    ///
138    /// Returns `None` only if the instant is unrepresentable (e.g. out of
139    /// `chrono`'s range), which cannot happen for any realistic timestamp.
140    pub fn now_serial(&self) -> Option<f64> {
141        let utc = Utc.timestamp_millis_opt(self.timestamp_ms).single()?;
142        let local = utc.with_timezone(&self.timezone).naive_local();
143        let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)?;
144        let days = local.date().signed_duration_since(epoch).num_days() as f64;
145        let secs = local.time().num_seconds_from_midnight() as f64;
146        Some(days + secs / 86_400.0)
147    }
148
149    /// The pinned "now" as an absolute UTC instant in nanoseconds, for the
150    /// zone-aware `TZNOW`. Derived from the same `timestamp_ms` as
151    /// [`now_serial`](Self::now_serial), so `NOW()` and `TZNOW()` share one
152    /// deterministic clock.
153    pub fn now_utc_nanos(&self) -> Option<i64> {
154        self.timestamp_ms.checked_mul(1_000_000)
155    }
156
157    /// The ADR's per-draw RNG key `prf(rng_seed, sheet_index, row, col,
158    /// draw_index)`, a deterministic, order-independent mixing of the cell
159    /// identity into the seed.
160    ///
161    /// Exposed (and unit-tested) so the keying scheme is fixed and ready for
162    /// the core integration that will consume it; see the type-level caveat.
163    pub fn rng_key(&self, sheet_index: u32, row: u32, col: u32, draw_index: u32) -> u64 {
164        // SplitMix64-style finalizer chained over the identity tuple — pure,
165        // order-independent, and identical across surfaces.
166        let mut h = self.rng_seed;
167        for part in [
168            sheet_index as u64,
169            row as u64,
170            col as u64,
171            draw_index as u64,
172        ] {
173            h = mix64(h ^ mix64(part));
174        }
175        h
176    }
177}
178
179/// One cell whose evaluated value a recalc changed.
180///
181/// Returned (in deterministic order) by [`Workbook::recalc`] and
182/// [`Workbook::recalc_incremental`]: the "change events" of v1, delivered as a
183/// value rather than via a callback (value-object ADR). Ordering is pinned —
184/// by sheet **tab index**, then row, then column (scope ADR Decision 3) — so
185/// the change list is reproducible.
186#[derive(Debug, Clone, PartialEq)]
187pub struct Change {
188    /// The sheet's name (its authored casing).
189    pub sheet: String,
190    /// The recomputed cell's address.
191    pub addr: Address,
192    /// The cell's value before this recalc (the stored result).
193    pub old: Value,
194    /// The cell's value after this recalc.
195    pub new: Value,
196}
197
198impl Workbook {
199    /// Recomputes **every** formula cell in dependency order against `ctx`,
200    /// writing each new result back into the grid and returning the ordered
201    /// list of cells whose value changed.
202    ///
203    /// Formula cells are evaluated in topological order (precedents first), so
204    /// each reads its inputs already current. Cells on a dependency cycle —
205    /// and any cell that cannot be ordered because it (transitively) reads one
206    /// — take the circular-dependency error ([`CIRCULAR_ERROR`]); recalc always
207    /// terminates. Volatile functions are pinned by `ctx` (scope ADR
208    /// Decision 3).
209    ///
210    /// Changes are returned sorted by (sheet tab index, row, column).
211    pub fn recalc(&mut self, ctx: &RecalcContext) -> Vec<Change> {
212        let graph = DependencyGraph::build(self);
213        // Evaluate every formula cell; ordering and cycle handling are shared
214        // with the incremental path.
215        let to_eval: BTreeSet<CellRef> = graph.formula_cells().cloned().collect();
216        self.recompute(&graph, ctx, to_eval)
217    }
218
219    /// Recomputes only the formula cells affected by an edit and returns the
220    /// ordered changes.
221    ///
222    /// `edited` lists the cells a mutation touched (the cell written, or — for
223    /// a named-range retarget — the name's old and new target cells; callers
224    /// pass whatever changed). The recalc closure is the transitive
225    /// [`direct_dependents`](DependencyGraph::direct_dependents_of) of those
226    /// cells, **plus** every volatile formula cell (always dirty, scope ADR
227    /// Decision 3). Everything outside the closure keeps its stored result.
228    ///
229    /// The result is identical to the subset of [`recalc`](Self::recalc)'s
230    /// output for the same edits — the `incremental ≡ full` guarantee.
231    pub fn recalc_incremental(
232        &mut self,
233        ctx: &RecalcContext,
234        edited: &[(String, Address)],
235    ) -> Vec<Change> {
236        let graph = DependencyGraph::build(self);
237        let folder = CaseMapperBorrowed::new();
238
239        // Seed the dirty frontier with the dependents of each edited cell.
240        let mut dirty: BTreeSet<CellRef> = BTreeSet::new();
241        let mut frontier: VecDeque<CellRef> = VecDeque::new();
242        for (sheet, addr) in edited {
243            let folded = simple_fold(&folder, sheet);
244            let seed = CellRef {
245                sheet: folded,
246                addr: *addr,
247            };
248            // The edited cell itself recomputes only if it is a formula; its
249            // dependents always do.
250            if graph.is_formula(&seed) && dirty.insert(seed.clone()) {
251                frontier.push_back(seed.clone());
252            }
253            for dep in graph.direct_dependents_of(&seed) {
254                if dirty.insert(dep.clone()) {
255                    frontier.push_back(dep);
256                }
257            }
258        }
259        // Transitive closure over the formula-cell dependents.
260        while let Some(cell) = frontier.pop_front() {
261            for dep in graph.direct_dependents_of(&cell) {
262                if dirty.insert(dep.clone()) {
263                    frontier.push_back(dep);
264                }
265            }
266        }
267        // Volatile cells are always dirty (scope ADR Decision 3).
268        for cell in graph.formula_cells() {
269            if self.is_volatile(cell) {
270                dirty.insert(cell.clone());
271            }
272        }
273
274        // Spill-occupancy seeding (issue #591). A cell's spill footprint or
275        // blocked status can change without the dependency graph carrying an
276        // edge that would dirty the cells depending on that change, because a
277        // spilled cell is not a formula node (P3.2) and a *blocked* anchor
278        // stores an error rather than an array that reads its blocker. Two
279        // concrete violations of `incremental ≡ full` (P3.3) follow:
280        //
281        //  - **Shrink / replace-with-scalar.** Setting a former array anchor to
282        //    a scalar vacates its old footprint, but `set` has already discarded
283        //    the prior array, so the widen loop's `before = anchor_rectangles()`
284        //    no longer sees the old rectangle and never dirties the readers of
285        //    the vacated cells (e.g. `D1 = =B1+1` after `A1` stops spilling onto
286        //    `B1`).
287        //  - **Unblock.** Clearing or overwriting the cell that blocks a spill
288        //    must let the anchor re-expand, but a blocked anchor has no edge to
289        //    its blocker, so clearing the blocker never re-dirties the anchor.
290        //
291        // Seeding the dirty set with every spill-occupancy-sensitive cell makes
292        // the closure independent of which edit triggered the recalc, so the
293        // result matches a full recalc despite the lost pre-edit footprint.
294        // Over-seeding is safe: a re-evaluated cell whose value is unchanged
295        // emits no change event (`diff_against_snapshot`), so `incremental ≡
296        // full` is preserved while the minimal-closure guarantee still holds for
297        // ordinary (non-spill) edits, which seed nothing here.
298        self.seed_spill_sensitive(&graph, &mut dirty);
299
300        // A cell that reads a *spilled* cell has no dependency-graph edge to its
301        // spilling anchor (a spilled cell is not a formula node, P3.2), so the
302        // closure above can miss a spilled-cell reader when an anchor's spill
303        // footprint changes. We widen the dirty set to those readers and re-run
304        // until it stabilizes, so an incremental recalc reproduces the full one
305        // (`incremental ≡ full`, P3.3) even across spills (§5).
306        //
307        // To return change events with correct *pre-operation* `old` values
308        // despite the multiple internal recomputes, snapshot every formula
309        // cell's value first, then recompute over the (growing) dirty set until
310        // no anchor's spill footprint changes, and finally diff the resulting
311        // grid against the snapshot. The loop is bounded by the formula-cell
312        // count (each pass strictly grows the dirty set or stops).
313        let pre = self.snapshot_formula_values(&graph);
314        let max_widen = graph.formula_cells().count().saturating_add(2).max(1);
315        for _ in 0..max_widen {
316            let before = self.anchor_rectangles();
317            self.recompute(&graph, ctx, dirty.clone());
318            let after = self.anchor_rectangles();
319
320            let mut added = false;
321            for (sheet, addr) in changed_rectangle_cells(&before, &after) {
322                let spilled_ref = CellRef { sheet, addr };
323                for dep in graph.direct_dependents_of(&spilled_ref) {
324                    if dirty.insert(dep) {
325                        added = true;
326                    }
327                }
328            }
329            if !added {
330                break;
331            }
332        }
333        self.diff_against_snapshot(pre)
334    }
335
336    /// Shared evaluation core: evaluates `to_eval` (a set of formula cells) in
337    /// dependency order through a grid-backed resolver, applies cycle errors,
338    /// writes results back, and returns the changes in pinned order.
339    fn recompute(
340        &mut self,
341        graph: &DependencyGraph,
342        ctx: &RecalcContext,
343        to_eval: BTreeSet<CellRef>,
344    ) -> Vec<Change> {
345        let now_serial = ctx.now_serial();
346        let now_utc_nanos = ctx.now_utc_nanos();
347        let rng_seed = ctx.rng_seed();
348
349        // Cells on a cycle short-circuit to the circular error; the rest are
350        // evaluated in topological order. `topological_order` returns the full
351        // order when acyclic, else the cycle set; we always have the cycle set
352        // available via `cycle_cells` for the tainted-downstream case.
353        let cycle = graph.cycle_cells();
354        let order = match graph.topological_order() {
355            Ok(order) => order,
356            Err(_) => {
357                // The graph has a cycle. Build a best-effort order over the
358                // acyclic remainder by stripping cycle nodes, so cells that do
359                // not touch the cycle still evaluate; cycle-tainted cells fall
360                // out as the error below.
361                graph.acyclic_order_excluding(&cycle)
362            }
363        };
364
365        // Evaluate in order, resolving array spills as we go (plan item 3.5,
366        // schema spec §5). `new_values` holds each formula's result — a spilling
367        // anchor stores its full `array` (its serialized form, §6); a blocked
368        // anchor stores the Sheets blocked-spill error and no array. `spills`
369        // records the rectangle each *successfully placed* anchor occupies, so
370        // (a) a later anchor competing for one of its cells blocks, and (b) the
371        // resolver returns spilled values to cells that read them (spilled cells
372        // participate in recalc as precedents, §5).
373        //
374        // A cell that *reads* a spilled cell has no dependency-graph edge to the
375        // spilling anchor (a spilled cell is not a formula node, P3.2), so the
376        // topological order does not guarantee the anchor is evaluated first. We
377        // therefore iterate the pass to a fixpoint: each pass re-evaluates every
378        // `to_eval` cell against the prior pass's spills, so a reader that ran
379        // before its anchor in one pass sees the spilled value in the next. The
380        // grid is finite and spill geometry is monotone (an anchor's array
381        // depends only on its own non-spilled precedents), so this converges; we
382        // cap the iteration count at the node count as a hard safety bound.
383        //
384        // Seed the "previous pass" state from the stored grid so an *incremental*
385        // recalc — whose `to_eval` is only the dirty closure — still resolves a
386        // read of a cell spilled by an anchor that is **not** dirty this pass:
387        // that anchor's array is already on the grid, so its spill rectangle is
388        // available as a fallback even though it is never re-placed this recalc.
389        // A full recalc re-places every anchor, overriding the seed.
390        let (mut new_values, mut spills) = self.seed_spills_from_grid();
391        let max_passes = order.len().saturating_add(2).max(1);
392        for _ in 0..max_passes {
393            let mut next_values: BTreeMap<CellRef, Value> = BTreeMap::new();
394            let mut next_spills: BTreeMap<CellRef, SpillRect> = BTreeMap::new();
395            for cell in &order {
396                if cycle.contains(cell) {
397                    continue; // handled in the cycle pass below
398                }
399                if !to_eval.contains(cell) {
400                    continue;
401                }
402                // Evaluate against this pass's values/spills placed so far, with
403                // the *previous* pass's values/spills as a fallback. The
404                // fallback is what lets a reader that comes *before* its spill
405                // anchor in the order still see the spilled value: the anchor
406                // placed its spill in the previous pass, so the reader resolves
407                // it from `prev_*` even though `next_*` has not reached the
408                // anchor yet this pass.
409                let raw = self.eval_formula_cell(
410                    cell,
411                    now_serial,
412                    now_utc_nanos,
413                    rng_seed,
414                    &next_values,
415                    &next_spills,
416                    &new_values,
417                    &spills,
418                    &cycle,
419                    &to_eval,
420                );
421                // Resolve array results into a placed spill or a blocked-spill
422                // error; a placed spill records its rectangle so later anchors
423                // and readers see it. Occupancy is judged against authored cells
424                // and the spills placed so far this pass.
425                let stored = self.place_spill(cell, raw, &next_values, &mut next_spills);
426                next_values.insert(cell.clone(), stored);
427            }
428            let converged = next_values == new_values && next_spills == spills;
429            new_values = next_values;
430            spills = next_spills;
431            if converged {
432                break;
433            }
434        }
435        // Cycle cells (and downstream cells the order could not place) take the
436        // circular error.
437        for cell in &to_eval {
438            if !new_values.contains_key(cell) {
439                new_values.insert(cell.clone(), Value::Error(CIRCULAR_ERROR.to_owned()));
440            }
441        }
442
443        self.apply_changes(new_values)
444    }
445
446    /// Evaluates a single formula cell through a resolver that reads the *new*
447    /// values computed so far this recalc, falling back to the stored grid for
448    /// everything else.
449    #[allow(clippy::too_many_arguments)]
450    fn eval_formula_cell(
451        &self,
452        cell: &CellRef,
453        now_serial: Option<f64>,
454        now_utc_nanos: Option<i64>,
455        rng_seed: u64,
456        new_values: &BTreeMap<CellRef, Value>,
457        spills: &BTreeMap<CellRef, SpillRect>,
458        prev_values: &BTreeMap<CellRef, Value>,
459        prev_spills: &BTreeMap<CellRef, SpillRect>,
460        cycle: &BTreeSet<CellRef>,
461        recomputed: &BTreeSet<CellRef>,
462    ) -> Value {
463        let formula = match self.cell_at(cell).and_then(Cell::formula) {
464            Some(f) => f.to_owned(),
465            None => return Value::Empty,
466        };
467        let engine = match self.engine() {
468            EngineFlavor::Sheets => Engine::sheets(),
469            EngineFlavor::Excel => Engine::excel(),
470        };
471        let folder = CaseMapperBorrowed::new();
472        let sheet_index = self
473            .sheets()
474            .iter()
475            .position(|ws| simple_fold(&folder, ws.name()) == cell.sheet)
476            .unwrap_or(0) as u32;
477        let rng_cell = Some((rng_seed, sheet_index, cell.addr.row, cell.addr.column));
478        let mut resolver = GridResolver {
479            workbook: self,
480            own_sheet: &cell.sheet,
481            new_values,
482            spills,
483            prev_values,
484            prev_spills,
485            cycle,
486            recomputed,
487        };
488        let core = engine.evaluate_with_resolver_at_keyed(
489            &formula,
490            &mut resolver,
491            now_serial,
492            now_utc_nanos,
493            rng_cell,
494        );
495        core_to_workbook(core)
496    }
497
498    /// Turns a freshly evaluated formula result into its **stored** value,
499    /// applying Sheets spill semantics (plan item 3.5, schema spec §5).
500    ///
501    /// A non-array result is stored verbatim. An array result is a spill anchor:
502    /// it occupies the `m × n` rectangle anchored at `cell`. If every non-anchor
503    /// cell of that rectangle is free — not authored, and not already claimed by
504    /// an earlier anchor's placed spill (`placed`) — and the rectangle stays in
505    /// the sheet's address bounds, the spill is *placed*: its rectangle is
506    /// recorded in `placed` and the anchor stores the full array (its serialized
507    /// form, §6; the spilled cells are reconstructed, never serialized). If any
508    /// target is occupied or the rectangle is out of bounds, the spill is
509    /// **blocked**: the anchor takes the Sheets blocked-spill error
510    /// ([`BLOCKED_SPILL_ERROR`]) and stores no array (§5, §12).
511    fn place_spill(
512        &self,
513        cell: &CellRef,
514        value: Value,
515        new_values: &BTreeMap<CellRef, Value>,
516        placed: &mut BTreeMap<CellRef, SpillRect>,
517    ) -> Value {
518        let Value::Array(ref rows) = value else {
519            return value; // scalar result: stored as-is
520        };
521        let nrows = rows.len();
522        let ncols = rows.first().map_or(0, Vec::len);
523        // `core_array_to_workbook` guarantees a rectangular, ≥ 2-cell array.
524        let Some(rect) = spill_rect(cell.addr, nrows, ncols) else {
525            // Out-of-bounds rectangle is blocked (§5).
526            return Value::Error(BLOCKED_SPILL_ERROR.to_owned());
527        };
528        if self.spill_blocked(cell, &rect, new_values, placed) {
529            return Value::Error(BLOCKED_SPILL_ERROR.to_owned());
530        }
531        placed.insert(cell.clone(), rect);
532        value
533    }
534
535    /// Whether the spill `rect` anchored at `cell` is blocked: any non-anchor
536    /// cell of the rectangle is authored on that sheet, is itself an evaluated
537    /// formula in this recalc (`new_values`), or already lies in an earlier
538    /// anchor's placed spill (`placed`). Schema spec §5.
539    fn spill_blocked(
540        &self,
541        cell: &CellRef,
542        rect: &SpillRect,
543        new_values: &BTreeMap<CellRef, Value>,
544        placed: &BTreeMap<CellRef, SpillRect>,
545    ) -> bool {
546        for addr in rect.spilled_cells() {
547            let target = CellRef {
548                sheet: cell.sheet.clone(),
549                addr,
550            };
551            // An authored cell in the way (literal or formula).
552            if self.cell_at(&target).is_some() {
553                return true;
554            }
555            // A formula cell evaluated this recalc that is not itself authored
556            // in the grid cannot exist, but a formula reader could be in
557            // `new_values`; treat any computed cell here as occupied for safety.
558            if new_values.contains_key(&target) {
559                return true;
560            }
561            // A cell already claimed by an earlier anchor's spill.
562            if placed
563                .values()
564                .any(|r| r.anchor != cell.addr && r.contains(addr))
565            {
566                return true;
567            }
568        }
569        false
570    }
571
572    /// Builds the spill state implied by the **stored** grid: every authored
573    /// cell whose stored value is an `array` is a spill anchor occupying its
574    /// reconstructed rectangle (schema spec §5). Returns the anchor → array map
575    /// and the anchor → rectangle map, used to seed an incremental recalc so a
576    /// read of a spilled cell whose anchor is not dirty this pass still resolves
577    /// (the anchor placed the spill in a prior recalc). An out-of-bounds stored
578    /// array — which a valid document never contains (`from_json` rejects it,
579    /// validate.rs §5) — is skipped.
580    fn seed_spills_from_grid(&self) -> (BTreeMap<CellRef, Value>, BTreeMap<CellRef, SpillRect>) {
581        let folder = CaseMapperBorrowed::new();
582        let mut values: BTreeMap<CellRef, Value> = BTreeMap::new();
583        let mut spills: BTreeMap<CellRef, SpillRect> = BTreeMap::new();
584        for sheet in self.sheets() {
585            let folded = simple_fold(&folder, sheet.name());
586            for (addr, cell) in sheet.iter() {
587                let Value::Array(rows) = cell.value() else {
588                    continue;
589                };
590                let nrows = rows.len();
591                let ncols = rows.first().map_or(0, Vec::len);
592                if let Some(rect) = spill_rect(addr, nrows, ncols) {
593                    let key = CellRef {
594                        sheet: folded.clone(),
595                        addr,
596                    };
597                    values.insert(key.clone(), cell.value().clone());
598                    spills.insert(key, rect);
599                }
600            }
601        }
602        (values, spills)
603    }
604
605    /// Writes the recomputed values back, emitting a [`Change`] for each cell
606    /// whose value actually changed, in pinned (sheet index, row, column) order.
607    fn apply_changes(&mut self, new_values: BTreeMap<CellRef, Value>) -> Vec<Change> {
608        let folder = CaseMapperBorrowed::new();
609        // Resolve folded sheet names to tab index + authored name once.
610        let mut changes: Vec<(usize, Change)> = Vec::new();
611        for (cell, new) in new_values {
612            let Some(idx) = self.sheet_index_folded(&folder, &cell.sheet) else {
613                continue; // sheet vanished (cannot happen mid-recalc)
614            };
615            let sheet_name = self.sheets()[idx].name().to_owned();
616            let old = self.sheets()[idx]
617                .get(cell.addr)
618                .map(|c| c.value().clone())
619                .unwrap_or(Value::Empty);
620            if old == new {
621                continue;
622            }
623            // Preserve the formula text; only the stored value updates.
624            let formula = self.sheets()[idx]
625                .get(cell.addr)
626                .and_then(|c| c.formula())
627                .map(str::to_owned);
628            if let Some(formula) = formula {
629                self.sheets_mut()[idx].set(cell.addr, Cell::with_formula(formula, new.clone()));
630            }
631            changes.push((
632                idx,
633                Change {
634                    sheet: sheet_name,
635                    addr: cell.addr,
636                    old,
637                    new,
638                },
639            ));
640        }
641        // Pin order: sheet tab index, then row, then column.
642        changes.sort_by(|a, b| {
643            a.0.cmp(&b.0)
644                .then(a.1.addr.row.cmp(&b.1.addr.row))
645                .then(a.1.addr.column.cmp(&b.1.addr.column))
646        });
647        changes.into_iter().map(|(_, c)| c).collect()
648    }
649
650    /// Whether `cell`'s formula calls any volatile function (`NOW`, `TODAY`,
651    /// `RAND`, `RANDBETWEEN`, `RANDARRAY` — core's `VOLATILE_FUNCTIONS`).
652    /// A volatile cell is always dirty in incremental recalc.
653    fn is_volatile(&self, cell: &CellRef) -> bool {
654        let Some(formula) = self.cell_at(cell).and_then(Cell::formula) else {
655            return false;
656        };
657        let upper = formula.to_ascii_uppercase();
658        truecalc_core::Registry::VOLATILE_FUNCTIONS
659            .iter()
660            .any(|name| contains_call(&upper, name))
661    }
662
663    /// Every formula cell's current stored value, keyed by [`CellRef`]. The
664    /// pre-operation snapshot an incremental recalc diffs its final grid against
665    /// to emit change events with correct `old` values despite internal
666    /// re-recomputes (spill widening).
667    fn snapshot_formula_values(&self, graph: &DependencyGraph) -> BTreeMap<CellRef, Value> {
668        let mut snap = BTreeMap::new();
669        for cell in graph.formula_cells() {
670            let value = self
671                .cell_at(cell)
672                .map(|c| c.value().clone())
673                .unwrap_or(Value::Empty);
674            snap.insert(cell.clone(), value);
675        }
676        snap
677    }
678
679    /// Adds every spill-occupancy-sensitive formula cell to `dirty` (issue
680    /// #591), so an incremental recalc reproduces a full recalc across any spill
681    /// footprint or blocked-status change even though the dependency graph
682    /// carries no edge for those transitions and `set` discarded the pre-edit
683    /// footprint.
684    ///
685    /// A cell is seeded when it is, or reads something that can become or cease
686    /// being, a spill:
687    ///
688    ///  1. **Every array anchor** (a formula cell whose stored value is an
689    ///     array) — re-placed so a footprint that should shrink or grow does so,
690    ///     and so a write into its region re-blocks it.
691    ///  2. **Every blocked-spill anchor** (a formula cell whose stored value is
692    ///     the blocked-spill error) — re-attempted so clearing/overwriting its
693    ///     blocker lets it re-expand (the unblock case).
694    ///  3. **Every reader of a non-authored single cell** — that precedent is
695    ///     empty or spilled today and may flip either way, e.g. `D1 = =B1+1`
696    ///     whose `B1` was spilled by a now-shrunk anchor (the vacated-reader
697    ///     case), or a reader of a cell a spill is about to grow onto.
698    ///  4. **Every reader of a range that overlaps a current spill rectangle** —
699    ///     a range aggregation whose window includes spilled cells, so a change
700    ///     to that spill (grow/shrink/block) re-aggregates.
701    ///
702    /// The blocked-spill error string equals [`BLOCKED_SPILL_ERROR`]; a cell
703    /// merely *holding* that error that is not actually a former/blocked spill
704    /// anchor is harmless to re-evaluate (it recomputes to the same value).
705    fn seed_spill_sensitive(&self, graph: &DependencyGraph, dirty: &mut BTreeSet<CellRef>) {
706        let rects = self.anchor_rectangles();
707        for cell in graph.formula_cells() {
708            // (1)/(2): the cell itself is (or held) a spill.
709            let is_spill_cell = match self.cell_at(cell).map(Cell::value) {
710                Some(Value::Array(_)) => true,
711                Some(Value::Error(code)) | Some(Value::ErrorMsg(code, _)) => {
712                    code == BLOCKED_SPILL_ERROR
713                }
714                _ => false,
715            };
716            let mut seed = is_spill_cell;
717            // (3)/(4): the cell reads a spill-sensitive precedent.
718            if !seed {
719                if let Some(precedents) = graph.precedents_of(cell) {
720                    seed = precedents
721                        .iter()
722                        .any(|p| self.precedent_is_spill_sensitive(p, &rects));
723                }
724            }
725            if seed {
726                dirty.insert(cell.clone());
727            }
728        }
729    }
730
731    /// Whether a single precedent reads a cell that is, or could become, a
732    /// spilled cell (issue #591): a non-authored single-cell target (empty or
733    /// spilled today), or a range overlapping a current spill rectangle.
734    fn precedent_is_spill_sensitive(
735        &self,
736        precedent: &Precedent,
737        rects: &BTreeMap<CellRef, SpillRect>,
738    ) -> bool {
739        match precedent {
740            // A single-cell precedent that is not authored is empty or spilled
741            // today, and may flip either way (grow/shrink/block/unblock).
742            Precedent::Cell(c) => self.cell_at(c).is_none(),
743            // A range precedent is spill-sensitive if it overlaps a current
744            // spill rectangle (a spill could grow/shrink/block within it) *or*
745            // if it contains any non-authored cell — which catches a cell a
746            // spill *used to* cover but no longer does (the lost pre-edit
747            // footprint of a shrink/collapse), since that cell is now empty.
748            Precedent::Range(r) => {
749                rects
750                    .iter()
751                    .any(|(anchor, rect)| anchor.sheet == r.sheet && rect_overlaps_range(rect, r))
752                    || self.range_has_unauthored_cell(r)
753            }
754            // A name resolves to a cell or range; treat it conservatively as
755            // spill-sensitive so a name pointing at a spilled cell still seeds
756            // its reader. Names are rare and this only widens the dirty set.
757            Precedent::Name(_) => true,
758            Precedent::Unresolved(_) => false,
759        }
760    }
761
762    /// Whether the range `r` contains at least one cell that is **not** an
763    /// authored cell (empty or spilled). Computed by comparing the range's area
764    /// to the number of authored cells inside it — so the cost is bounded by the
765    /// sheet's populated-cell count, never the range area (issue #591).
766    fn range_has_unauthored_cell(&self, r: &RangeRef) -> bool {
767        let folder = CaseMapperBorrowed::new();
768        let Some(sheet) = self
769            .sheets()
770            .iter()
771            .find(|s| simple_fold(&folder, s.name()) == r.sheet)
772        else {
773            // The range targets a missing sheet; nothing authored, so it is
774            // (vacuously) all-unauthored — seed conservatively.
775            return true;
776        };
777        let rows = (r.end.row - r.start.row + 1) as u64;
778        let cols = (r.end.column - r.start.column + 1) as u64;
779        let area = rows.saturating_mul(cols);
780        let authored_inside = sheet
781            .iter()
782            .filter(|(addr, _)| {
783                addr.row >= r.start.row
784                    && addr.row <= r.end.row
785                    && addr.column >= r.start.column
786                    && addr.column <= r.end.column
787            })
788            .count() as u64;
789        authored_inside < area
790    }
791
792    /// Every spill rectangle currently on the stored grid (anchor → rectangle),
793    /// derived from authored cells whose stored value is an array (schema spec
794    /// §5). Used to detect when an incremental recompute changed a spill
795    /// footprint so the affected readers can be dirtied.
796    fn anchor_rectangles(&self) -> BTreeMap<CellRef, SpillRect> {
797        let folder = CaseMapperBorrowed::new();
798        let mut rects = BTreeMap::new();
799        for sheet in self.sheets() {
800            let folded = simple_fold(&folder, sheet.name());
801            for (addr, cell) in sheet.iter() {
802                let Value::Array(rows) = cell.value() else {
803                    continue;
804                };
805                let nrows = rows.len();
806                let ncols = rows.first().map_or(0, Vec::len);
807                if let Some(rect) = spill_rect(addr, nrows, ncols) {
808                    rects.insert(
809                        CellRef {
810                            sheet: folded.clone(),
811                            addr,
812                        },
813                        rect,
814                    );
815                }
816            }
817        }
818        rects
819    }
820
821    /// Emits the change list for an incremental recalc by diffing the final grid
822    /// against the pre-operation `snapshot`: one [`Change`] per formula cell
823    /// whose value differs, in the pinned (sheet tab index, row, column) order.
824    fn diff_against_snapshot(&self, snapshot: BTreeMap<CellRef, Value>) -> Vec<Change> {
825        let folder = CaseMapperBorrowed::new();
826        let mut changes: Vec<(usize, Change)> = Vec::new();
827        for (cell, old) in snapshot {
828            let Some(idx) = self.sheet_index_folded(&folder, &cell.sheet) else {
829                continue;
830            };
831            let new = self.sheets()[idx]
832                .get(cell.addr)
833                .map(|c| c.value().clone())
834                .unwrap_or(Value::Empty);
835            if old == new {
836                continue;
837            }
838            changes.push((
839                idx,
840                Change {
841                    sheet: self.sheets()[idx].name().to_owned(),
842                    addr: cell.addr,
843                    old,
844                    new,
845                },
846            ));
847        }
848        changes.sort_by(|a, b| {
849            a.0.cmp(&b.0)
850                .then(a.1.addr.row.cmp(&b.1.addr.row))
851                .then(a.1.addr.column.cmp(&b.1.addr.column))
852        });
853        changes.into_iter().map(|(_, c)| c).collect()
854    }
855
856    /// The cell at a [`CellRef`] (folded sheet + address), or `None`.
857    fn cell_at(&self, cell: &CellRef) -> Option<&Cell> {
858        let folder = CaseMapperBorrowed::new();
859        let idx = self.sheet_index_folded(&folder, &cell.sheet)?;
860        self.sheets()[idx].get(cell.addr)
861    }
862
863    /// Tab index of the sheet whose folded name equals `folded`.
864    fn sheet_index_folded(
865        &self,
866        folder: &CaseMapperBorrowed<'static>,
867        folded: &str,
868    ) -> Option<usize> {
869        self.sheets()
870            .iter()
871            .position(|s| simple_fold(folder, s.name()) == folded)
872    }
873}
874
875/// A [`Resolver`] backed by the workbook grid, reading the values computed so
876/// far this recalc before falling back to the stored grid.
877struct GridResolver<'a> {
878    workbook: &'a Workbook,
879    own_sheet: &'a str,
880    new_values: &'a BTreeMap<CellRef, Value>,
881    /// Spills placed so far **this pass** (anchor → rectangle): a read of a cell
882    /// inside one of these rectangles resolves to the spilled array element
883    /// (schema spec §5 — spilled cells participate as precedents).
884    spills: &'a BTreeMap<CellRef, SpillRect>,
885    /// The **previous** pass's values, used as a fallback so a reader ordered
886    /// before its spill anchor still sees the spilled value (the anchor placed
887    /// it last pass). Empty on the first pass.
888    prev_values: &'a BTreeMap<CellRef, Value>,
889    /// The previous pass's spills (same fallback role as `prev_values`).
890    prev_spills: &'a BTreeMap<CellRef, SpillRect>,
891    cycle: &'a BTreeSet<CellRef>,
892    /// The formula cells being recomputed this recalc (`to_eval`). The stored
893    /// grid still holds these anchors' *pre-recalc* arrays until `apply_changes`
894    /// runs, so the `grid_spilled_value` fallback must ignore an anchor in this
895    /// set: its authoritative spill state for this recalc is the per-pass
896    /// `spills`/`prev_spills`, not the stale grid (issue #591 — otherwise a
897    /// reader of a cell an anchor *stops* spilling onto, e.g. when the anchor
898    /// blocks or shrinks, would resolve the vacated cell from the obsolete
899    /// stored array).
900    recomputed: &'a BTreeSet<CellRef>,
901}
902
903impl GridResolver<'_> {
904    /// The current value of a resolved cell: this recalc's fresh value if it
905    /// was already computed, else the stored grid value, else empty. A cell on
906    /// a cycle resolves to the circular error (so a cell that *reads* a cycle
907    /// inherits the taint).
908    fn cell_value(&self, sheet_folded: &str, addr: Address) -> CoreValue {
909        let key = CellRef {
910            sheet: sheet_folded.to_owned(),
911            addr,
912        };
913        if self.cycle.contains(&key) {
914            return CoreValue::Error(ErrorKind::Ref);
915        }
916        if let Some(v) = self.new_values.get(&key) {
917            return workbook_to_core(v);
918        }
919        let folder = CaseMapperBorrowed::new();
920        if let Some(c) = self
921            .workbook
922            .sheets()
923            .iter()
924            .find(|s| simple_fold(&folder, s.name()) == sheet_folded)
925            .and_then(|s| s.get(addr))
926        {
927            return workbook_to_core(c.value());
928        }
929        // Not authored and not freshly computed: it may be a spilled cell of an
930        // anchor placed this pass — or, if the anchor is ordered *after* this
931        // reader, of the previous pass (schema spec §5). Resolve through the
932        // spill, preferring this pass's placement.
933        if let Some(v) = self.spilled_value(sheet_folded, addr, self.spills, self.new_values) {
934            return workbook_to_core(&v);
935        }
936        if let Some(v) = self.spilled_value(sheet_folded, addr, self.prev_spills, self.prev_values)
937        {
938            return workbook_to_core(&v);
939        }
940        // A cell whose value the previous pass computed but this pass has not
941        // reached yet (a reader's plain-cell precedent ordered after it).
942        if let Some(v) = self.prev_values.get(&key) {
943            return workbook_to_core(v);
944        }
945        // Final fallback (matters for *incremental* recalc): the cell may be
946        // spilled by an anchor that is not dirty this recalc, so it never enters
947        // the per-pass maps. Its array is on the stored grid; reconstruct the
948        // element directly (schema spec §5).
949        if let Some(v) = self.grid_spilled_value(sheet_folded, addr) {
950            return workbook_to_core(&v);
951        }
952        CoreValue::Empty
953    }
954
955    /// The value spilled to `addr` on `sheet_folded` per the **stored grid**:
956    /// scans authored anchors whose stored value is an array and reconstructs
957    /// the element (schema spec §5). Used as the incremental-recalc fallback for
958    /// spills whose anchor is not re-evaluated this pass.
959    fn grid_spilled_value(&self, sheet_folded: &str, addr: Address) -> Option<Value> {
960        let folder = CaseMapperBorrowed::new();
961        let sheet = self
962            .workbook
963            .sheets()
964            .iter()
965            .find(|s| simple_fold(&folder, s.name()) == sheet_folded)?;
966        for (anchor_addr, cell) in sheet.iter() {
967            if anchor_addr == addr {
968                continue;
969            }
970            // An anchor being recomputed this recalc has its current spill state
971            // in the per-pass maps; its stored array is stale until
972            // `apply_changes`, so never resolve through it here (issue #591).
973            let anchor_key = CellRef {
974                sheet: sheet_folded.to_owned(),
975                addr: anchor_addr,
976            };
977            if self.recomputed.contains(&anchor_key) {
978                continue;
979            }
980            let Value::Array(rows) = cell.value() else {
981                continue;
982            };
983            let nrows = rows.len();
984            let ncols = rows.first().map_or(0, Vec::len);
985            let Some(rect) = crate::spill::spill_rect(anchor_addr, nrows, ncols) else {
986                continue;
987            };
988            if let Some((i, j)) = rect.offset_of(addr) {
989                return rows.get(i).and_then(|r| r.get(j)).cloned();
990            }
991        }
992        None
993    }
994
995    /// The value spilled to `addr` on `sheet_folded` per a given `spills` map
996    /// and its backing `values`: the `[i][j]` element of the anchor's stored
997    /// array (schema spec §5). `None` if `addr` is not a non-anchor cell of any
998    /// spill in `spills`.
999    fn spilled_value(
1000        &self,
1001        sheet_folded: &str,
1002        addr: Address,
1003        spills: &BTreeMap<CellRef, SpillRect>,
1004        values: &BTreeMap<CellRef, Value>,
1005    ) -> Option<Value> {
1006        for (anchor, rect) in spills {
1007            if anchor.sheet != sheet_folded {
1008                continue;
1009            }
1010            if anchor.addr == addr {
1011                continue; // the anchor itself is in `values`
1012            }
1013            let Some((i, j)) = rect.offset_of(addr) else {
1014                continue;
1015            };
1016            if let Some(Value::Array(rows)) = values.get(anchor) {
1017                return rows.get(i).and_then(|r| r.get(j)).cloned();
1018            }
1019        }
1020        None
1021    }
1022
1023    /// Resolves the folded target sheet name for a `Ref`'s optional sheet
1024    /// qualifier, or `None` if the named sheet does not exist.
1025    fn target_sheet(&self, sheet: &Option<String>) -> Option<String> {
1026        let folder = CaseMapperBorrowed::new();
1027        match sheet {
1028            None => Some(self.own_sheet.to_owned()),
1029            Some(name) => self
1030                .workbook
1031                .sheet(name)
1032                .map(|s| simple_fold(&folder, s.name())),
1033        }
1034    }
1035}
1036
1037impl Resolver for GridResolver<'_> {
1038    fn resolve(&mut self, r: &Ref) -> CoreValue {
1039        match r {
1040            Ref::Cell { sheet, addr } => {
1041                let Some(target) = self.target_sheet(sheet) else {
1042                    return CoreValue::Error(ErrorKind::Ref);
1043                };
1044                match Address::new(addr.row, addr.col) {
1045                    Some(a) => self.cell_value(&target, a),
1046                    None => CoreValue::Error(ErrorKind::Ref),
1047                }
1048            }
1049            Ref::Range { sheet, start, end } => {
1050                let Some(target) = self.target_sheet(sheet) else {
1051                    return CoreValue::Error(ErrorKind::Ref);
1052                };
1053                self.resolve_range(&target, start, end)
1054            }
1055            Ref::Name(name) => {
1056                // Resolve the name to its canonical ref, then resolve that.
1057                let folder = CaseMapperBorrowed::new();
1058                let folded = simple_fold(&folder, name);
1059                let target = self
1060                    .workbook
1061                    .names()
1062                    .iter()
1063                    .find(|nr| simple_fold(&folder, &nr.name) == folded);
1064                match target {
1065                    None => CoreValue::Error(ErrorKind::Name),
1066                    // Re-parse the name's canonical `Sheet!A1` ref so a name
1067                    // pointing at a cell or a range resolves identically to a
1068                    // literal ref of the same shape.
1069                    Some(nr) => self.resolve_name_ref(&nr.r#ref),
1070                }
1071            }
1072        }
1073    }
1074}
1075
1076impl GridResolver<'_> {
1077    /// Materializes a range as a core `Value::Array` of its cells in row-major
1078    /// reading order — the flat shape the P1.3 [`Resolver`] contract specifies
1079    /// ("a range -> a Value::Array of the cells in reading order") and the shape
1080    /// core's aggregations (SUM/AVERAGE/COUNT/SUMIF) and shape functions
1081    /// consume. The own/target sheet has already been resolved.
1082    fn resolve_range(
1083        &self,
1084        sheet_folded: &str,
1085        start: &truecalc_core::CellAddr,
1086        end: &truecalc_core::CellAddr,
1087    ) -> CoreValue {
1088        let (r0, r1) = (start.row.min(end.row), start.row.max(end.row));
1089        let (c0, c1) = (start.col.min(end.col), start.col.max(end.col));
1090        let mut cells: Vec<CoreValue> = Vec::new();
1091        for r in r0..=r1 {
1092            for c in c0..=c1 {
1093                match Address::new(r, c) {
1094                    Some(a) => {
1095                        let v = self.cell_value(sheet_folded, a);
1096                        // A spill anchor stores the full array; its individual
1097                        // elements are visited when the range iteration reaches
1098                        // the spilled positions (which resolve via spilled_value).
1099                        // Use only the [0][0] element here to avoid double-counting.
1100                        let scalar = match v {
1101                            CoreValue::Array(ref rows) => match rows.first() {
1102                                Some(CoreValue::Array(ref cols)) => {
1103                                    cols.first().cloned().unwrap_or(CoreValue::Empty)
1104                                }
1105                                Some(other) => other.clone(),
1106                                None => CoreValue::Empty,
1107                            },
1108                            other => other,
1109                        };
1110                        cells.push(scalar);
1111                    }
1112                    None => cells.push(CoreValue::Error(ErrorKind::Ref)),
1113                }
1114            }
1115        }
1116        CoreValue::Array(cells)
1117    }
1118
1119    /// Resolves a named range's canonical `ref` string (`Sheet!A1` /
1120    /// `Sheet!A1:B2`) the same way a literal reference resolves.
1121    fn resolve_name_ref(&mut self, r: &str) -> CoreValue {
1122        let engine = match self.workbook.engine() {
1123            EngineFlavor::Sheets => Engine::sheets(),
1124            EngineFlavor::Excel => Engine::excel(),
1125        };
1126        // The ref string parses as a one-reference formula; extract and resolve.
1127        let formula = format!("={r}");
1128        match engine.parse(&formula) {
1129            Ok(expr) => {
1130                let refs = truecalc_core::extract_refs(&expr);
1131                match refs.first() {
1132                    Some(first) => self.resolve(first),
1133                    None => CoreValue::Error(ErrorKind::Ref),
1134                }
1135            }
1136            Err(_) => CoreValue::Error(ErrorKind::Ref),
1137        }
1138    }
1139}
1140
1141/// Maps a core evaluated [`CoreValue`] to the workbook [`Value`] (schema §6).
1142/// Core arrays (flat or nested rows) become a rectangular 2-D workbook array;
1143/// a 1×1 array collapses to its scalar (schema §6).
1144fn core_to_workbook(v: CoreValue) -> Value {
1145    match v {
1146        CoreValue::Number(n) => Value::Number(n),
1147        CoreValue::Text(s) => Value::Text(s),
1148        CoreValue::Bool(b) => Value::Boolean(b),
1149        CoreValue::Error(e) => Value::Error(e.to_string()),
1150        CoreValue::ErrorMsg(e, m) => Value::ErrorMsg(e.to_string(), m),
1151        CoreValue::Empty => Value::Empty,
1152        CoreValue::Date(n) => Value::Date(n),
1153        CoreValue::Zoned(z) => Value::Zoned(z),
1154        CoreValue::Array(elems) => core_array_to_workbook(elems),
1155    }
1156}
1157
1158/// Normalizes a core array (which may be flat scalars or nested rows) into the
1159/// workbook's row-major 2-D shape, collapsing a 1×1 array to its scalar.
1160fn core_array_to_workbook(elems: Vec<CoreValue>) -> Value {
1161    if elems.is_empty() {
1162        // An empty array has no scalar form; surface as #REF! (a degenerate
1163        // spill the P3.5 engine will own). Kept minimal here.
1164        return Value::Error("#REF!".to_owned());
1165    }
1166    let nested = elems.iter().all(|e| matches!(e, CoreValue::Array(_)));
1167    let rows: Vec<Vec<Value>> = if nested {
1168        elems
1169            .into_iter()
1170            .map(|row| match row {
1171                CoreValue::Array(cells) => cells.into_iter().map(core_to_workbook).collect(),
1172                other => vec![core_to_workbook(other)],
1173            })
1174            .collect()
1175    } else {
1176        vec![elems.into_iter().map(core_to_workbook).collect()]
1177    };
1178    if rows.len() == 1 && rows[0].len() == 1 {
1179        return rows.into_iter().next().unwrap().into_iter().next().unwrap();
1180    }
1181    Value::Array(rows)
1182}
1183
1184/// Maps a workbook [`Value`] back to a core [`CoreValue`] for feeding a stored
1185/// cell value into evaluation through the resolver.
1186fn workbook_to_core(v: &Value) -> CoreValue {
1187    match v {
1188        Value::Number(n) => CoreValue::Number(*n),
1189        Value::Text(s) => CoreValue::Text(s.clone()),
1190        Value::Boolean(b) => CoreValue::Bool(*b),
1191        Value::Error(code) | Value::ErrorMsg(code, _) => {
1192            CoreValue::Error(error_kind_from_code(code))
1193        }
1194        Value::Empty => CoreValue::Empty,
1195        Value::Date(n) => CoreValue::Date(*n),
1196        Value::Zoned(z) => CoreValue::Zoned(z.clone()),
1197        Value::Array(rows) => CoreValue::Array(
1198            rows.iter()
1199                .map(|row| CoreValue::Array(row.iter().map(workbook_to_core).collect()))
1200                .collect(),
1201        ),
1202    }
1203}
1204
1205/// Parses a Sheets error code string back to a core [`ErrorKind`]; an unknown
1206/// code maps to `#REF!` (the most conservative reference error).
1207fn error_kind_from_code(code: &str) -> ErrorKind {
1208    match code {
1209        "#DIV/0!" => ErrorKind::DivByZero,
1210        "#VALUE!" => ErrorKind::Value,
1211        "#REF!" => ErrorKind::Ref,
1212        "#NAME?" => ErrorKind::Name,
1213        "#NUM!" => ErrorKind::Num,
1214        "#N/A" => ErrorKind::NA,
1215        "#NULL!" => ErrorKind::Null,
1216        _ => ErrorKind::Ref,
1217    }
1218}
1219
1220/// Whether `upper` (an upper-cased formula) calls the function `name`, i.e.
1221/// `name` appears followed by `(` (ignoring spaces). Avoids matching a name
1222/// that is merely a substring of a longer identifier.
1223fn contains_call(upper: &str, name: &str) -> bool {
1224    let bytes = upper.as_bytes();
1225    let nb = name.as_bytes();
1226    let mut i = 0;
1227    while let Some(pos) = find_from(bytes, nb, i) {
1228        // Preceding char must not be an identifier char.
1229        let before_ok = pos == 0 || !is_ident_byte(bytes[pos - 1]);
1230        // Following non-space char must be '('.
1231        let mut j = pos + nb.len();
1232        while j < bytes.len() && bytes[j] == b' ' {
1233            j += 1;
1234        }
1235        let after_ok = j < bytes.len() && bytes[j] == b'(';
1236        if before_ok && after_ok {
1237            return true;
1238        }
1239        i = pos + 1;
1240    }
1241    false
1242}
1243
1244fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1245    if needle.is_empty() || from + needle.len() > haystack.len() {
1246        return None;
1247    }
1248    (from..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
1249}
1250
1251fn is_ident_byte(b: u8) -> bool {
1252    b.is_ascii_alphanumeric() || b == b'_'
1253}
1254
1255/// The set of `(folded sheet, address)` cells whose spill coverage changed
1256/// between two anchor-rectangle maps: the union of all cells in any rectangle
1257/// that appeared, vanished, or resized (schema spec §5). Their readers may now
1258/// be stale and must be dirtied in an incremental recalc.
1259fn changed_rectangle_cells(
1260    before: &BTreeMap<CellRef, SpillRect>,
1261    after: &BTreeMap<CellRef, SpillRect>,
1262) -> BTreeSet<(String, Address)> {
1263    let mut out: BTreeSet<(String, Address)> = BTreeSet::new();
1264    let mut consider = |anchor: &CellRef, rect: &SpillRect| {
1265        // The anchor cell itself is a formula node with its own graph edges;
1266        // only the spilled cells need this spill-aware dirtying.
1267        for addr in rect.spilled_cells() {
1268            out.insert((anchor.sheet.clone(), addr));
1269        }
1270    };
1271    for (anchor, rect) in before {
1272        match after.get(anchor) {
1273            Some(same) if same == rect => {}
1274            _ => consider(anchor, rect),
1275        }
1276    }
1277    for (anchor, rect) in after {
1278        match before.get(anchor) {
1279            Some(same) if same == rect => {}
1280            _ => consider(anchor, rect),
1281        }
1282    }
1283    out
1284}
1285
1286/// Whether a spill rectangle and a range reference overlap (same sheet assumed
1287/// checked by the caller): their inclusive row/column extents intersect (issue
1288/// #591). Used to seed range aggregations that read spilled cells.
1289fn rect_overlaps_range(rect: &SpillRect, range: &RangeRef) -> bool {
1290    let rect_r0 = rect.anchor.row;
1291    let rect_r1 = rect.anchor.row + rect.rows - 1;
1292    let rect_c0 = rect.anchor.column;
1293    let rect_c1 = rect.anchor.column + rect.cols - 1;
1294    rect_r0 <= range.end.row
1295        && rect_r1 >= range.start.row
1296        && rect_c0 <= range.end.column
1297        && rect_c1 >= range.start.column
1298}
1299
1300/// SplitMix64 finalizer — a fast, well-distributed integer mix.
1301fn mix64(mut z: u64) -> u64 {
1302    z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
1303    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1304    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1305    z ^ (z >> 31)
1306}