Skip to main content

truecalc_workbook/
spill.rs

1//! Array-spill geometry and the blocked-spill policy (plan item 3.5, issue
2//! #537), shared between the recalc engine ([`crate::recalc`]) and the
3//! spill-resolving cell accessor ([`Workbook::get`](crate::Workbook::get)).
4//!
5//! # The model (schema spec §5)
6//!
7//! A formula whose result is an `m × n` array is a **spill anchor**: it stores
8//! the full array (schema spec §6 `array`) and *occupies* the rectangle
9//! `(r..r+m-1, c..c+n-1)` anchored at the formula cell `(r, c)`, in reading
10//! order. The non-anchor cells of that rectangle are **spilled** cells: they
11//! are a derived view, never authored and **never serialized** — only the
12//! anchor's array is on the wire (schema spec §5, superseding the plan's
13//! "spilled marker" sketch). A consumer reconstructs the grid by the five-line
14//! rule in §5; this module is that rule.
15//!
16//! # Blocked spill (Sheets semantics)
17//!
18//! A spill is **blocked** — the anchor takes the Sheets blocked-spill error
19//! ([`BLOCKED_SPILL_ERROR`]) and **no array is stored** — when, per §5:
20//!
21//! - any non-anchor cell of the rectangle is *occupied* (an authored literal or
22//!   formula, or a cell already claimed by another spill resolved earlier this
23//!   recalc), or
24//! - the rectangle would extend **beyond the sheet's address bounds** (limits
25//!   ADR) — treated as blocked pending fixture verification (§5).
26//!
27//! Spill resolution runs in deterministic recalc order (topological, tie-broken
28//! by sheet position → row → column — scope ADR Decision 3), so two anchors
29//! competing for the same cell resolve reproducibly: the earlier anchor wins
30//! and the later one blocks.
31//!
32//! A 1×1 array never reaches here: it is collapsed to its scalar before storage
33//! (schema spec §6), so [`Value::Array`](crate::Value::Array) always describes a
34//! spill of at least two cells.
35
36use crate::address::Address;
37use crate::limits::{MAX_COLUMN, MAX_ROW};
38
39/// The Sheets blocked-spill error code (schema spec §12 edge-case 1: Sheets
40/// reports a blocked array expansion as `#REF!`).
41///
42/// A dedicated workbook **blocked-spill** fixture is not yet exported into the
43/// core crate's fixture tree (the P3.6 set committed there covers cross-sheet,
44/// named-range, and date-type rows — see `crates/core/tests/fixtures/`), so this
45/// exact code is **not fixture-pinned in this crate**; the schema spec's §12
46/// worked example is the authority used here, and the in-repo spill tests assert
47/// the *behavior* (a blocked anchor takes this error, stores no array, and
48/// leaves the target cells unspilled). The code is re-verified against a
49/// `spill` fixture when one lands in core (issue note, mirrors
50/// [`crate::recalc::CIRCULAR_ERROR`]'s handling).
51pub const BLOCKED_SPILL_ERROR: &str = "#REF!";
52
53/// The rectangle an `rows × cols` array spills into, anchored at `anchor`
54/// (reading order): inclusive corners `(anchor.row, anchor.column)` ..
55/// `(anchor.row + rows - 1, anchor.column + cols - 1)`.
56///
57/// Returns `None` if the rectangle would leave the sheet's address bounds (a
58/// blocked, out-of-bounds spill — §5). `rows`/`cols` are the dimensions of a
59/// stored `array` value, hence always ≥ 1 (and, jointly, ≥ 2 — §6).
60pub fn spill_rect(anchor: Address, rows: usize, cols: usize) -> Option<SpillRect> {
61    debug_assert!(rows >= 1 && cols >= 1, "an array value is non-empty (§6)");
62    let last_row = anchor.row as u64 + rows as u64 - 1;
63    let last_col = anchor.column as u64 + cols as u64 - 1;
64    if last_row > MAX_ROW as u64 || last_col > MAX_COLUMN as u64 {
65        return None;
66    }
67    Some(SpillRect {
68        anchor,
69        rows: rows as u32,
70        cols: cols as u32,
71    })
72}
73
74/// An in-bounds spill rectangle: the geometry of one anchor's reconstructed
75/// array. Cheap to copy; membership and indexing are `O(1)`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct SpillRect {
78    /// The anchor (top-left) cell — the authored formula cell.
79    pub anchor: Address,
80    /// Row count of the array (≥ 1).
81    pub rows: u32,
82    /// Column count of the array (≥ 1).
83    pub cols: u32,
84}
85
86impl SpillRect {
87    /// Whether `addr` lies inside this rectangle (anchor included).
88    pub fn contains(&self, addr: Address) -> bool {
89        addr.row >= self.anchor.row
90            && addr.row < self.anchor.row + self.rows
91            && addr.column >= self.anchor.column
92            && addr.column < self.anchor.column + self.cols
93    }
94
95    /// The `(i, j)` offset of `addr` within this rectangle (anchor = `(0, 0)`),
96    /// or `None` if `addr` is outside it. The element of the anchor's array at
97    /// `[i][j]` is the value spilled to `addr`.
98    pub fn offset_of(&self, addr: Address) -> Option<(usize, usize)> {
99        if !self.contains(addr) {
100            return None;
101        }
102        Some((
103            (addr.row - self.anchor.row) as usize,
104            (addr.column - self.anchor.column) as usize,
105        ))
106    }
107
108    /// Iterates the **non-anchor** cells of the rectangle (the spilled cells),
109    /// in reading order. The addresses are all in-bounds by construction.
110    pub fn spilled_cells(&self) -> impl Iterator<Item = Address> + '_ {
111        let anchor = self.anchor;
112        (0..self.rows).flat_map(move |i| {
113            (0..self.cols).filter_map(move |j| {
114                if i == 0 && j == 0 {
115                    return None; // the anchor is authored, not spilled
116                }
117                Address::new(anchor.row + i, anchor.column + j)
118            })
119        })
120    }
121}