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}