Skip to main content

truecalc_workbook/
mutate.rs

1//! Workbook-level mutation API (plan item 3.4): cell `set` / `get` / `clear`
2//! and named-range CRUD, all preserving value-object semantics — no interior
3//! threads, no I/O, no hidden callbacks (issue #536, scope ADR Accepted).
4//!
5//! These methods build on P3.1's grid primitives ([`Worksheet::get`] /
6//! [`Worksheet::set`] / [`Worksheet::clear`]) and sheet management; they add the
7//! *workbook-scoped* invariants P3.1 deliberately deferred to the mutation API:
8//!
9//! - **Eager limit enforcement.** The per-mutation caps of scope ADR Decision 5
10//!   are checked at the point of mutation — the workbook cell count
11//!   ([`MAX_CELLS_PER_WORKBOOK`](crate::limits::MAX_CELLS_PER_WORKBOOK)), a
12//!   `text` value's length, an `array` value's element count, a formula's
13//!   length, and the named-range count — so a mutation that would breach a cap
14//!   fails immediately rather than at serialize time. (The cell-count cap is
15//!   enforced on `wasm32` only; see the [`limits`](crate::limits) module
16//!   docs.) The serialized **byte**
17//!   cap is the lone exception: it stays a serialize-time check
18//!   ([`to_json`](crate::Workbook::to_json)), since recomputing canonical byte
19//!   length per edit is O(document) per mutation (Decision 5, `limits` docs).
20//! - **No dangling named ranges.** Named-range CRUD validates the target sheet
21//!   exists at definition time (schema spec §7), the mirror of `from_json`'s
22//!   dangling-ref rejection. (Sheet *removal* can still orphan a name; that
23//!   stays re-checked at the serialization boundary, as P3.1 documents on
24//!   [`remove_sheet`](crate::Workbook::remove_sheet).)
25//!
26//! Formula cells are stored verbatim with a [`Value::Empty`] result — the
27//! "not yet evaluated" state. Recalc (P3.3) is out of scope here; `set` only
28//! validates a formula's *syntax* against the workbook's locked engine and
29//! leaves the cell awaiting a later recalc.
30
31use icu_casemap::CaseMapperBorrowed;
32
33use crate::address::Address;
34use crate::casefold::simple_fold;
35use crate::cell::Cell;
36use crate::error::WorkbookError;
37use crate::limits;
38use crate::named_range::NamedRange;
39use crate::named_ref;
40use crate::spill::spill_rect;
41use crate::table::Table;
42use crate::table_ref::{self, ParsedRangeBounds};
43use crate::value::Value;
44use crate::workbook::Workbook;
45
46/// What a [`Workbook::set`] writes into a cell: a literal value or a formula.
47///
48/// This is the parsed shape the caller hands `set`; the surface layers (WASM,
49/// MCP, REST) decide how to turn a user's raw string into one of these (a
50/// leading `=` selects [`CellInput::Formula`], everything else is a literal).
51/// Keeping the discriminator explicit here avoids guessing inside the value
52/// object and keeps `set` engine-agnostic about *input* syntax while still
53/// validating formula syntax against the locked engine.
54#[derive(Debug, Clone, PartialEq)]
55pub enum CellInput {
56    /// A literal cell value (schema spec §4 — a `value`-only cell). Must not be
57    /// [`Value::Empty`]: an empty literal is byte-indistinguishable from an
58    /// absent cell, so it is rejected (clear the cell instead).
59    Literal(Value),
60    /// A formula, stored verbatim including the leading `=`. Its syntax is
61    /// validated against the workbook's locked engine on `set`; its value is
62    /// [`Value::Empty`] until the next recalc (P3.3).
63    Formula(String),
64}
65
66/// The effective value at an address after spill resolution (schema spec §5),
67/// returned by [`Workbook::resolved`].
68///
69/// For an authored cell, `anchor` is `None` and `value` is the cell's stored
70/// value. For a *spilled* cell, `value` is the reconstructed array element and
71/// `anchor` is the address of the spilling anchor on the same sheet (the
72/// runtime `spilledFrom` marker of §5 — a derived view, never serialized).
73#[derive(Debug, Clone, PartialEq)]
74pub struct Resolved {
75    /// The effective value at the queried address.
76    pub value: Value,
77    /// The spill anchor, if the queried cell is spilled; `None` for an authored
78    /// cell.
79    pub anchor: Option<Address>,
80}
81
82impl Workbook {
83    /// Writes `input` at `addr` on the sheet named `sheet` (case-insensitive),
84    /// returning the cell previously there (if any).
85    ///
86    /// A literal is stored as a `value`-only cell; a formula is stored verbatim
87    /// (with its leading `=`) carrying a [`Value::Empty`] result until the next
88    /// recalc — `set` validates only the formula's *syntax* against the
89    /// workbook's locked engine, never evaluating it (recalc is P3.3).
90    ///
91    /// Enforces, eagerly, the per-mutation caps of scope ADR Decision 5: a
92    /// formula's length, a `text`/`array` value's size, and — when the write
93    /// introduces a *new* populated cell — the per-workbook cell count.
94    ///
95    /// Errors if the sheet does not exist, the input is an empty literal
96    /// (schema spec §4 — clear instead), the formula is syntactically invalid
97    /// for the locked engine, or any cap would be exceeded.
98    ///
99    /// Auto-expand-by-append (structured-references spec §4,
100    /// truecalc/core#861): if the written address lands exactly one row
101    /// below a table's current range, within that table's column span, the
102    /// matching table's `ref` is extended by one row — unless doing so would
103    /// overlap another table's range, in which case the expansion is
104    /// silently skipped and the write still succeeds as an ordinary cell
105    /// write. At most one table can match, since table ranges never overlap
106    /// (§4) and this only looks at the row immediately adjacent to a table,
107    /// not inside any range.
108    pub fn set(
109        &mut self,
110        sheet: &str,
111        addr: Address,
112        input: CellInput,
113    ) -> Result<Option<Cell>, WorkbookError> {
114        // Build (and validate) the cell before touching the grid, so a rejected
115        // input leaves the workbook untouched (value-object atomicity).
116        let cell = match input {
117            CellInput::Literal(value) => {
118                if matches!(value, Value::Empty) {
119                    return Err(WorkbookError::EmptyLiteral);
120                }
121                check_value_limits(&value)?;
122                Cell::literal(value)?
123            }
124            CellInput::Formula(formula) => {
125                check_formula_limit(&formula)?;
126                self.validate_formula(&formula)?;
127                // Unevaluated until recalc (P3.3): empty result, formula verbatim.
128                Cell::with_formula(formula, Value::Empty)
129            }
130        };
131
132        let idx = self.sheet_index(sheet).ok_or_else(|| {
133            WorkbookError::UnknownSheet(format!("cannot set cell: no sheet named {sheet:?}"))
134        })?;
135
136        // Eager workbook cell-count cap: only a *new* cell grows the count.
137        // `+ 1` is the cell this call is about to add.
138        let introduces_new_cell = !self.sheets()[idx].contains(addr);
139        if introduces_new_cell && limits::exceeds_cell_cap(self.total_cells() + 1) {
140            return Err(WorkbookError::ResourceLimit(format!(
141                "cannot set cell: workbook already holds {} populated cells, the limit \
142                 (scope ADR Decision 5)",
143                limits::MAX_CELLS_PER_WORKBOOK
144            )));
145        }
146
147        // Dependency-graph cache (see the `graph_cache` module docs). The
148        // graph is a function of the workbook's formula cells, sheet names,
149        // name/table declarations, and — only when a table is declared — the
150        // text stored in that table's header row. So this write is
151        // structure-preserving exactly when it neither creates nor destroys a
152        // formula node *and* no table exists whose header text it could be.
153        //
154        // "A literal write cannot change the graph" is therefore false in
155        // general: writing text into a declared table's header cell moves what
156        // `T[column]` resolves to. It is true only in a workbook with no
157        // tables, which is the condition tested here.
158        let writes_formula = cell.formula().is_some();
159        // Spill-anchor cache (see the `spill_anchor_cache` module docs): a
160        // narrower, separate condition from the graph cache's above — a
161        // literal write can create or destroy an array-valued cell directly
162        // (an array literal is a supported `CellInput`), and recalc's own
163        // value write-back deliberately does *not* invalidate the graph cache
164        // for exactly this kind of write, so the two caches cannot share a
165        // schedule. Captured before `cell` moves into `set` below.
166        let new_value_is_array = matches!(cell.value(), Value::Array(_));
167        let prev = self.sheets_mut_untracked()[idx].set(addr, cell);
168        let replaced_formula = prev.as_ref().and_then(Cell::formula).is_some();
169        if writes_formula || replaced_formula || !self.tables().is_empty() {
170            self.invalidate_graph_cache();
171        }
172        let prev_value_is_array = prev
173            .as_ref()
174            .is_some_and(|c| matches!(c.value(), Value::Array(_)));
175        if prev_value_is_array || new_value_is_array {
176            self.invalidate_anchor_cache();
177        }
178        // Authored-cell-index cache (see the `authored_cell_index_cache`
179        // module docs): the index is a function of which addresses are
180        // authored, not of any cell's value or formula-ness, so only a write
181        // that actually adds a new entry can make it stale — an overwrite of
182        // an already-authored cell changes no entry.
183        if introduces_new_cell {
184            self.invalidate_authored_index_cache();
185        }
186        // Auto-expansion retargets a table `ref`, which is a graph input; it
187        // invalidates through `tables_mut` on the path that actually expands,
188        // and is a no-op (so correctly non-invalidating) on the path that does
189        // not.
190        self.expand_table_on_append(idx, addr);
191        Ok(prev)
192    }
193
194    /// The **authored** cell at `addr` on the sheet named `sheet`
195    /// (case-insensitive), or `None` if no cell is authored there.
196    ///
197    /// This returns only authored cells — a literal or a formula physically
198    /// present in `cells`. A *spilled* cell (one materialized by a spill anchor,
199    /// schema spec §5) is **not** authored and has no [`Cell`] to borrow, so
200    /// `get` returns `None` for it; use [`resolved`](Self::resolved) to read the
201    /// effective value at any address (authored *or* spilled) and learn the
202    /// spill anchor. Keeping `get` authored-only preserves the structural
203    /// distinguishability rule of §5 (a cell is authored iff it has an entry).
204    pub fn get(&self, sheet: &str, addr: Address) -> Option<&Cell> {
205        self.sheet(sheet).and_then(|ws| ws.get(addr))
206    }
207
208    /// The **effective** value at `addr` on the sheet named `sheet`
209    /// (case-insensitive), resolving through array spills (schema spec §5).
210    ///
211    /// Returns `None` only if `addr` is genuinely empty — neither authored nor
212    /// covered by a spill. Otherwise the returned [`Resolved`] carries the
213    /// value and, for a spilled cell, the `anchor` it spilled from (the
214    /// runtime `spilledFrom` view of §5 — never serialized). For an authored
215    /// cell `anchor` is `None`. A blocked-spill anchor is just an authored
216    /// formula cell whose value is the blocked-spill error, so it resolves as
217    /// an ordinary authored cell with no `anchor`.
218    ///
219    /// Resolution reads the **stored** grid (the last recalc's results): a
220    /// spilling anchor stores its full array (§6), and this reconstructs the
221    /// spilled element by the five-line rule of §5. It does not recalc.
222    pub fn resolved(&self, sheet: &str, addr: Address) -> Option<Resolved> {
223        let ws = self.sheet(sheet)?;
224        // Authored cell wins (a spill never overlaps an authored cell — §5).
225        if let Some(cell) = ws.get(addr) {
226            return Some(Resolved {
227                value: cell.value().clone(),
228                anchor: None,
229            });
230        }
231        // Otherwise look for an anchor whose stored array spills onto `addr`.
232        for (anchor_addr, cell) in ws.iter() {
233            let Value::Array(rows) = cell.value() else {
234                continue;
235            };
236            let nrows = rows.len();
237            let ncols = rows.first().map_or(0, Vec::len);
238            let Some(rect) = spill_rect(anchor_addr, nrows, ncols) else {
239                continue;
240            };
241            if anchor_addr == addr {
242                continue; // the anchor is authored, handled above
243            }
244            if let Some((i, j)) = rect.offset_of(addr) {
245                let value = rows[i][j].clone();
246                return Some(Resolved {
247                    value,
248                    anchor: Some(anchor_addr),
249                });
250            }
251        }
252        None
253    }
254
255    /// The spill anchor that materializes `addr` on the sheet named `sheet`
256    /// (case-insensitive), or `None` if `addr` is authored or empty (schema
257    /// spec §5). Convenience over [`resolved`](Self::resolved) when only the
258    /// anchor identity (the `spilledFrom` view) is needed.
259    pub fn spill_anchor(&self, sheet: &str, addr: Address) -> Option<Address> {
260        self.resolved(sheet, addr).and_then(|r| r.anchor)
261    }
262
263    /// Removes the cell at `addr` on the sheet named `sheet` (case-insensitive),
264    /// returning it if present. Clearing is *removing the entry*, never writing
265    /// an empty value (schema spec §4). Returns `None` if the sheet or cell is
266    /// absent.
267    pub fn clear(&mut self, sheet: &str, addr: Address) -> Option<Cell> {
268        let idx = self.sheet_index(sheet)?;
269        let prev = self.sheets_mut_untracked()[idx].clear(addr);
270        // Same rule as `set`, minus the "writes a formula" half: removing a
271        // literal from a table-free workbook removes no node, no edge, and no
272        // header text the graph can see.
273        let removed_formula = prev.as_ref().and_then(Cell::formula).is_some();
274        if removed_formula || !self.tables().is_empty() {
275            self.invalidate_graph_cache();
276        }
277        // Spill-anchor cache: clearing an array-valued cell removes its
278        // rectangle (see the `set` invalidation above and the
279        // `spill_anchor_cache` module docs for why this must not ride the
280        // graph cache's schedule).
281        if prev
282            .as_ref()
283            .is_some_and(|c| matches!(c.value(), Value::Array(_)))
284        {
285            self.invalidate_anchor_cache();
286        }
287        // Authored-cell-index cache: clearing an authored cell removes its
288        // entry from the index (see the `set` invalidation above and the
289        // `authored_cell_index_cache` module docs).
290        if prev.is_some() {
291            self.invalidate_authored_index_cache();
292        }
293        prev
294    }
295
296    /// The total number of populated cells across every sheet — the quantity
297    /// the per-workbook cell cap (scope ADR Decision 5) bounds.
298    pub fn total_cells(&self) -> usize {
299        self.sheets().iter().map(|s| s.len()).sum()
300    }
301
302    /// Defines a new workbook-scoped named range `name` pointing at the
303    /// canonical reference `r` (`Sheet!A1` / `Sheet!A1:B2`), returning the
304    /// stored [`NamedRange`].
305    ///
306    /// Validates everything `from_json` checks for a name (schema spec §7): the
307    /// name's shape, the `ref`'s canonical form, that the target sheet exists
308    /// (no dangling ref), that the name does not already exist
309    /// (case-insensitively) as either a named range or a table
310    /// (structured-references spec §4), and that the named-range cap
311    /// (Decision 5) is not exceeded. To replace an existing name use
312    /// [`redefine_name`](Self::redefine_name).
313    pub fn define_name(&mut self, name: &str, r: &str) -> Result<&NamedRange, WorkbookError> {
314        self.validate_name_definition(name, r)?;
315        if self.names().len() >= limits::MAX_NAMED_RANGES {
316            return Err(WorkbookError::ResourceLimit(format!(
317                "cannot define named range: workbook already has {} named ranges, the limit \
318                 (scope ADR Decision 5)",
319                limits::MAX_NAMED_RANGES
320            )));
321        }
322        if let Some(existing) = self.name_index(name) {
323            return Err(WorkbookError::DuplicateName(format!(
324                "cannot define named range {name:?}: it collides with the existing name {:?} \
325                 under simple case folding (schema spec §7)",
326                self.names()[existing].name
327            )));
328        }
329        if let Some(existing) = self.table_index(name) {
330            return Err(WorkbookError::DuplicateName(format!(
331                "cannot define named range {name:?}: it collides with the existing table {:?} \
332                 under simple case folding (structured-references spec §4)",
333                self.tables()[existing].name
334            )));
335        }
336        self.names_mut().push(NamedRange {
337            name: name.to_owned(),
338            r#ref: r.to_owned(),
339        });
340        // Borrow the freshly pushed entry (it is last in declaration order;
341        // serialization re-sorts by name independently, §8.7).
342        Ok(self.names().last().expect("just pushed a named range"))
343    }
344
345    /// Redefines the existing named range `name` (case-insensitive) to point at
346    /// the canonical reference `r`, returning the updated [`NamedRange`]. The
347    /// name's identity and original casing are preserved; only the `ref`
348    /// changes.
349    ///
350    /// Validates the `ref` exactly as [`define_name`](Self::define_name) does,
351    /// including that the target sheet exists (no dangling ref). Errors if no
352    /// name currently matches `name` (case-insensitively) or if the `ref` is
353    /// not a valid canonical reference to an existing sheet.
354    pub fn redefine_name(&mut self, name: &str, r: &str) -> Result<&NamedRange, WorkbookError> {
355        self.validate_name_definition(name, r)?;
356        let idx = self.name_index(name).ok_or_else(|| {
357            WorkbookError::NotFound(format!(
358                "cannot redefine named range: no name {name:?} exists"
359            ))
360        })?;
361        // The lookup is case-insensitive, so this preserves the existing name's
362        // identity (including its original casing) and only swaps the `ref`.
363        self.names_mut()[idx].r#ref = r.to_owned();
364        Ok(&self.names()[idx])
365    }
366
367    /// Removes the named range `name` (case-insensitive), returning it if it
368    /// existed, or `None` otherwise.
369    pub fn remove_name(&mut self, name: &str) -> Option<NamedRange> {
370        self.name_index(name).map(|i| self.names_mut().remove(i))
371    }
372
373    /// The named range called `name` (case-insensitive), or `None`. Listing is
374    /// [`names`](crate::Workbook::names).
375    pub fn name(&self, name: &str) -> Option<&NamedRange> {
376        self.name_index(name).map(|i| &self.names()[i])
377    }
378
379    /// Declaration-order index of the named range `name` (case-insensitive,
380    /// simple case folding per schema spec §2/§7), or `None`.
381    fn name_index(&self, name: &str) -> Option<usize> {
382        let folder = CaseMapperBorrowed::new();
383        let target = simple_fold(&folder, name);
384        self.names()
385            .iter()
386            .position(|n| simple_fold(&folder, &n.name) == target)
387    }
388
389    /// Defines a new workbook-scoped table `name` over the canonical range
390    /// `r` (`Sheet!A1:B2` — a table `ref` is always a range, never the
391    /// single-cell form), returning the stored [`Table`].
392    ///
393    /// Validates the name's shape, that `r` is a canonical range referencing
394    /// an existing sheet (no dangling ref), that the name does not already
395    /// collide with an existing table or named range (case-insensitively),
396    /// that the range does not overlap an existing table's range, and the
397    /// table count cap (Decision 5). Unlike [`Workbook::from_json`], this
398    /// does **not** validate the header row's column names — a table may
399    /// legitimately be defined ahead of its header cells being written
400    /// (define the shape first, fill the headers in later). A table defined
401    /// over a headerless or malformed-header region therefore succeeds here,
402    /// but the workbook will fail to reload (`from_json`'s load-time
403    /// validation *does* check header content) if serialized before real
404    /// header text is written at the range's first row
405    /// (structured-references spec §4). To replace an existing table's
406    /// range use [`redefine_table`](Self::redefine_table).
407    pub fn define_table(&mut self, name: &str, r: &str) -> Result<&Table, WorkbookError> {
408        let bounds = self.validate_table_definition(name, r)?;
409        if self.tables().len() >= limits::MAX_TABLES {
410            return Err(WorkbookError::ResourceLimit(format!(
411                "cannot define table: workbook already has {} tables, the limit \
412                 (scope ADR Decision 5)",
413                limits::MAX_TABLES
414            )));
415        }
416        if let Some(existing) = self.table_index(name) {
417            return Err(WorkbookError::DuplicateName(format!(
418                "cannot define table {name:?}: it collides with the existing table {:?} \
419                 under simple case folding (structured-references spec §4)",
420                self.tables()[existing].name
421            )));
422        }
423        if let Some(existing) = self.name_index(name) {
424            return Err(WorkbookError::DuplicateName(format!(
425                "cannot define table {name:?}: it collides with the existing named range {:?} \
426                 under simple case folding (structured-references spec §4)",
427                self.names()[existing].name
428            )));
429        }
430        if let Some(other) = self.overlapping_table(&bounds, None) {
431            return Err(WorkbookError::RangeOverlap(format!(
432                "cannot define table {name:?}: its range overlaps the existing table {other:?} \
433                 (structured-references spec §4)"
434            )));
435        }
436        self.tables_mut().push(Table {
437            name: name.to_owned(),
438            r#ref: r.to_owned(),
439        });
440        // Borrow the freshly pushed entry (it is last in declaration order;
441        // serialization re-sorts by name independently, §8.7).
442        Ok(self.tables().last().expect("just pushed a table"))
443    }
444
445    /// Redefines the existing table `name` (case-insensitive) to point at
446    /// the canonical range `r`, returning the updated [`Table`]. The name's
447    /// identity and original casing are preserved; only the `ref` changes.
448    ///
449    /// Validates the `ref` exactly as [`define_table`](Self::define_table)
450    /// does, including that the target sheet exists (no dangling ref) and
451    /// that the new range does not overlap another table's range. Errors if
452    /// no table currently matches `name` (case-insensitively) or if the
453    /// `ref` is not a valid canonical range to an existing sheet.
454    pub fn redefine_table(&mut self, name: &str, r: &str) -> Result<&Table, WorkbookError> {
455        let bounds = self.validate_table_definition(name, r)?;
456        let idx = self.table_index(name).ok_or_else(|| {
457            WorkbookError::NotFound(format!("cannot redefine table: no table {name:?} exists"))
458        })?;
459        if let Some(other) = self.overlapping_table(&bounds, Some(idx)) {
460            return Err(WorkbookError::RangeOverlap(format!(
461                "cannot redefine table {name:?}: its range overlaps the existing table {other:?} \
462                 (structured-references spec §4)"
463            )));
464        }
465        // The lookup is case-insensitive, so this preserves the existing name's
466        // identity (including its original casing) and only swaps the `ref`.
467        self.tables_mut()[idx].r#ref = r.to_owned();
468        Ok(&self.tables()[idx])
469    }
470
471    /// Removes the table `name` (case-insensitive), returning it if it
472    /// existed, or `None` otherwise.
473    pub fn remove_table(&mut self, name: &str) -> Option<Table> {
474        self.table_index(name).map(|i| self.tables_mut().remove(i))
475    }
476
477    /// The table called `name` (case-insensitive), or `None`. Listing is
478    /// [`tables`](crate::Workbook::tables).
479    pub fn table(&self, name: &str) -> Option<&Table> {
480        self.table_index(name).map(|i| &self.tables()[i])
481    }
482
483    /// Declaration-order index of the table `name` (case-insensitive, simple
484    /// case folding per structured-references spec §4), or `None`.
485    fn table_index(&self, name: &str) -> Option<usize> {
486        let folder = CaseMapperBorrowed::new();
487        let target = simple_fold(&folder, name);
488        self.tables()
489            .iter()
490            .position(|t| simple_fold(&folder, &t.name) == target)
491    }
492
493    /// Shared name/`ref` validation for table define/redefine
494    /// (structured-references spec §4): name shape, canonical `ref` in range
495    /// form, and the target sheet's existence. Returns the parsed range
496    /// bounds (sheet folded, for overlap comparison) on success. Does not
497    /// check name uniqueness, range overlap, or the header row — those
498    /// depend on whether the call is a define or a redefine and are handled
499    /// by each caller (the header row is validated only at document-load
500    /// time, since a table may be defined ahead of its header cells being
501    /// written).
502    fn validate_table_definition(
503        &self,
504        name: &str,
505        r: &str,
506    ) -> Result<ParsedRangeBounds, WorkbookError> {
507        if !named_ref::is_valid_name(name) {
508            return Err(WorkbookError::InvalidReference(format!(
509                "table name {name:?} is invalid: it must match ^[A-Za-z_][A-Za-z0-9_]*$ and \
510                 must not be an A1 address, an R1C1-style reference, or a boolean \
511                 (structured-references spec §4)"
512            )));
513        }
514        let parsed = named_ref::parse_canonical_ref(r).map_err(WorkbookError::InvalidReference)?;
515        if self.sheet(&parsed.sheet).is_none() {
516            return Err(WorkbookError::DanglingSheetRef(format!(
517                "table {name:?} refers to sheet {:?}, which does not exist \
518                 (structured-references spec §4)",
519                parsed.sheet
520            )));
521        }
522        let mut bounds = table_ref::parsed_range_bounds(r, &parsed).ok_or_else(|| {
523            WorkbookError::InvalidReference(format!(
524                "table {name:?} has a malformed ref: a table ref must be a range \
525                 (structured-references spec §4)"
526            ))
527        })?;
528        let folder = CaseMapperBorrowed::new();
529        bounds.sheet = simple_fold(&folder, &bounds.sheet);
530        Ok(bounds)
531    }
532
533    /// The name of an existing table (other than the one at `exclude_idx`,
534    /// if any) whose range overlaps `bounds` (sheet already folded), or
535    /// `None`. `exclude_idx` lets `redefine_table` compare a table's new
536    /// range against every *other* table without it overlapping itself.
537    fn overlapping_table(
538        &self,
539        bounds: &ParsedRangeBounds,
540        exclude_idx: Option<usize>,
541    ) -> Option<String> {
542        let folder = CaseMapperBorrowed::new();
543        for (i, t) in self.tables().iter().enumerate() {
544            if Some(i) == exclude_idx {
545                continue;
546            }
547            // A stored table ref is normally already validated as a
548            // canonical range by `define_table`/`redefine_table`, but
549            // `tables_mut()` is public and lets a caller push an arbitrary
550            // `Table` bypassing that validation — skip (never panic on) a
551            // ref this function can't parse, matching
552            // `expand_table_on_append`'s `let Ok(..) else { .. }` style.
553            let Ok(parsed) = named_ref::parse_canonical_ref(&t.r#ref) else {
554                continue;
555            };
556            let Some(mut other_bounds) = table_ref::parsed_range_bounds(&t.r#ref, &parsed) else {
557                continue;
558            };
559            other_bounds.sheet = simple_fold(&folder, &other_bounds.sheet);
560            if table_ref::ranges_overlap(bounds, &other_bounds) {
561                return Some(t.name.clone());
562            }
563        }
564        None
565    }
566
567    /// Auto-expand-by-append (structured-references spec §4,
568    /// truecalc/core#861): if `addr` on the sheet at `sheet_idx` lands
569    /// exactly one row below a table's current range, within that table's
570    /// column span, extends that table's `ref` by one row — unless the
571    /// expanded range would overlap another table's range, in which case
572    /// the expansion is silently skipped and the table's `ref` is left
573    /// unchanged (the cell write itself still succeeds; auto-expand is a
574    /// best-effort convenience, not a hard requirement, matching how real
575    /// Excel does not auto-expand a table into another table's cells). A
576    /// no-op if no table matches. At most one table's range can be adjacent
577    /// to `addr` to begin with, since table ranges never overlap (§4).
578    fn expand_table_on_append(&mut self, sheet_idx: usize, addr: Address) {
579        let folder = CaseMapperBorrowed::new();
580        let sheet_name = simple_fold(&folder, self.sheets()[sheet_idx].name());
581        let Some(idx) = self.tables().iter().position(|t| {
582            let Ok(parsed) = named_ref::parse_canonical_ref(&t.r#ref) else {
583                return false;
584            };
585            let Some(bounds) = table_ref::parsed_range_bounds(&t.r#ref, &parsed) else {
586                return false;
587            };
588            simple_fold(&folder, &bounds.sheet) == sheet_name
589                && bounds.row_end + 1 == addr.row
590                && bounds.col_start <= addr.column
591                && addr.column <= bounds.col_end
592        }) else {
593            return;
594        };
595
596        let t = &self.tables()[idx];
597        // Already validated as canonical by `define_table`/`redefine_table`.
598        let parsed = named_ref::parse_canonical_ref(&t.r#ref)
599            .expect("stored table ref is already canonical");
600        let bounds = table_ref::parsed_range_bounds(&t.r#ref, &parsed)
601            .expect("stored table ref is already a validated range");
602
603        // Before committing the expansion, check the *would-be-expanded*
604        // rectangle against every other table's range, the same overlap
605        // helper `define_table`/`redefine_table` use (Finding 1, final PR2
606        // review: an unchecked expansion could silently create two
607        // overlapping tables, producing a workbook that can't reload).
608        let new_bounds = ParsedRangeBounds {
609            sheet: simple_fold(&folder, &bounds.sheet),
610            row_start: bounds.row_start,
611            row_end: addr.row,
612            col_start: bounds.col_start,
613            col_end: bounds.col_end,
614        };
615        if self.overlapping_table(&new_bounds, Some(idx)).is_some() {
616            return; // best-effort convenience only; leave the table as-is
617        }
618
619        let sheet_token = named_ref::quote_sheet_if_needed(&parsed.sheet);
620        let start = Address::new(bounds.row_start, bounds.col_start)
621            .expect("bounds were derived from an already-validated ref");
622        let end = Address::new(addr.row, bounds.col_end)
623            .expect("bounds were derived from an already-validated ref");
624        self.tables_mut()[idx].r#ref = format!("{sheet_token}!{}:{}", start.to_a1(), end.to_a1());
625    }
626
627    /// Shared name/`ref` validation for define/redefine (schema spec §7):
628    /// name shape, canonical `ref`, and the target sheet's existence. Does not
629    /// check uniqueness or the count cap — those depend on whether the call is
630    /// a define or a redefine and are handled by each caller.
631    fn validate_name_definition(&self, name: &str, r: &str) -> Result<(), WorkbookError> {
632        if !named_ref::is_valid_name(name) {
633            return Err(WorkbookError::InvalidReference(format!(
634                "named-range name {name:?} is invalid: it must match ^[A-Za-z_][A-Za-z0-9_]*$ and \
635                 must not be an A1 address, an R1C1-style reference, or a boolean (schema spec §7)"
636            )));
637        }
638        let parsed = named_ref::parse_canonical_ref(r).map_err(WorkbookError::InvalidReference)?;
639        if self.sheet(&parsed.sheet).is_none() {
640            return Err(WorkbookError::DanglingSheetRef(format!(
641                "named range {name:?} refers to sheet {:?}, which does not exist (schema spec §7)",
642                parsed.sheet
643            )));
644        }
645        Ok(())
646    }
647
648    /// Validates a formula's syntax (issue #536: "parsed with the workbook's
649    /// locked engine"). Parses only — no evaluation — so an unevaluated formula
650    /// cell is still guaranteed to hold syntactically valid text.
651    ///
652    /// Calls the parser directly rather than through an [`Engine`]: parsing is
653    /// flavor-independent (`Engine::parse` ignores the flavor and forwards to
654    /// this same entry point) and never reads the function registry, so
655    /// building an engine here bought nothing and cost a full 518-function
656    /// registry construction on **every formula cell written** — orders of
657    /// magnitude more than the parse itself (issue #900).
658    fn validate_formula(&self, formula: &str) -> Result<(), WorkbookError> {
659        truecalc_core::parse_formula(formula)
660            .map(|_| ())
661            .map_err(|e| {
662                WorkbookError::InvalidFormula(format!("formula {formula:?} is invalid: {e}"))
663            })
664    }
665}
666
667/// Eager `text`/`array` size caps of scope ADR Decision 5, mirroring the
668/// serialize-boundary checks in `validate.rs` for the mutation path.
669fn check_value_limits(value: &Value) -> Result<(), WorkbookError> {
670    match value {
671        Value::Text(s) => {
672            let len = s.chars().count();
673            if len > limits::MAX_TEXT_LEN {
674                return Err(WorkbookError::ResourceLimit(format!(
675                    "text value has {len} scalar values, exceeding the limit of {} \
676                     (scope ADR Decision 5)",
677                    limits::MAX_TEXT_LEN
678                )));
679            }
680        }
681        Value::Array(rows) => {
682            let elems: usize = rows.iter().map(|r| r.len()).sum();
683            if elems > limits::MAX_ARRAY_ELEMENTS {
684                return Err(WorkbookError::ResourceLimit(format!(
685                    "array value has {elems} elements, exceeding the limit of {} \
686                     (scope ADR Decision 5)",
687                    limits::MAX_ARRAY_ELEMENTS
688                )));
689            }
690        }
691        _ => {}
692    }
693    Ok(())
694}
695
696/// Eager formula-length cap of scope ADR Decision 5 (bytes), mirroring the
697/// serialize-boundary check for the mutation path.
698fn check_formula_limit(formula: &str) -> Result<(), WorkbookError> {
699    if formula.len() > limits::MAX_FORMULA_LEN {
700        return Err(WorkbookError::ResourceLimit(format!(
701            "formula is {} bytes, exceeding the limit of {} bytes (scope ADR Decision 5)",
702            formula.len(),
703            limits::MAX_FORMULA_LEN
704        )));
705    }
706    Ok(())
707}