Skip to main content

truecalc_core/engine/
mod.rs

1use std::collections::HashMap;
2
3use crate::eval::functions::Registry;
4use crate::eval::{evaluate_expr, Context, EvalCtx, EvalHook, Resolver};
5use crate::parser::{parse_formula, Expr};
6use crate::types::{ErrorKind, ParseError, Value};
7
8mod grid_edit;
9
10pub use grid_edit::{Axis, AxisMove, GridEdit};
11mod rename;
12mod translate;
13
14/// Which spreadsheet product's semantics the engine targets.
15///
16/// The engine flavor also locks the **date serial system** (P1.4, issue #526):
17///
18/// - `Sheets`: day 0 = 1899-12-30; no leap-year bug (1900-02-28 = serial 60,
19///   1900-03-01 = serial 61, no serial for the nonexistent 1900-02-29).
20/// - `Excel`: 1900 date system — serial 1 = 1900-01-01, **including** the
21///   historical Lotus 1-2-3 leap-year bug (serial 60 = the fictitious
22///   1900-02-29). Conversion helpers live in
23///   `eval::functions::date::serial`; Excel evaluation itself is still
24///   stubbed (`evaluate` returns `#UNSUPPORTED!`).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
28pub enum EngineFlavor {
29    Sheets,
30    Excel,
31}
32
33pub struct Engine {
34    flavor: EngineFlavor,
35    registry: Registry,
36}
37
38impl Engine {
39    /// Engine targeting Google Sheets conformance.
40    pub fn sheets() -> Self {
41        Self { flavor: EngineFlavor::Sheets, registry: Registry::new() }
42    }
43
44    /// Engine targeting Excel conformance.
45    ///
46    /// Excel evaluation semantics are not implemented yet (they land in a
47    /// later phase): [`Engine::evaluate`] returns
48    /// `Value::Error(ErrorKind::Unsupported)` for every formula. [`Engine::parse`]
49    /// and [`Engine::validate`] work.
50    pub fn excel() -> Self {
51        Self { flavor: EngineFlavor::Excel, registry: Registry::new() }
52    }
53
54    /// Deprecated alias for [`Engine::sheets`].
55    #[deprecated(since = "0.7.0", note = "use Engine::sheets() — engine flavor is required; see ADR 2026-04-27; removal target: 0.7.0 coordinated release")]
56    pub fn google_sheets() -> Self {
57        Self::sheets()
58    }
59
60    /// The engine flavor this instance targets.
61    ///
62    /// Flavor is fixed at construction (`Engine::sheets()` / `Engine::excel()`);
63    /// there is no way to change it on an existing engine (engine-flavor ADR
64    /// 2026-04-27). The workbook layer uses this to assert a workbook's locked
65    /// [`EngineFlavor`] matches the engine driving its recalc.
66    pub fn flavor(&self) -> EngineFlavor {
67        self.flavor
68    }
69
70    /// Parse a formula string into an expression tree.
71    ///
72    /// The formula may start with `=`. Returns a [`ParseError`] if the input
73    /// is not a valid formula.
74    ///
75    /// Parsing is flavor-independent and never reads the function registry, so
76    /// a caller that only needs the AST can call [`crate::parse_formula`]
77    /// directly instead of constructing an engine (issue #900).
78    pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
79        parse_formula(formula)
80    }
81
82    /// Validate that a formula string is syntactically correct without
83    /// returning the AST.
84    ///
85    /// A syntax check is exactly a parse: see [`Engine::parse`] for why a
86    /// caller that only validates need not build an engine.
87    pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
88        self.parse(formula).map(|_| ())
89    }
90
91    /// Shift every relative axis of every cell/range reference in `formula`
92    /// by `(d_row, d_col)` — the fill / copy-paste reference-adjustment
93    /// transform. `$`-absolute axes are left unchanged. An axis that shifts
94    /// out of the Sheets grid becomes a literal `#REF!` for that corner.
95    ///
96    /// Sheets flavor only: `Engine::excel().translate_formula(...)` returns
97    /// `Err` until Excel grid bounds are established.
98    pub fn translate_formula(&self, formula: &str, d_row: i64, d_col: i64) -> Result<String, ParseError> {
99        if self.flavor == EngineFlavor::Excel {
100            return Err(ParseError {
101                message: "translate_formula: Excel flavor not yet supported".into(),
102                position: 0,
103            });
104        }
105        translate::translate_text(formula, d_row, d_col)
106    }
107
108    /// Rewrite the sheet qualifier of every cell/range reference in `formula`
109    /// that points at `old` to point at `new` instead — the sheet-rename
110    /// reference-rewrite transform. Sheet-name matching is case-insensitive
111    /// (mirrors the workbook crate's own sheet-identity rule: sheet names are
112    /// unique case-insensitively, and a pure case-change rename is allowed).
113    /// Requoting is handled automatically. Unqualified refs, refs to other
114    /// sheets, string literals, function names, and defined names are left
115    /// untouched. No-op if `formula` has no `old`-qualified refs.
116    pub fn rename_sheet_refs(&self, formula: &str, old: &str, new: &str) -> Result<String, ParseError> {
117        rename::rename_sheet_refs_text(formula, old, new)
118    }
119
120    /// Rewrite the cell/range references in `formula` for a row/column insert
121    /// or delete — the structural-edit reference-rewrite transform.
122    ///
123    /// Unlike [`Engine::translate_formula`], which applies a uniform offset,
124    /// a [`GridEdit`] moves references *conditionally*: those before the edit
125    /// index stay put, those at or after it shift by `count`, a range
126    /// straddling the edit grows (insert) or shrinks (delete), and a
127    /// reference whose every row/column was deleted becomes `#REF!` — the
128    /// whole reference, sheet qualifier included, since `Sheet1!#REF!` does
129    /// not re-parse.
130    ///
131    /// `$` anchors do **not** exempt an axis here: `$` governs how a
132    /// reference is *copied*, not which cell it points at, so `$A$5` tracks
133    /// its cell through an insert exactly as `A5` does. The anchors are
134    /// preserved in the output.
135    ///
136    /// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
137    /// resolves to; `edited_sheet` is the sheet the rows/columns were
138    /// inserted into or deleted from. Only references resolving to
139    /// `edited_sheet` are touched, so a formula's references to other sheets
140    /// never move. Matching is case-insensitive, as in
141    /// [`Engine::rename_sheet_refs`]. String literals, function names,
142    /// defined names and `LET`/`LAMBDA` bindings are left untouched.
143    ///
144    /// Returns `Err` if `formula` does not parse, if the edit's `at` is `0`
145    /// (rows and columns are 1-based), or for `EngineFlavor::Excel`, whose
146    /// grid bounds are not established yet — the same guard
147    /// [`Engine::translate_formula`] carries.
148    ///
149    /// ```
150    /// use truecalc_core::{Engine, GridEdit};
151    ///
152    /// let engine = Engine::sheets();
153    /// let edit = GridEdit::DeleteRows { at: 2, count: 2 };
154    ///
155    /// // A formula on Sheet1, and rows deleted from Sheet1: the range shrinks
156    /// // and the cell inside the deleted band is gone.
157    /// let out = engine.shift_refs_for_grid_edit("=SUM(A1:A5)+A3", "Sheet1", "Sheet1", edit).unwrap();
158    /// assert_eq!(out, "=SUM(A1:A3)+#REF!");
159    ///
160    /// // The same formula living on Sheet2 instead: its bare refs mean
161    /// // Sheet2, which the Sheet1 edit does not touch, so nothing moves. Note
162    /// // the argument order — `formula_sheet` first, then `edited_sheet`.
163    /// let out = engine.shift_refs_for_grid_edit("=SUM(A1:A5)+A3", "Sheet2", "Sheet1", edit).unwrap();
164    /// assert_eq!(out, "=SUM(A1:A5)+A3");
165    ///
166    /// // ...but its explicitly Sheet1-qualified refs still move.
167    /// let out = engine.shift_refs_for_grid_edit("=SUM(Sheet1!A1:A5)", "Sheet2", "Sheet1", edit).unwrap();
168    /// assert_eq!(out, "=SUM(Sheet1!A1:A3)");
169    /// ```
170    pub fn shift_refs_for_grid_edit(
171        &self,
172        formula: &str,
173        formula_sheet: &str,
174        edited_sheet: &str,
175        edit: GridEdit,
176    ) -> Result<String, ParseError> {
177        if self.flavor == EngineFlavor::Excel {
178            return Err(ParseError {
179                message: "shift_refs_for_grid_edit: Excel flavor not yet supported".into(),
180                position: 0,
181            });
182        }
183        grid_edit::shift_refs_text(formula, formula_sheet, edited_sheet, edit)
184    }
185
186    /// Rewrite the cell/range references in `formula` for a row/column
187    /// **move** — relocating the contiguous band `mv.start..=mv.end` on
188    /// `edited_sheet` so it starts at `mv.at`, without inserting or deleting
189    /// anything.
190    ///
191    /// Unlike [`Engine::shift_refs_for_grid_edit`], which can shift a
192    /// reference away or drop it as `#REF!`, a move is a *total* remap:
193    /// nothing is created or destroyed, so every coordinate on the moved
194    /// axis maps to exactly one output coordinate. A coordinate inside the
195    /// moved band translates onto the band's new start; a coordinate
196    /// between the band's old and new position slides by the band's width
197    /// in the opposite direction, closing the gap the band left; everything
198    /// else is unchanged.
199    ///
200    /// `mv.at` landing inside `mv.start..=mv.end` has no well-defined
201    /// destination — there is no way to "move a band into the middle of
202    /// itself" — so it is a silent no-op, the same way
203    /// [`GridEdit`]'s own `count: 0` is. `mv.at == mv.end + 1` is *not* part
204    /// of that no-op range: it is the smallest genuine forward move,
205    /// swapping the band with the immediately following equal-width block.
206    ///
207    /// Mapping a range's two endpoints independently can flip their
208    /// relative order even when they were written ascending — moving rows
209    /// 5:7 to before row 2 sends row 3's content to row 6 and row 6's
210    /// content to row 3, so `A4:A6` maps to `A3:A7`, not `A7:A3`. The same
211    /// correction runs in the mirror direction too: a range deliberately
212    /// written backwards (`A6:A4`) that this same move would otherwise
213    /// "uncross" into ascending order is swapped back so it stays
214    /// backwards, the way [`Engine::shift_refs_for_grid_edit`] preserves a
215    /// backwards-written range through insert/delete. A backwards range
216    /// unaffected by the move (`A7:A5`, nowhere near the band) simply keeps
217    /// its written orientation, since nothing about it changed.
218    ///
219    /// `$` anchors do **not** exempt an axis here either: `$` governs how a
220    /// reference is *copied*, not which cell it points at, so `$A$6` moves
221    /// exactly as `A6` does. The anchors are preserved in the output.
222    ///
223    /// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
224    /// resolves to; `edited_sheet` is the sheet the band moved on. Only
225    /// references resolving to `edited_sheet` are touched. Matching is
226    /// case-insensitive, as in [`Engine::rename_sheet_refs`].
227    ///
228    /// Returns `Err` if `formula` does not parse, if `mv.start` or `mv.at`
229    /// is `0` (rows and columns are 1-based), if `mv.start > mv.end`, if
230    /// `mv.at` would push the band off the grid, or for `EngineFlavor::Excel`,
231    /// whose grid bounds are not established yet — the same guard
232    /// [`Engine::shift_refs_for_grid_edit`] carries. A move never grows the
233    /// sheet, so an off-grid *result* cannot happen from a well-formed
234    /// [`AxisMove`]; only an off-grid *request* is rejected, once, here.
235    ///
236    /// ```
237    /// use truecalc_core::{Axis, AxisMove, Engine};
238    ///
239    /// let engine = Engine::sheets();
240    ///
241    /// // Moving rows 5:7 to row 2: independently-mapped endpoints invert
242    /// // (row 4's content ends up at row 7, row 6's at row 3), so the
243    /// // range is normalized back to ascending order.
244    /// let mv = AxisMove { axis: Axis::Row, start: 5, end: 7, at: 2 };
245    /// let out = engine.shift_refs_for_move("=SUM(A4:A6)", "Sheet1", "Sheet1", mv).unwrap();
246    /// assert_eq!(out, "=SUM(A3:A7)");
247    ///
248    /// // The same move applied to a range deliberately written backwards
249    /// // (A6:A4) would otherwise "uncross" it into ascending order — it is
250    /// // swapped back so it stays backwards, mirroring the ascending case.
251    /// let out = engine.shift_refs_for_move("=SUM(A6:A4)", "Sheet1", "Sheet1", mv).unwrap();
252    /// assert_eq!(out, "=SUM(A7:A3)");
253    ///
254    /// // `$` governs how a reference copies, not what it points at, so it
255    /// // does not exempt an axis from a move either.
256    /// let out = engine.shift_refs_for_move("=$A$6", "Sheet1", "Sheet1", mv).unwrap();
257    /// assert_eq!(out, "=$A$3");
258    ///
259    /// // `at` inside the band itself has no well-defined destination: a no-op.
260    /// let noop = AxisMove { axis: Axis::Row, start: 5, end: 7, at: 6 };
261    /// let out = engine.shift_refs_for_move("=SUM(A1:A10)", "Sheet1", "Sheet1", noop).unwrap();
262    /// assert_eq!(out, "=SUM(A1:A10)");
263    /// ```
264    pub fn shift_refs_for_move(
265        &self,
266        formula: &str,
267        formula_sheet: &str,
268        edited_sheet: &str,
269        mv: AxisMove,
270    ) -> Result<String, ParseError> {
271        if self.flavor == EngineFlavor::Excel {
272            return Err(ParseError {
273                message: "shift_refs_for_move: Excel flavor not yet supported".into(),
274                position: 0,
275            });
276        }
277        grid_edit::shift_refs_for_move(formula, formula_sheet, edited_sheet, mv)
278    }
279
280    /// Evaluate a formula string with named variables.
281    ///
282    /// Array results flow through **unspilled**: a formula producing an array
283    /// returns the full [`Value::Array`] — spilling it across cells (or
284    /// collapsing it for a single-cell view) is the workbook/surface layer's
285    /// job, not the evaluator's (P1.4, issue #526).
286    ///
287    /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
288    /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
289    pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
290        self.evaluate_inner(formula, variables, None)
291    }
292
293    /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
294    /// pinned to `now_serial`, a local-time spreadsheet serial datetime
295    /// (integer part = day serial in this engine's date system, fractional
296    /// part = time of day).
297    ///
298    /// Same formula + same variables + same `now_serial` ⇒ identical result.
299    /// This is the core-level hook the workbook layer's `RecalcContext`
300    /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
301    /// the caller converts its UTC instant + timezone to a local serial and
302    /// passes it here. Conformance fixture rows for volatile formulas are
303    /// verified by pinning `now_serial` to the fixture's recorded
304    /// `meta.evaluatedAt`.
305    ///
306    /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
307    pub fn evaluate_at(
308        &self,
309        formula: &str,
310        variables: &HashMap<String, Value>,
311        now_serial: f64,
312    ) -> Value {
313        if !now_serial.is_finite() {
314            return Value::Error(ErrorKind::Num);
315        }
316        self.evaluate_inner(formula, variables, Some(now_serial))
317    }
318
319    /// Evaluate a formula string, resolving references through `resolver`.
320    ///
321    /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
322    /// (which reads references from a variable map and treats anything unbound
323    /// as [`Value::Empty`]), every cell, range, and name reference that is not
324    /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
325    /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
326    /// undefined name, ranges materialized to [`Value::Array`]. See
327    /// [`Resolver`].
328    ///
329    /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
330    /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
331    /// [`Engine::evaluate`].
332    ///
333    /// ```
334    /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
335    ///
336    /// struct OneSheet;
337    /// impl Resolver for OneSheet {
338    ///     fn resolve(&mut self, r: &Ref) -> Value {
339    ///         match r {
340    ///             Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
341    ///             Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
342    ///             _ => Value::Empty,
343    ///         }
344    ///     }
345    /// }
346    ///
347    /// let engine = Engine::sheets();
348    /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
349    /// assert_eq!(
350    ///     engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
351    ///     Value::Error(ErrorKind::Ref),
352    /// );
353    /// ```
354    pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
355        self.evaluate_with_resolver_at(formula, resolver, None)
356    }
357
358    /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
359    /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
360    /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
361    /// `now_serial` is not finite.
362    pub fn evaluate_with_resolver_at(
363        &self,
364        formula: &str,
365        resolver: &mut impl Resolver,
366        now_serial: Option<f64>,
367    ) -> Value {
368        if let Some(n) = now_serial {
369            if !n.is_finite() {
370                return Value::Error(ErrorKind::Num);
371            }
372        }
373        if self.flavor == EngineFlavor::Excel {
374            return Value::Error(ErrorKind::Unsupported);
375        }
376        match parse_formula(formula) {
377            Err(_) => Value::Error(ErrorKind::Value),
378            Ok(expr) => {
379                let mut ctx = Context::empty();
380                ctx.now_serial = now_serial;
381                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
382                evaluate_expr(&expr, &mut eval_ctx)
383            }
384        }
385    }
386
387    /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
388    /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
389    /// this degrades to the non-deterministic SystemTime fallback in RAND.
390    pub fn evaluate_with_resolver_at_keyed(
391        &self,
392        formula: &str,
393        resolver: &mut dyn Resolver,
394        now_serial: Option<f64>,
395        now_utc_nanos: Option<i64>,
396        rng_cell: Option<(u64, u32, u32, u32)>,
397    ) -> Value {
398        self.evaluate_with_resolver_at_keyed_hooked(
399            formula,
400            resolver,
401            now_serial,
402            now_utc_nanos,
403            rng_cell,
404            None,
405        )
406    }
407
408    /// Like [`Engine::evaluate_with_resolver_at_keyed`], but additionally
409    /// wires an opt-in per-node [`EvalHook`] (issue #743) onto the
410    /// [`EvalCtx`] built for this evaluation. `hook: None` is exactly
411    /// [`Engine::evaluate_with_resolver_at_keyed`] — same code path, same
412    /// value, no observation overhead beyond the `Option` check already paid
413    /// by [`evaluate_expr`]'s per-node hook branch. This is the seam the
414    /// workbook layer's single-cell tracer (`Workbook::trace_cell`) uses to
415    /// reach a real cell's evaluation with the same resolver-backed
416    /// semantics `recalc` uses, rather than re-deriving its own `EvalCtx`.
417    pub fn evaluate_with_resolver_at_keyed_hooked<'r>(
418        &'r self,
419        formula: &str,
420        resolver: &'r mut dyn Resolver,
421        now_serial: Option<f64>,
422        now_utc_nanos: Option<i64>,
423        rng_cell: Option<(u64, u32, u32, u32)>,
424        hook: Option<&'r mut dyn EvalHook>,
425    ) -> Value {
426        if let Some(n) = now_serial {
427            if !n.is_finite() {
428                return Value::Error(ErrorKind::Num);
429            }
430        }
431        if self.flavor == EngineFlavor::Excel {
432            return Value::Error(ErrorKind::NA);
433        }
434        match parse_formula(formula) {
435            Err(_) => Value::Error(ErrorKind::Value),
436            Ok(expr) => {
437                let mut ctx = Context::empty();
438                ctx.now_serial = now_serial;
439                ctx.now_utc_nanos = now_utc_nanos;
440                ctx.rng_cell = rng_cell;
441                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
442                eval_ctx.hook = hook;
443                evaluate_expr(&expr, &mut eval_ctx)
444            }
445        }
446    }
447
448    fn evaluate_inner(
449        &self,
450        formula: &str,
451        variables: &HashMap<String, Value>,
452        now_serial: Option<f64>,
453    ) -> Value {
454        if self.flavor == EngineFlavor::Excel {
455            // Excel evaluation semantics are not implemented yet.
456            return Value::Error(ErrorKind::Unsupported);
457        }
458        match parse_formula(formula) {
459            Err(_) => Value::Error(ErrorKind::Value),
460            Ok(expr) => {
461                let mut ctx = Context::new(variables.clone());
462                ctx.now_serial = now_serial;
463                let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
464                evaluate_expr(&expr, &mut eval_ctx)
465            }
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests;