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 rename;
9mod translate;
10
11/// Which spreadsheet product's semantics the engine targets.
12///
13/// The engine flavor also locks the **date serial system** (P1.4, issue #526):
14///
15/// - `Sheets`: day 0 = 1899-12-30; no leap-year bug (1900-02-28 = serial 60,
16///   1900-03-01 = serial 61, no serial for the nonexistent 1900-02-29).
17/// - `Excel`: 1900 date system — serial 1 = 1900-01-01, **including** the
18///   historical Lotus 1-2-3 leap-year bug (serial 60 = the fictitious
19///   1900-02-29). Conversion helpers live in
20///   `eval::functions::date::serial`; Excel evaluation itself is still
21///   stubbed (`evaluate` returns `#UNSUPPORTED!`).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
25pub enum EngineFlavor {
26    Sheets,
27    Excel,
28}
29
30pub struct Engine {
31    flavor: EngineFlavor,
32    registry: Registry,
33}
34
35impl Engine {
36    /// Engine targeting Google Sheets conformance.
37    pub fn sheets() -> Self {
38        Self { flavor: EngineFlavor::Sheets, registry: Registry::new() }
39    }
40
41    /// Engine targeting Excel conformance.
42    ///
43    /// Excel evaluation semantics are not implemented yet (they land in a
44    /// later phase): [`Engine::evaluate`] returns
45    /// `Value::Error(ErrorKind::Unsupported)` for every formula. [`Engine::parse`]
46    /// and [`Engine::validate`] work.
47    pub fn excel() -> Self {
48        Self { flavor: EngineFlavor::Excel, registry: Registry::new() }
49    }
50
51    /// Deprecated alias for [`Engine::sheets`].
52    #[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")]
53    pub fn google_sheets() -> Self {
54        Self::sheets()
55    }
56
57    /// The engine flavor this instance targets.
58    ///
59    /// Flavor is fixed at construction (`Engine::sheets()` / `Engine::excel()`);
60    /// there is no way to change it on an existing engine (engine-flavor ADR
61    /// 2026-04-27). The workbook layer uses this to assert a workbook's locked
62    /// [`EngineFlavor`] matches the engine driving its recalc.
63    pub fn flavor(&self) -> EngineFlavor {
64        self.flavor
65    }
66
67    /// Parse a formula string into an expression tree.
68    ///
69    /// The formula may start with `=`. Returns a [`ParseError`] if the input
70    /// is not a valid formula.
71    ///
72    /// Parsing is flavor-independent and never reads the function registry, so
73    /// a caller that only needs the AST can call [`crate::parse_formula`]
74    /// directly instead of constructing an engine (issue #900).
75    pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
76        parse_formula(formula)
77    }
78
79    /// Validate that a formula string is syntactically correct without
80    /// returning the AST.
81    ///
82    /// A syntax check is exactly a parse: see [`Engine::parse`] for why a
83    /// caller that only validates need not build an engine.
84    pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
85        self.parse(formula).map(|_| ())
86    }
87
88    /// Shift every relative axis of every cell/range reference in `formula`
89    /// by `(d_row, d_col)` — the fill / copy-paste reference-adjustment
90    /// transform. `$`-absolute axes are left unchanged. An axis that shifts
91    /// out of the Sheets grid becomes a literal `#REF!` for that corner.
92    ///
93    /// Sheets flavor only: `Engine::excel().translate_formula(...)` returns
94    /// `Err` until Excel grid bounds are established.
95    pub fn translate_formula(&self, formula: &str, d_row: i64, d_col: i64) -> Result<String, ParseError> {
96        if self.flavor == EngineFlavor::Excel {
97            return Err(ParseError {
98                message: "translate_formula: Excel flavor not yet supported".into(),
99                position: 0,
100            });
101        }
102        translate::translate_text(formula, d_row, d_col)
103    }
104
105    /// Rewrite the sheet qualifier of every cell/range reference in `formula`
106    /// that points at `old` to point at `new` instead — the sheet-rename
107    /// reference-rewrite transform. Sheet-name matching is case-insensitive
108    /// (mirrors the workbook crate's own sheet-identity rule: sheet names are
109    /// unique case-insensitively, and a pure case-change rename is allowed).
110    /// Requoting is handled automatically. Unqualified refs, refs to other
111    /// sheets, string literals, function names, and defined names are left
112    /// untouched. No-op if `formula` has no `old`-qualified refs.
113    pub fn rename_sheet_refs(&self, formula: &str, old: &str, new: &str) -> Result<String, ParseError> {
114        rename::rename_sheet_refs_text(formula, old, new)
115    }
116
117    /// Evaluate a formula string with named variables.
118    ///
119    /// Array results flow through **unspilled**: a formula producing an array
120    /// returns the full [`Value::Array`] — spilling it across cells (or
121    /// collapsing it for a single-cell view) is the workbook/surface layer's
122    /// job, not the evaluator's (P1.4, issue #526).
123    ///
124    /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
125    /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
126    pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
127        self.evaluate_inner(formula, variables, None)
128    }
129
130    /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
131    /// pinned to `now_serial`, a local-time spreadsheet serial datetime
132    /// (integer part = day serial in this engine's date system, fractional
133    /// part = time of day).
134    ///
135    /// Same formula + same variables + same `now_serial` ⇒ identical result.
136    /// This is the core-level hook the workbook layer's `RecalcContext`
137    /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
138    /// the caller converts its UTC instant + timezone to a local serial and
139    /// passes it here. Conformance fixture rows for volatile formulas are
140    /// verified by pinning `now_serial` to the fixture's recorded
141    /// `meta.evaluatedAt`.
142    ///
143    /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
144    pub fn evaluate_at(
145        &self,
146        formula: &str,
147        variables: &HashMap<String, Value>,
148        now_serial: f64,
149    ) -> Value {
150        if !now_serial.is_finite() {
151            return Value::Error(ErrorKind::Num);
152        }
153        self.evaluate_inner(formula, variables, Some(now_serial))
154    }
155
156    /// Evaluate a formula string, resolving references through `resolver`.
157    ///
158    /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
159    /// (which reads references from a variable map and treats anything unbound
160    /// as [`Value::Empty`]), every cell, range, and name reference that is not
161    /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
162    /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
163    /// undefined name, ranges materialized to [`Value::Array`]. See
164    /// [`Resolver`].
165    ///
166    /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
167    /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
168    /// [`Engine::evaluate`].
169    ///
170    /// ```
171    /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
172    ///
173    /// struct OneSheet;
174    /// impl Resolver for OneSheet {
175    ///     fn resolve(&mut self, r: &Ref) -> Value {
176    ///         match r {
177    ///             Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
178    ///             Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
179    ///             _ => Value::Empty,
180    ///         }
181    ///     }
182    /// }
183    ///
184    /// let engine = Engine::sheets();
185    /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
186    /// assert_eq!(
187    ///     engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
188    ///     Value::Error(ErrorKind::Ref),
189    /// );
190    /// ```
191    pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
192        self.evaluate_with_resolver_at(formula, resolver, None)
193    }
194
195    /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
196    /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
197    /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
198    /// `now_serial` is not finite.
199    pub fn evaluate_with_resolver_at(
200        &self,
201        formula: &str,
202        resolver: &mut impl Resolver,
203        now_serial: Option<f64>,
204    ) -> Value {
205        if let Some(n) = now_serial {
206            if !n.is_finite() {
207                return Value::Error(ErrorKind::Num);
208            }
209        }
210        if self.flavor == EngineFlavor::Excel {
211            return Value::Error(ErrorKind::Unsupported);
212        }
213        match parse_formula(formula) {
214            Err(_) => Value::Error(ErrorKind::Value),
215            Ok(expr) => {
216                let mut ctx = Context::empty();
217                ctx.now_serial = now_serial;
218                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
219                evaluate_expr(&expr, &mut eval_ctx)
220            }
221        }
222    }
223
224    /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
225    /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
226    /// this degrades to the non-deterministic SystemTime fallback in RAND.
227    pub fn evaluate_with_resolver_at_keyed(
228        &self,
229        formula: &str,
230        resolver: &mut dyn Resolver,
231        now_serial: Option<f64>,
232        now_utc_nanos: Option<i64>,
233        rng_cell: Option<(u64, u32, u32, u32)>,
234    ) -> Value {
235        self.evaluate_with_resolver_at_keyed_hooked(
236            formula,
237            resolver,
238            now_serial,
239            now_utc_nanos,
240            rng_cell,
241            None,
242        )
243    }
244
245    /// Like [`Engine::evaluate_with_resolver_at_keyed`], but additionally
246    /// wires an opt-in per-node [`EvalHook`] (issue #743) onto the
247    /// [`EvalCtx`] built for this evaluation. `hook: None` is exactly
248    /// [`Engine::evaluate_with_resolver_at_keyed`] — same code path, same
249    /// value, no observation overhead beyond the `Option` check already paid
250    /// by [`evaluate_expr`]'s per-node hook branch. This is the seam the
251    /// workbook layer's single-cell tracer (`Workbook::trace_cell`) uses to
252    /// reach a real cell's evaluation with the same resolver-backed
253    /// semantics `recalc` uses, rather than re-deriving its own `EvalCtx`.
254    pub fn evaluate_with_resolver_at_keyed_hooked<'r>(
255        &'r self,
256        formula: &str,
257        resolver: &'r mut dyn Resolver,
258        now_serial: Option<f64>,
259        now_utc_nanos: Option<i64>,
260        rng_cell: Option<(u64, u32, u32, u32)>,
261        hook: Option<&'r mut dyn EvalHook>,
262    ) -> Value {
263        if let Some(n) = now_serial {
264            if !n.is_finite() {
265                return Value::Error(ErrorKind::Num);
266            }
267        }
268        if self.flavor == EngineFlavor::Excel {
269            return Value::Error(ErrorKind::NA);
270        }
271        match parse_formula(formula) {
272            Err(_) => Value::Error(ErrorKind::Value),
273            Ok(expr) => {
274                let mut ctx = Context::empty();
275                ctx.now_serial = now_serial;
276                ctx.now_utc_nanos = now_utc_nanos;
277                ctx.rng_cell = rng_cell;
278                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
279                eval_ctx.hook = hook;
280                evaluate_expr(&expr, &mut eval_ctx)
281            }
282        }
283    }
284
285    fn evaluate_inner(
286        &self,
287        formula: &str,
288        variables: &HashMap<String, Value>,
289        now_serial: Option<f64>,
290    ) -> Value {
291        if self.flavor == EngineFlavor::Excel {
292            // Excel evaluation semantics are not implemented yet.
293            return Value::Error(ErrorKind::Unsupported);
294        }
295        match parse_formula(formula) {
296            Err(_) => Value::Error(ErrorKind::Value),
297            Ok(expr) => {
298                let mut ctx = Context::new(variables.clone());
299                ctx.now_serial = now_serial;
300                let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
301                evaluate_expr(&expr, &mut eval_ctx)
302            }
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests;