Skip to main content

truecalc_workbook/
workbook.rs

1use std::collections::{BTreeMap, HashSet};
2use std::sync::Arc;
3
4use serde::de::Error as _;
5use serde::{Deserialize, Deserializer, Serialize};
6
7use icu_casemap::CaseMapperBorrowed;
8
9use truecalc_core::Engine;
10
11use crate::address::Address;
12use crate::authored_cell_index_cache::AuthoredCellIndexCache;
13use crate::authored_index::AuthoredCellIndex;
14use crate::canonical;
15use crate::casefold::simple_fold;
16use crate::depgraph::CellRef;
17use crate::engine::EngineFlavor;
18use crate::error::WorkbookError;
19use crate::graph_cache::{CachedGraph, GraphCache};
20use crate::limits;
21use crate::named_range::NamedRange;
22use crate::named_ref;
23use crate::pre_image_stats::PreImageStats;
24use crate::spill::SpillRect;
25use crate::spill_anchor_cache::SpillAnchorCache;
26use crate::strict_json;
27use crate::table::Table;
28use crate::table_ref;
29use crate::validate;
30use crate::value::Value;
31use crate::worksheet::Worksheet;
32
33/// The schema version this library writes (schema spec §10). A string, not
34/// an integer: compared by exact match, never numerically.
35pub const SCHEMA_VERSION: &str = "2";
36
37/// An engine-locked spreadsheet workbook — a pure value object (no hidden
38/// state, no callbacks). Schema spec §2.
39///
40/// All five *document* fields are always serialized, even when empty. Field
41/// declaration order (`engine`, `names`, `sheets`, `tables`, `version`)
42/// matches canonical (JCS) key order.
43///
44/// `graph_cache`, `spill_anchor_cache` and `authored_cell_index_cache` are not
45/// part of the document: they are derived state the workbook memoizes across
46/// recalculations (see the `graph_cache`, `spill_anchor_cache` and
47/// `authored_cell_index_cache` module docs). `pre_image_stats` is likewise
48/// not document content — it is instrumentation for the last incremental
49/// recalc's own bookkeeping (see the `pre_image_stats` module docs). All four
50/// are skipped by serde, ignored by `PartialEq`, and contribute nothing to
51/// `Hash`, so the value object is exactly what it was before any of them
52/// existed.
53#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Workbook {
56    engine: EngineFlavor,
57    names: Vec<NamedRange>,
58    sheets: Vec<Worksheet>,
59    #[serde(default)]
60    tables: Vec<Table>,
61    #[serde(deserialize_with = "de_version")]
62    version: String,
63    #[serde(skip)]
64    graph_cache: GraphCache,
65    #[serde(skip)]
66    spill_anchor_cache: SpillAnchorCache,
67    #[serde(skip)]
68    authored_cell_index_cache: AuthoredCellIndexCache,
69    #[serde(skip)]
70    pre_image_stats: PreImageStats,
71}
72
73impl Workbook {
74    /// Creates an empty workbook locked to `engine`.
75    ///
76    /// The engine flavor is required at creation and immutable for the
77    /// workbook's lifetime (ADR 2026-04-27-engine-flavor-explicit-everywhere):
78    /// there is no default and no setter.
79    pub fn new(engine: EngineFlavor) -> Self {
80        Self {
81            engine,
82            names: Vec::new(),
83            sheets: Vec::new(),
84            tables: Vec::new(),
85            version: SCHEMA_VERSION.to_owned(),
86            graph_cache: GraphCache::default(),
87            spill_anchor_cache: SpillAnchorCache::default(),
88            authored_cell_index_cache: AuthoredCellIndexCache::default(),
89            pre_image_stats: PreImageStats::default(),
90        }
91    }
92
93    /// The engine flavor every formula in this workbook targets.
94    pub fn engine(&self) -> EngineFlavor {
95        self.engine
96    }
97
98    /// The schema version of this workbook document.
99    pub fn version(&self) -> &str {
100        &self.version
101    }
102
103    /// The worksheets, in tab order (array position is tab position).
104    pub fn sheets(&self) -> &[Worksheet] {
105        &self.sheets
106    }
107
108    /// Mutable access to the worksheets.
109    ///
110    /// Invalidates the dependency-graph cache on the borrow: what the caller
111    /// does with a `&mut Vec<Worksheet>` is unobservable from here, so the
112    /// only sound assumption is that it changed the graph. Invalidates the
113    /// spill-anchor cache for the same reason: an unobserved write can add or
114    /// remove an array-valued cell just as easily as it can add or remove a
115    /// formula. Invalidates the authored-cell-index cache for the same reason
116    /// again: an unobserved write can add or remove an authored cell just as
117    /// easily.
118    pub fn sheets_mut(&mut self) -> &mut Vec<Worksheet> {
119        self.graph_cache.invalidate();
120        self.spill_anchor_cache.invalidate();
121        self.authored_cell_index_cache.invalidate();
122        &mut self.sheets
123    }
124
125    /// The workbook-scoped named ranges.
126    pub fn names(&self) -> &[NamedRange] {
127        &self.names
128    }
129
130    /// Mutable access to the named ranges.
131    ///
132    /// Invalidates the dependency-graph cache on the borrow (see
133    /// [`sheets_mut`](Self::sheets_mut)).
134    pub fn names_mut(&mut self) -> &mut Vec<NamedRange> {
135        self.graph_cache.invalidate();
136        &mut self.names
137    }
138
139    /// The workbook-scoped table declarations.
140    pub fn tables(&self) -> &[Table] {
141        &self.tables
142    }
143
144    /// Mutable access to the table declarations.
145    ///
146    /// Invalidates the dependency-graph cache on the borrow (see
147    /// [`sheets_mut`](Self::sheets_mut)).
148    pub fn tables_mut(&mut self) -> &mut Vec<Table> {
149        self.graph_cache.invalidate();
150        &mut self.tables
151    }
152
153    /// The worksheet named `name` (case-insensitive, simple case folding per
154    /// schema spec §2), or `None` if no sheet matches.
155    pub fn sheet(&self, name: &str) -> Option<&Worksheet> {
156        self.sheet_index(name).map(|i| &self.sheets[i])
157    }
158
159    /// Mutable access to the worksheet named `name` (case-insensitive).
160    ///
161    /// Invalidates the dependency-graph cache, the spill-anchor cache and the
162    /// authored-cell-index cache on the borrow (see
163    /// [`sheets_mut`](Self::sheets_mut)).
164    pub fn sheet_mut(&mut self, name: &str) -> Option<&mut Worksheet> {
165        self.graph_cache.invalidate();
166        self.spill_anchor_cache.invalidate();
167        self.authored_cell_index_cache.invalidate();
168        match self.sheet_index(name) {
169            Some(i) => Some(&mut self.sheets[i]),
170            None => None,
171        }
172    }
173
174    /// The tab position (0-based array index) of the sheet named `name`
175    /// (case-insensitive per schema spec §2), or `None` if no sheet matches.
176    pub fn sheet_index(&self, name: &str) -> Option<usize> {
177        let folder = CaseMapperBorrowed::new();
178        let target = simple_fold(&folder, name);
179        self.sheets
180            .iter()
181            .position(|s| simple_fold(&folder, s.name()) == target)
182    }
183
184    /// Appends `sheet` after the last tab and returns its 0-based position.
185    ///
186    /// Errors if the name collides with an existing sheet under simple case
187    /// folding (schema spec §2), is empty or too long (schema spec §3), or
188    /// would exceed the per-workbook sheet cap (scope ADR Decision 5).
189    pub fn add_sheet(&mut self, sheet: Worksheet) -> Result<usize, WorkbookError> {
190        let pos = self.sheets.len();
191        self.insert_sheet(pos, sheet)?;
192        Ok(pos)
193    }
194
195    /// Inserts `sheet` at tab position `index`, shifting later tabs right.
196    /// `index == sheets().len()` appends. Position semantics: array index is
197    /// tab position (schema spec §2 — order is significant).
198    ///
199    /// Errors on a duplicate name (case-insensitive, §2), an empty/too-long
200    /// name (§3), the sheet cap (Decision 5), or `index` out of `0..=len`.
201    pub fn insert_sheet(&mut self, index: usize, sheet: Worksheet) -> Result<(), WorkbookError> {
202        if self.sheets.len() >= limits::MAX_SHEETS {
203            return Err(WorkbookError::SheetManagement(format!(
204                "cannot add sheet: workbook already has {} sheets, the limit (scope ADR Decision 5)",
205                limits::MAX_SHEETS
206            )));
207        }
208        if index > self.sheets.len() {
209            return Err(WorkbookError::SheetManagement(format!(
210                "cannot insert sheet at position {index}: only {} tab slots exist",
211                self.sheets.len() + 1
212            )));
213        }
214        validate_sheet_name(sheet.name())?;
215        if let Some(existing) = self.sheet(sheet.name()) {
216            return Err(WorkbookError::SheetManagement(format!(
217                "cannot add sheet {:?}: it collides with the existing sheet {:?} under simple \
218                 case folding (schema spec §2)",
219                sheet.name(),
220                existing.name()
221            )));
222        }
223        // A new sheet changes the sheet name set, which is one of the graph's
224        // inputs: a formula that referenced this name resolved to `Unresolved`
225        // before and resolves for real now.
226        self.graph_cache.invalidate();
227        // The inserted sheet is an already-built `Worksheet` the caller
228        // assembled independently — it may already hold array-valued cells,
229        // so the spill-anchor cache cannot assume it is unaffected. Same
230        // reasoning for the authored-cell-index cache: the inserted sheet may
231        // already hold authored cells the index has never seen.
232        self.spill_anchor_cache.invalidate();
233        self.authored_cell_index_cache.invalidate();
234        self.sheets.insert(index, sheet);
235        Ok(())
236    }
237
238    /// Removes and returns the sheet named `name` (case-insensitive),
239    /// shifting later tabs left, or `None` if no sheet matches.
240    ///
241    /// A workbook-scoped named range or table may now dangle to the removed
242    /// sheet. The dangling-ref invariant is re-checked at
243    /// [`to_json`](Self::to_json) and [`from_json`](Self::from_json) (schema
244    /// spec §7) — including, since issue #969, at `to_json`, which the earlier
245    /// wording claimed but the code did not do. A workbook left holding a
246    /// dangling `ref` therefore fails to **save**, rather than saving cleanly
247    /// and failing at some later load.
248    ///
249    /// Removal deliberately does not tidy up for you. It returns
250    /// `Option<Worksheet>` and so has no channel to report what it discarded,
251    /// and dropping a name or table the caller still wants is a silent loss
252    /// they cannot detect; refusing the save names the offending range
253    /// instead. Drop the refs you no longer want with
254    /// [`remove_name`](Self::remove_name) / [`remove_table`](Self::remove_table),
255    /// or repoint them with [`redefine_name`](Self::redefine_name) /
256    /// [`redefine_table`](Self::redefine_table).
257    pub fn remove_sheet(&mut self, name: &str) -> Option<Worksheet> {
258        let i = self.sheet_index(name)?;
259        // Removes every formula node on that sheet, and turns every reference
260        // to it into `Unresolved`.
261        self.graph_cache.invalidate();
262        // The removed sheet may have held spill anchors; the cached map would
263        // then contain rectangles for a sheet that no longer exists. Same
264        // reasoning for the authored-cell-index cache: the removed sheet's
265        // authored cells must drop out of the index too.
266        self.spill_anchor_cache.invalidate();
267        self.authored_cell_index_cache.invalidate();
268        Some(self.sheets.remove(i))
269    }
270
271    /// Renames the sheet currently named `from` (case-insensitive) to `to`,
272    /// repointing everything in the document that named the old sheet.
273    ///
274    /// A rename is **holistic**: the workbook owns the dangling-ref invariant
275    /// across it (issue #969). Three things move together —
276    ///
277    /// - the sheet's own name;
278    /// - every [`NamedRange`] and [`Table`] `ref` whose sheet token resolves
279    ///   to this sheet (case-insensitively, §2). The A1 part is untouched and
280    ///   the new sheet token is re-emitted in canonical quoting, so a `ref`
281    ///   that was canonical stays canonical (§7);
282    /// - every formula that qualifies a cell/range reference with the old
283    ///   name, rewritten via [`Engine::rename_sheet_refs`]: unqualified refs,
284    ///   refs to other sheets, string literals, function names and defined
285    ///   names are left alone. A formula that does not parse has no references
286    ///   to rewrite and is left verbatim — formula text carries no document
287    ///   invariant, and `from_json` does not validate it either.
288    ///
289    /// # Errors
290    ///
291    /// Beyond the name rules — `from` does not exist, `to` is empty or too
292    /// long (§3), `to` collides with a *different* sheet (§2) — a rename is
293    /// refused when the rewrite itself would produce a document
294    /// [`from_json`](Self::from_json) rejects. That is the whole point of the
295    /// operation, so it errors rather than writing one:
296    ///
297    /// - a rewritten formula longer than the formula cap (Decision 5). A sheet
298    ///   name may be 100 scalar values, so a rename can multiply a formula's
299    ///   length; the check mirrors the one the rest of the mutation API
300    ///   applies at the point of change;
301    /// - a repointed [`Table`] landing on a range another table already
302    ///   occupies (structured-references spec §4). Reachable because a table
303    ///   may legitimately be left dangling by [`remove_sheet`](Self::remove_sheet),
304    ///   and a later rename can move a live table onto it;
305    /// - a table that was dangling at `to` **coming alive** on this sheet over
306    ///   a header row that is not a valid table header (§4). A table that moves
307    ///   *with* the sheet keeps reading the cells it always read, so
308    ///   [`define_table`](Self::define_table)'s deliberate "declare the shape
309    ///   now, write the headers later" allowance is untouched; a table that
310    ///   adopts a sheet nobody chose for it is a different thing, and
311    ///   `from_json` checks those column names.
312    ///
313    /// Every error is decided before anything is written, so a rejected rename
314    /// leaves the document exactly as it was.
315    ///
316    /// A pure case change of the *same* sheet is allowed (it does not collide
317    /// with itself) and repoints refs and formulas to the new casing.
318    ///
319    /// # The one case-folding asymmetry
320    ///
321    /// Sheet *identity* in this crate is Unicode **simple case folding**, and
322    /// the `ref` rewrite above uses it. [`Engine::rename_sheet_refs`] matches a
323    /// formula's sheet qualifier with `str::to_uppercase()` instead
324    /// (`truecalc-core` does not depend on `icu_casemap`). The two agree on
325    /// every name whose characters case-map one-to-one, and disagree where they
326    /// do not: `simple_fold("ß") == "ß"`, so `Maß` and `MASS` are *different*
327    /// sheets under §2 and both may exist, while `to_uppercase` collapses them.
328    ///
329    /// The consequence is sharper than "a formula is left unrewritten".
330    /// Renaming `MASS` re-points a formula's `Maß!A1` qualifier at the new
331    /// name, so **a formula can be silently moved onto a different, still
332    /// existing sheet and quietly compute different numbers**, while a
333    /// `NamedRange` spelled `'Maß'!A1` is correctly left alone — refs and
334    /// formulas end up disagreeing about the same rename. `fi` (U+FB01) versus
335    /// `fi` is the same shape. It cannot break the §7 dangling-ref rule — refs
336    /// use simple folding and are exact — and an over-long rewrite it causes is
337    /// refused by the formula-cap check above rather than written, so it does
338    /// not produce an unloadable document. It is simply wrong, and the fix
339    /// belongs in the matcher rather than here —
340    /// see the test `divergent_case_folding_repoints_a_formula_at_another_sheet`,
341    /// which pins the current behaviour so it is not rediscovered as a mystery.
342    pub fn rename_sheet(&mut self, from: &str, to: &str) -> Result<(), WorkbookError> {
343        let idx = self.sheet_index(from).ok_or_else(|| {
344            WorkbookError::SheetManagement(format!("cannot rename: no sheet named {from:?}"))
345        })?;
346        validate_sheet_name(to)?;
347        if let Some(other) = self.sheet_index(to) {
348            if other != idx {
349                return Err(WorkbookError::SheetManagement(format!(
350                    "cannot rename sheet to {to:?}: it collides with another sheet under simple \
351                     case folding (schema spec §2)"
352                )));
353            }
354        }
355
356        let old = self.sheets[idx].name().to_owned();
357        let folder = CaseMapperBorrowed::new();
358        let old_folded = simple_fold(&folder, &old);
359        let new_token = named_ref::quote_sheet_if_needed(to);
360
361        // ── Phase 1: compute every rewrite and validate it. Nothing is written
362        // until all of it is known good, so a refused rename is a no-op.
363        //
364        // A `ref` that will not even split is one `from_json` rejects outright;
365        // there is no sheet token to repoint, so leave it for `to_json` to
366        // report rather than guessing at a rewrite.
367        let repoint = |r: &str| -> Option<String> {
368            let (sheet, a1) = named_ref::split_sheet_ref(r).ok()?;
369            (simple_fold(&folder, &sheet) == old_folded).then(|| format!("{new_token}!{a1}"))
370        };
371        let name_refs: Vec<Option<String>> = self.names.iter().map(|n| repoint(&n.r#ref)).collect();
372        let table_refs: Vec<Option<String>> =
373            self.tables.iter().map(|t| repoint(&t.r#ref)).collect();
374        // A pure case change moves no table between sheets: the target bucket
375        // holds exactly the tables it held before, at exactly the same ranges.
376        // Skipping keeps the rename from newly refusing a document whose
377        // tables already overlapped (which only `tables_mut` can build, and
378        // which `from_json` already rejects on its own).
379        if simple_fold(&folder, to) != old_folded {
380            check_rename_table_invariants(
381                &self.tables,
382                &table_refs,
383                &self.sheets[idx],
384                to,
385                &folder,
386            )?;
387        }
388
389        // The one rewriter, not a second one: `truecalc-core` already ships
390        // this transform and the wasm surface exposes it, so a private copy
391        // here would be a second implementation to drift.
392        // Built lazily: constructing one populates a function registry, and a
393        // workbook with no cross-sheet formula never needs it.
394        let flavor = self.engine;
395        let mut engine: Option<Engine> = None;
396        // Buffered, not applied in place, so the formula-length check below can
397        // refuse the whole rename. Bounded by the text of the formulas that
398        // actually reference the renamed sheet, not by the workbook.
399        let mut formula_rewrites: Vec<(usize, String, String)> = Vec::new();
400        for (sheet_idx, sheet) in self.sheets.iter().enumerate() {
401            for (key, cell) in sheet.cells() {
402                let Some(formula) = cell.formula() else {
403                    continue;
404                };
405                // A sheet qualifier is spelled `Sheet!A1` — both grammar rules
406                // that produce one (`parser::mod.rs`, the bare and the quoted
407                // sheet-ref productions) require a literal `!` — so a formula
408                // without one cannot hold a qualifier and needs no parse. A
409                // byte scan instead of a parse is what keeps the common case,
410                // where most cells do not reference another sheet, off the
411                // rename's cost.
412                if !formula.contains('!') {
413                    continue;
414                }
415                let engine = engine.get_or_insert_with(|| match flavor {
416                    EngineFlavor::Sheets => Engine::sheets(),
417                    EngineFlavor::Excel => Engine::excel(),
418                });
419                let Ok(rewritten) = engine.rename_sheet_refs(formula, &old, to) else {
420                    continue;
421                };
422                if rewritten == formula {
423                    continue;
424                }
425                if rewritten.len() > limits::MAX_FORMULA_LEN {
426                    return Err(WorkbookError::SheetManagement(format!(
427                        "cannot rename sheet {old:?} to {to:?}: it would grow the formula in \
428                         cell {key:?} of sheet {:?} to {} bytes, exceeding the limit of {} \
429                         (scope ADR Decision 5)",
430                        sheet.name(),
431                        rewritten.len(),
432                        limits::MAX_FORMULA_LEN
433                    )));
434                }
435                formula_rewrites.push((sheet_idx, key.clone(), rewritten));
436            }
437        }
438
439        // ── Phase 2: commit.
440        //
441        // Re-keys every node on the sheet and re-resolves every qualified
442        // reference to the old and the new name.
443        self.graph_cache.invalidate();
444        // The spill-anchor cache keys every rectangle by folded sheet name
445        // (see `spill_anchor_cache` module docs), so a rename invalidates the
446        // key space even though it changes no cell value. The authored-cell
447        // index is keyed by folded sheet name too, for the same reason.
448        self.spill_anchor_cache.invalidate();
449        self.authored_cell_index_cache.invalidate();
450        for (nr, new) in self.names.iter_mut().zip(name_refs) {
451            if let Some(new) = new {
452                nr.r#ref = new;
453            }
454        }
455        for (t, new) in self.tables.iter_mut().zip(table_refs) {
456            if let Some(new) = new {
457                t.r#ref = new;
458            }
459        }
460        for (sheet_idx, key, rewritten) in formula_rewrites {
461            if let Some(cell) = self.sheets[sheet_idx].cells_mut().get_mut(&key) {
462                cell.set_formula(rewritten);
463            }
464        }
465        self.sheets[idx].set_name(to);
466        Ok(())
467    }
468
469    /// Moves the sheet at tab position `from` to position `to`, shifting the
470    /// sheets in between (schema spec §2 — array position is tab position).
471    /// Errors if either index is out of `0..len`.
472    pub fn move_sheet(&mut self, from: usize, to: usize) -> Result<(), WorkbookError> {
473        let len = self.sheets.len();
474        if from >= len || to >= len {
475            return Err(WorkbookError::SheetManagement(format!(
476                "cannot move sheet from {from} to {to}: valid tab positions are 0..{len}"
477            )));
478        }
479        // Tab order is not a graph input by construction (the graph keys
480        // sheets by folded name, never by index), but `DependencyGraph::build`
481        // before and after a reorder does *not* compare equal: `range_dependents`
482        // (`depgraph.rs`) is a `Vec` ordered by first encounter during the
483        // `workbook.sheets()` walk, so tab order leaks into that field. What
484        // actually makes a reorder safe to skip is that nothing recalculation
485        // observes is sensitive to it: `evaluation_order` comes from a
486        // `BTreeMap`, `formula_edges`'s successors are `BTreeSet`s, and
487        // `direct_dependents_of` collects into a `BTreeSet` before returning -
488        // every order-sensitive part of the graph gets set-ified before a
489        // caller can see it. The cache is dropped anyway: a move is a rare,
490        // human-scale operation, and "every sheet operation invalidates" is a
491        // rule a future reader can apply without re-deriving this.
492        self.graph_cache.invalidate();
493        let sheet = self.sheets.remove(from);
494        self.sheets.insert(to, sheet);
495        Ok(())
496    }
497
498    /// The cached dependency graph and evaluation order, if the cache is warm.
499    ///
500    /// Warm means "equal to a build against the workbook as it is now" — see
501    /// the `graph_cache` module docs for the invalidation contract that
502    /// maintains it. `pub`, not `pub(crate)`, so a read-only, host-facing
503    /// graph query that only has `&Workbook` to work with (the wasm
504    /// `precedentsOf`/`dependentsOf` binding) can reuse a warm cache instead
505    /// of building its own copy — the same constraint
506    /// [`trace_cell`](Self::trace_cell) documents for itself: it can read a
507    /// warm entry but, taking `&self`, cannot populate a cold one.
508    pub fn cached_graph_entry(&self) -> Option<Arc<CachedGraph>> {
509        self.graph_cache.get()
510    }
511
512    /// Records a freshly built graph as the cache entry.
513    pub(crate) fn store_cached_graph(&mut self, entry: Arc<CachedGraph>) {
514        self.graph_cache.store(entry);
515    }
516
517    /// Drops the cache entry. Always sound; the cost of a spurious call is one
518    /// rebuild.
519    pub(crate) fn invalidate_graph_cache(&mut self) {
520        self.graph_cache.invalidate();
521    }
522
523    /// Releases every cached derived-state entry the workbook holds — today
524    /// the dependency graph (reclaiming the ~545 B/cell (wasm32) / ~856 B/cell
525    /// (native) it retains for every formula cell — see the `limits` module
526    /// docs for the multi-workbook arithmetic this exists for), the
527    /// spill-anchor-rectangle map, and the authored-cell index.
528    ///
529    /// The workbook itself is unchanged: the next `recalc` / `recalc_incremental`
530    /// / `explain` call simply rebuilds whatever it needs, exactly as it would
531    /// after a mutation the owning cache's module invalidates on (`graph_builds`
532    /// / `anchor_builds` / `authored_index_builds` ticks up by one).
533    ///
534    /// Named for what it releases, not for the mechanism, and kept apart from
535    /// [`invalidate_graph_cache`](Self::invalidate_graph_cache) /
536    /// [`invalidate_anchor_cache`](Self::invalidate_anchor_cache) /
537    /// [`invalidate_authored_index_cache`](Self::invalidate_authored_index_cache)
538    /// (all `pub(crate)`) on purpose: those are this crate's word for "a
539    /// mutation made the entry stale, it must rebuild before next use" — an
540    /// internal correctness call the workbook makes about itself. This is a
541    /// different call: a still-*valid* cache the *host* chooses to give back
542    /// for its memory. The name says what a caller gets (memory back), not
543    /// how, so it keeps meaning "every derived cache" as more join it.
544    pub fn drop_derived_state(&mut self) {
545        self.invalidate_graph_cache();
546        self.invalidate_anchor_cache();
547        self.invalidate_authored_index_cache();
548    }
549
550    /// Mutable access to the worksheets that does **not** invalidate the
551    /// dependency-graph cache.
552    ///
553    /// Every caller must be a write the graph provably cannot see, and must
554    /// say which clause of the `graph_cache` contract makes it so. Today that
555    /// is exactly two: `Workbook::set`/`Workbook::clear` of a literal over a
556    /// non-formula cell, and recalc's value write-back — both only while the
557    /// workbook declares no tables, since a table header's stored text *is* a
558    /// graph input. If you are not certain, use
559    /// [`sheets_mut`](Self::sheets_mut).
560    pub(crate) fn sheets_mut_untracked(&mut self) -> &mut Vec<Worksheet> {
561        &mut self.sheets
562    }
563
564    /// How many dependency graphs this workbook has built.
565    ///
566    /// Instrumentation, not a feature: "graph builds per recalculation" is the
567    /// exact-count metric behind the graph cache, and wall clock is too
568    /// machine-dependent to assert on in a test. Hidden from the docs because
569    /// no caller needs it.
570    ///
571    /// **Does not count a cold [`trace_cell`](Self::trace_cell)/`explain`.**
572    /// `trace_cell` takes `&self` and so cannot call `store_cached_graph`
573    /// (needs `&mut self`); its cold path builds a `DependencyGraph` locally
574    /// and discards it without ever calling the `GraphCache` store that is
575    /// the only place this counter increments. A cold `explain` on a
576    /// workbook therefore leaves this at `0` (and
577    /// [`graph_cache_is_warm`](Self::graph_cache_is_warm) at `false`) even
578    /// though a graph was, in fact, built — do not write a test asserting
579    /// "explain builds no graph" from this counter.
580    #[doc(hidden)]
581    pub fn graph_builds(&self) -> u64 {
582        self.graph_cache.builds()
583    }
584
585    /// Whether the dependency-graph cache currently holds an entry.
586    /// Instrumentation, same rationale as [`graph_builds`](Self::graph_builds).
587    #[doc(hidden)]
588    pub fn graph_cache_is_warm(&self) -> bool {
589        self.graph_cache.is_warm()
590    }
591
592    /// The cached spill-anchor-rectangle map, if the cache is warm.
593    ///
594    /// Warm means "equal to a build against the workbook as it is now" — see
595    /// the `spill_anchor_cache` module docs for the invalidation contract
596    /// that maintains it (a genuinely separate schedule from the dependency
597    /// graph's — see that module's docs for why). `pub(crate)`: unlike the
598    /// graph cache, nothing outside this crate currently needs a warm anchor
599    /// map without recalculating first.
600    pub(crate) fn cached_anchor_entry(&self) -> Option<Arc<BTreeMap<CellRef, SpillRect>>> {
601        self.spill_anchor_cache.get()
602    }
603
604    /// Records a freshly built anchor-rectangle map as the cache entry.
605    pub(crate) fn store_cached_anchors(&mut self, entry: Arc<BTreeMap<CellRef, SpillRect>>) {
606        self.spill_anchor_cache.store(entry);
607    }
608
609    /// Drops the spill-anchor cache entry. Always sound; the cost of a
610    /// spurious call is one rebuild.
611    pub(crate) fn invalidate_anchor_cache(&mut self) {
612        self.spill_anchor_cache.invalidate();
613    }
614
615    /// How many spill-anchor-rectangle maps this workbook has built.
616    /// Instrumentation, same rationale as [`graph_builds`](Self::graph_builds):
617    /// "builds per recalc" is the exact-count metric behind this cache, and
618    /// wall clock is too machine-dependent to assert on in a test.
619    #[doc(hidden)]
620    pub fn anchor_builds(&self) -> u64 {
621        self.spill_anchor_cache.builds()
622    }
623
624    /// Whether the spill-anchor cache currently holds an entry.
625    /// Instrumentation, same rationale as [`graph_builds`](Self::graph_builds).
626    #[doc(hidden)]
627    pub fn anchor_cache_is_warm(&self) -> bool {
628        self.spill_anchor_cache.is_warm()
629    }
630
631    /// Records that `Workbook::seed_spills_from_grid` actually ran (issue #985).
632    pub(crate) fn record_seed_spills_from_grid_call(&mut self) {
633        self.spill_anchor_cache.record_seed_spills_from_grid_call();
634    }
635
636    /// Records that `GridSpillIndex::build` actually ran (issue #985).
637    pub(crate) fn record_grid_spill_index_build_call(&mut self) {
638        self.spill_anchor_cache.record_grid_spill_index_build_call();
639    }
640
641    /// How many times `seed_spills_from_grid` has actually run: instrumentation
642    /// proving issue #985's short-circuit skips it on a workbook with no
643    /// current spills. Same rationale as [`graph_builds`](Self::graph_builds).
644    #[doc(hidden)]
645    pub fn seed_spills_from_grid_calls(&self) -> u64 {
646        self.spill_anchor_cache.seed_spills_from_grid_calls()
647    }
648
649    /// How many times `GridSpillIndex::build` has actually run: instrumentation
650    /// proving issue #985's short-circuit skips it on a workbook with no
651    /// current spills. Same rationale as [`graph_builds`](Self::graph_builds).
652    #[doc(hidden)]
653    pub fn grid_spill_index_build_calls(&self) -> u64 {
654        self.spill_anchor_cache.grid_spill_index_build_calls()
655    }
656
657    /// The cached authored-cell index, if the cache is warm.
658    ///
659    /// Warm means "equal to a build against the workbook as it is now" — see
660    /// the `authored_cell_index_cache` module docs for the invalidation
661    /// contract that maintains it. `pub(crate)`: nothing outside this crate
662    /// currently needs it.
663    pub(crate) fn cached_authored_index_entry(&self) -> Option<Arc<AuthoredCellIndex>> {
664        self.authored_cell_index_cache.get()
665    }
666
667    /// Records a freshly built authored-cell index as the cache entry.
668    pub(crate) fn store_cached_authored_index(&mut self, entry: Arc<AuthoredCellIndex>) {
669        self.authored_cell_index_cache.store(entry);
670    }
671
672    /// Drops the authored-cell-index cache entry. Always sound; the cost of a
673    /// spurious call is one rebuild.
674    pub(crate) fn invalidate_authored_index_cache(&mut self) {
675        self.authored_cell_index_cache.invalidate();
676    }
677
678    /// How many authored-cell indexes this workbook has built.
679    /// Instrumentation, same rationale as [`graph_builds`](Self::graph_builds):
680    /// "builds per recalc" is the exact-count metric behind this cache, and
681    /// wall clock is too machine-dependent to assert on in a test.
682    #[doc(hidden)]
683    pub fn authored_index_builds(&self) -> u64 {
684        self.authored_cell_index_cache.builds()
685    }
686
687    /// Whether the authored-cell-index cache currently holds an entry.
688    /// Instrumentation, same rationale as [`graph_builds`](Self::graph_builds).
689    #[doc(hidden)]
690    pub fn authored_index_cache_is_warm(&self) -> bool {
691        self.authored_cell_index_cache.is_warm()
692    }
693
694    /// Records `count` as the last incremental recalc's pre-image-map size
695    /// (issue #991, Design A). See the `pre_image_stats` module docs.
696    pub(crate) fn record_pre_image_count(&mut self, count: usize) {
697        self.pre_image_stats.record(count);
698    }
699
700    /// How many cells the last incremental recalc recorded a pre-image for.
701    /// Instrumentation, not a feature: this is the exact-count metric behind
702    /// Design A's lazy pre-image accumulation (issue #991) — wall clock
703    /// cannot prove that an edit into a large workbook recorded one pre-image
704    /// rather than one per formula cell, but this count can. `0` before the
705    /// first incremental call.
706    #[doc(hidden)]
707    pub fn pre_image_count(&self) -> u64 {
708        self.pre_image_stats.count()
709    }
710
711    /// Parses a workbook from JSON bytes, enforcing every document-level rule
712    /// of the schema (schema spec §1–§10) and the resource limits of the scope
713    /// ADR (Decision 5).
714    ///
715    /// Accepts any schema-valid JSON — pretty-printed, reordered keys, extra
716    /// whitespace are all fine; only the *content* must be valid (schema spec
717    /// §8: non-canonical-but-valid input is accepted, output is always
718    /// canonical). Beyond the serde layer's checks (unknown fields, value
719    /// encodings incl. NaN/Inf and `-0`, empty-literal, exact version match),
720    /// this enforces the rules serde cannot express:
721    ///
722    /// - **§1** duplicate object keys are rejected; a UTF-8 BOM and invalid
723    ///   UTF-8 are rejected at the byte boundary (hence `&[u8]`, not `&str`);
724    /// - **§2/§3** sheet names are non-empty, ≤ 100 scalar values, and unique
725    ///   under Unicode **simple** case folding;
726    /// - **§3** cell keys match `^[A-Z]{1,3}[1-9][0-9]{0,7}$` and lie within
727    ///   the address bounds;
728    /// - **§5** spill rectangles are document-valid (no authored cell inside an
729    ///   anchor's rectangle, no overlapping rectangles, none out of bounds);
730    /// - **§7** named-range names and `ref`s are valid and canonical, names are
731    ///   unique case-insensitively, and no `ref` dangles to a missing sheet;
732    /// - **Decision 5** input size and all structural limits are enforced (the
733    ///   input-size and cell-count caps on `wasm32` only — see the
734    ///   [`limits`](crate::limits) module docs).
735    pub fn from_json(bytes: &[u8]) -> Result<Self, WorkbookError> {
736        if limits::exceeds_serialized_cap(bytes.len()) {
737            return Err(WorkbookError::Validation(format!(
738                "input is {} bytes, exceeding the {}-byte limit (scope ADR Decision 5)",
739                bytes.len(),
740                limits::MAX_SERIALIZED_BYTES
741            )));
742        }
743        // §1: duplicate-key- and BOM-rejecting parse into a JSON tree.
744        let tree = strict_json::parse_no_dup_keys(bytes).map_err(WorkbookError::Validation)?;
745        // Document-level invariants serde cannot express (§2/§3/§5/§7, limits).
746        validate::validate_document(&tree).map_err(WorkbookError::Validation)?;
747        // Typed deserialization (unknown fields, value encodings, version,
748        // empty-literal) — the serde layer of P2.2.
749        serde_json::from_value(tree).map_err(|e| WorkbookError::Validation(e.to_string()))
750    }
751
752    /// Serializes the workbook to its canonical RFC 8785 (JCS) byte form
753    /// (schema spec §8): one line, no insignificant whitespace, no trailing
754    /// newline, object keys sorted by UTF-16 code units, ECMAScript number
755    /// formatting, `names` sorted by `name`.
756    ///
757    /// Errors if a named range or table `ref` dangles to a sheet the workbook
758    /// does not have — the §7 invariant [`remove_sheet`](Self::remove_sheet)
759    /// and [`rename_sheet`](Self::rename_sheet) are the ways to break, checked
760    /// here so a document that cannot be loaded cannot be written — if a value
761    /// is non-finite (forbidden, schema spec §8.4), or if the canonical bytes
762    /// exceed the 100 MiB cap — enforced on `wasm32` only, see the
763    /// [`limits`](crate::limits) module docs (scope ADR Decision 5).
764    pub fn to_json(&self) -> Result<String, WorkbookError> {
765        // Saving must not produce a document that can never be opened: the §7
766        // dangling-ref rule is the one document invariant a structural change
767        // can leave broken, so it is re-checked here and not only on load.
768        self.check_no_dangling_refs()?;
769        // Serialize through the typed serde layer (which already emits the §6
770        // value encodings and rejects NaN/Inf), then canonicalize the tree.
771        let mut tree =
772            serde_json::to_value(self).map_err(|e| WorkbookError::Validation(e.to_string()))?;
773        sort_names_by_name(&mut tree);
774        sort_tables_by_name(&mut tree);
775        let canonical = canonical::to_canonical_string(&tree).map_err(WorkbookError::Validation)?;
776        if limits::exceeds_serialized_cap(canonical.len()) {
777            return Err(WorkbookError::Validation(format!(
778                "canonical workbook is {} bytes, exceeding the {}-byte limit                  (scope ADR Decision 5)",
779                canonical.len(),
780                limits::MAX_SERIALIZED_BYTES
781            )));
782        }
783        Ok(canonical)
784    }
785
786    /// The §7 dangling-sheet-ref invariant, checked against the in-memory
787    /// document: every [`NamedRange`] and [`Table`] `ref` must name a sheet
788    /// this workbook still has (case-insensitively, §2).
789    ///
790    /// Deliberately the same rule, applied with the same helper and reported
791    /// with the same wording as [`from_json`](Self::from_json), so the two
792    /// cannot drift — a save that succeeds is a load that will succeed for
793    /// this rule. Deliberately *only* that rule: it costs `O(names + tables)`,
794    /// both capped at 10 000, and never touches a cell.
795    ///
796    /// The rest of the load-time rules stay load-only, in three groups, and
797    /// the difference between them matters:
798    ///
799    /// - **Unreachable through the mutation API.** §2/§3 sheet names and the
800    ///   sheet cap, §3 cell-key syntax and bounds, §5 spill rectangles, §7
801    ///   named-range name shape and uniqueness, table-name uniqueness and the
802    ///   count caps, and text/array/cell limits. [`add_sheet`](Self::add_sheet),
803    ///   [`define_name`](Self::define_name), [`define_table`](Self::define_table)
804    ///   and their siblings each check their own at the point of change.
805    /// - **Reachable and deliberate.** [`define_table`](Self::define_table)
806    ///   does not validate a table's header-row column names — a table may be
807    ///   declared before its headers are written — but `from_json` does. That
808    ///   asymmetry is a documented design decision on `define_table`, not an
809    ///   oversight, and is left alone here.
810    /// - **Reachable below the workbook API.** The formula-length cap
811    ///   (Decision 5) is checked by [`set`](Self::set) and by
812    ///   [`rename_sheet`](Self::rename_sheet), but
813    ///   [`Worksheet::set`](crate::Worksheet::set) with a
814    ///   [`Cell::with_formula`](crate::Cell::with_formula) over the cap goes
815    ///   straight into the grid; and [`names_mut`](Self::names_mut),
816    ///   [`tables_mut`](Self::tables_mut), [`sheets_mut`](Self::sheets_mut) and
817    ///   [`Worksheet::cells_mut`](crate::Worksheet::cells_mut) hand out a raw
818    ///   `&mut` and promise nothing. A non-canonical `ref` (§7), a cell key
819    ///   that is not valid A1 (§3), an over-long formula, a duplicate name —
820    ///   all still save and will not load.
821    ///
822    /// A formula naming a missing sheet is **not** a violation. `from_json`
823    /// does not check formula text at all: such a reference is legal and
824    /// resolves to an error at recalculation, so `to_json` accepts it too.
825    ///
826    /// Message equality with `from_json` holds when the dangling ref is the
827    /// document's *first* violation. `from_json` checks table name validity
828    /// and uniqueness before it checks a table's sheet, so a document that
829    /// breaks both reports whichever rule its side reaches first.
830    fn check_no_dangling_refs(&self) -> Result<(), WorkbookError> {
831        if self.names.is_empty() && self.tables.is_empty() {
832            return Ok(());
833        }
834        let folder = CaseMapperBorrowed::new();
835        // A set, where `from_json`'s equivalent is a `Vec`: same membership
836        // test, but this one runs on the save path against up to 20 000 refs,
837        // and the linear scan made the sheet count (capped at 256) a factor in
838        // it — ~10 ms at both caps, versus ~3 ms here.
839        let folded_sheets: HashSet<String> = self
840            .sheets
841            .iter()
842            .map(|s| simple_fold(&folder, s.name()))
843            .collect();
844        let exists = |sheet: &str| folded_sheets.contains(&simple_fold(&folder, sheet));
845
846        for nr in &self.names {
847            let (sheet, _) =
848                named_ref::split_sheet_ref(&nr.r#ref).map_err(WorkbookError::Validation)?;
849            if !exists(&sheet) {
850                return Err(WorkbookError::Validation(format!(
851                    "named range {:?} refers to sheet {sheet:?}, which does not exist \
852                     (schema spec §7)",
853                    nr.name
854                )));
855            }
856        }
857        for t in &self.tables {
858            let (sheet, _) = named_ref::split_sheet_ref(&t.r#ref)
859                .map_err(|e| WorkbookError::Validation(format!("table {:?}: {e}", t.name)))?;
860            if !exists(&sheet) {
861                return Err(WorkbookError::Validation(format!(
862                    "table {:?} refers to sheet {sheet:?}, which does not exist \
863                     (structured-references spec §4)",
864                    t.name
865                )));
866            }
867        }
868        Ok(())
869    }
870}
871
872/// Refuses a rename that would leave the renamed sheet holding a table layout
873/// [`Workbook::from_json`] rejects: two tables whose ranges overlap, or a
874/// table that comes alive over a header row that is not a valid table header
875/// (structured-references spec §4). A rename is the one operation that can
876/// change which cells a table covers without going through
877/// [`Workbook::define_table`], so it has to check what that would have.
878///
879/// `repointed[i]` is the new `ref` for `tables[i]`, or `None` if that table
880/// does not target the renamed sheet. Only the target sheet's occupancy
881/// changes, so only tables that end up there are compared — bucketing by sheet
882/// keeps this off the `O(tables²)` shape `from_json` pays once at load.
883///
884/// A `ref` that will not parse as a canonical range yields no bounds and is
885/// skipped rather than panicked on, matching
886/// [`Workbook::define_table`]'s own handling of a `tables_mut()`-injected ref.
887///
888/// [`Workbook::define_table`]: crate::Workbook::define_table
889/// [`Workbook::from_json`]: crate::Workbook::from_json
890fn check_rename_table_invariants(
891    tables: &[Table],
892    repointed: &[Option<String>],
893    sheet: &Worksheet,
894    to: &str,
895    folder: &CaseMapperBorrowed<'static>,
896) -> Result<(), WorkbookError> {
897    let target = simple_fold(folder, to);
898    // Every table that ends up on the renamed sheet: the ones that moved with
899    // it, and the ones that were already spelled `to` and were therefore
900    // *dangling* (no sheet could have had that name, or the rename would have
901    // collided) and are about to come alive on it.
902    let landing: Vec<(&str, table_ref::ParsedRangeBounds, bool)> = tables
903        .iter()
904        .zip(repointed)
905        .filter_map(|(t, new)| {
906            let moved = new.is_some();
907            let r = new.as_deref().unwrap_or(t.r#ref.as_str());
908            let parsed = named_ref::parse_canonical_ref(r).ok()?;
909            let mut bounds = table_ref::parsed_range_bounds(r, &parsed)?;
910            bounds.sheet = simple_fold(folder, &parsed.sheet);
911            (bounds.sheet == target).then_some((t.name.as_str(), bounds, moved))
912        })
913        .collect();
914
915    for i in 0..landing.len() {
916        for j in (i + 1)..landing.len() {
917            if table_ref::ranges_overlap(&landing[i].1, &landing[j].1) {
918                // Name the table that moved first — it is the one the rename
919                // put there, and blaming the stationary one reads backwards.
920                let (mover, resident) = if landing[i].2 {
921                    (landing[i].0, landing[j].0)
922                } else {
923                    (landing[j].0, landing[i].0)
924                };
925                return Err(WorkbookError::SheetManagement(format!(
926                    "cannot rename sheet to {to:?}: it would place table {mover:?} over table \
927                     {resident:?}, whose ranges overlap (structured-references spec §4)"
928                )));
929            }
930        }
931    }
932
933    // A table that *moved* keeps reading the very cells it always read — the
934    // sheet was renamed, not replaced — so its header row cannot have changed
935    // and `define_table`'s deliberate "declare the shape now, write the headers
936    // later" allowance still covers it. A table that comes alive here is a
937    // different matter: it adopts a sheet's existing content, chosen by nobody,
938    // and `from_json` validates those column names (structured-references
939    // spec §4). Refuse rather than write a document that will not load.
940    for (name, bounds, moved) in &landing {
941        if *moved {
942            continue;
943        }
944        let headers: Vec<String> = (bounds.col_start..=bounds.col_end)
945            .map(|col| {
946                let text = Address::new(bounds.row_start, col)
947                    .and_then(|addr| sheet.get(addr))
948                    .map(crate::cell::Cell::value);
949                match text {
950                    Some(Value::Text(t)) => t.clone(),
951                    _ => String::new(),
952                }
953            })
954            .collect();
955        table_ref::header_row_columns(headers.iter().map(String::as_str)).map_err(|e| {
956            WorkbookError::SheetManagement(format!(
957                "cannot rename sheet to {to:?}: it would bring the dangling table {name:?} to \
958                 rest on this sheet, whose header row is not a valid table header — {e} \
959                 (structured-references spec §4)"
960            ))
961        })?;
962    }
963    Ok(())
964}
965
966/// Validates a sheet name for the mutation API: non-empty and ≤ 100 Unicode
967/// scalar values (schema spec §3). Uniqueness is checked separately against the
968/// existing sheet set; this is only the per-name shape check, mirroring the
969/// rule [`Workbook::from_json`] applies to a deserialized document.
970///
971/// [`Workbook::from_json`]: crate::Workbook::from_json
972fn validate_sheet_name(name: &str) -> Result<(), WorkbookError> {
973    let len = name.chars().count();
974    if len == 0 {
975        return Err(WorkbookError::SheetManagement(
976            "a worksheet name must be non-empty (schema spec §3)".to_owned(),
977        ));
978    }
979    if len > limits::MAX_SHEET_NAME_LEN {
980        return Err(WorkbookError::SheetManagement(format!(
981            "worksheet name {name:?} has {len} scalar values, exceeding the limit of {} \
982             (schema spec §3)",
983            limits::MAX_SHEET_NAME_LEN
984        )));
985    }
986    Ok(())
987}
988
989/// Domain ordering of schema spec §8.7: `names` is serialized sorted by `name`
990/// in ascending UTF-16 code-unit order (matching JCS string ordering).
991/// `sheets` keeps authored tab order (it is data, not a set) and is left
992/// untouched.
993fn sort_names_by_name(tree: &mut serde_json::Value) {
994    if let Some(names) = tree.get_mut("names").and_then(|v| v.as_array_mut()) {
995        names.sort_by(|a, b| {
996            let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
997            let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
998            an.encode_utf16().cmp(bn.encode_utf16())
999        });
1000    }
1001}
1002
1003/// Domain ordering of schema spec §8.7 (extended by the structured-refs
1004/// design spec §4): `tables` is serialized sorted by `name`, same rule as
1005/// `names`.
1006fn sort_tables_by_name(tree: &mut serde_json::Value) {
1007    if let Some(tables) = tree.get_mut("tables").and_then(|v| v.as_array_mut()) {
1008        tables.sort_by(|a, b| {
1009            let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
1010            let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
1011            an.encode_utf16().cmp(bn.encode_utf16())
1012        });
1013    }
1014}
1015
1016/// Reader rule of schema spec §10: accept every version this library
1017/// knows (`"1"`, `"2"`), reject unknown versions with a clear "upgrade" error.
1018/// Writer rule of schema spec §10: a loaded document always migrates to
1019/// [`SCHEMA_VERSION`] on load, so re-serializing it writes the newest version
1020/// (confirmed empirically: `de_version` only sees the raw field value, so
1021/// without this the in-memory `version` would keep whatever string was read).
1022fn de_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
1023    let version = String::deserialize(deserializer)?;
1024    if version != "1" && version != SCHEMA_VERSION {
1025        return Err(D::Error::custom(format!(
1026            "unsupported schema version {version:?}: this version of \
1027             truecalc-workbook reads versions \"1\" and \"2\" (schema spec §10); \
1028             upgrade truecalc to load this workbook"
1029        )));
1030    }
1031    Ok(SCHEMA_VERSION.to_owned())
1032}