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}