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    pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
72        parse_formula(formula)
73    }
74
75    /// Validate that a formula string is syntactically correct without
76    /// returning the AST.
77    pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
78        self.parse(formula).map(|_| ())
79    }
80
81    /// Shift every relative axis of every cell/range reference in `formula`
82    /// by `(d_row, d_col)` — the fill / copy-paste reference-adjustment
83    /// transform. `$`-absolute axes are left unchanged. An axis that shifts
84    /// out of the Sheets grid becomes a literal `#REF!` for that corner.
85    ///
86    /// Sheets flavor only: `Engine::excel().translate_formula(...)` returns
87    /// `Err` until Excel grid bounds are established.
88    pub fn translate_formula(&self, formula: &str, d_row: i64, d_col: i64) -> Result<String, ParseError> {
89        if self.flavor == EngineFlavor::Excel {
90            return Err(ParseError {
91                message: "translate_formula: Excel flavor not yet supported".into(),
92                position: 0,
93            });
94        }
95        translate::translate_text(formula, d_row, d_col)
96    }
97
98    /// Rewrite the sheet qualifier of every cell/range reference in `formula`
99    /// that points at `old` to point at `new` instead — the sheet-rename
100    /// reference-rewrite transform. Sheet-name matching is case-insensitive
101    /// (mirrors the workbook crate's own sheet-identity rule: sheet names are
102    /// unique case-insensitively, and a pure case-change rename is allowed).
103    /// Requoting is handled automatically. Unqualified refs, refs to other
104    /// sheets, string literals, function names, and defined names are left
105    /// untouched. No-op if `formula` has no `old`-qualified refs.
106    pub fn rename_sheet_refs(&self, formula: &str, old: &str, new: &str) -> Result<String, ParseError> {
107        rename::rename_sheet_refs_text(formula, old, new)
108    }
109
110    /// Evaluate a formula string with named variables.
111    ///
112    /// Array results flow through **unspilled**: a formula producing an array
113    /// returns the full [`Value::Array`] — spilling it across cells (or
114    /// collapsing it for a single-cell view) is the workbook/surface layer's
115    /// job, not the evaluator's (P1.4, issue #526).
116    ///
117    /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
118    /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
119    pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
120        self.evaluate_inner(formula, variables, None)
121    }
122
123    /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
124    /// pinned to `now_serial`, a local-time spreadsheet serial datetime
125    /// (integer part = day serial in this engine's date system, fractional
126    /// part = time of day).
127    ///
128    /// Same formula + same variables + same `now_serial` ⇒ identical result.
129    /// This is the core-level hook the workbook layer's `RecalcContext`
130    /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
131    /// the caller converts its UTC instant + timezone to a local serial and
132    /// passes it here. Conformance fixture rows for volatile formulas are
133    /// verified by pinning `now_serial` to the fixture's recorded
134    /// `meta.evaluatedAt`.
135    ///
136    /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
137    pub fn evaluate_at(
138        &self,
139        formula: &str,
140        variables: &HashMap<String, Value>,
141        now_serial: f64,
142    ) -> Value {
143        if !now_serial.is_finite() {
144            return Value::Error(ErrorKind::Num);
145        }
146        self.evaluate_inner(formula, variables, Some(now_serial))
147    }
148
149    /// Evaluate a formula string, resolving references through `resolver`.
150    ///
151    /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
152    /// (which reads references from a variable map and treats anything unbound
153    /// as [`Value::Empty`]), every cell, range, and name reference that is not
154    /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
155    /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
156    /// undefined name, ranges materialized to [`Value::Array`]. See
157    /// [`Resolver`].
158    ///
159    /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
160    /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
161    /// [`Engine::evaluate`].
162    ///
163    /// ```
164    /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
165    ///
166    /// struct OneSheet;
167    /// impl Resolver for OneSheet {
168    ///     fn resolve(&mut self, r: &Ref) -> Value {
169    ///         match r {
170    ///             Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
171    ///             Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
172    ///             _ => Value::Empty,
173    ///         }
174    ///     }
175    /// }
176    ///
177    /// let engine = Engine::sheets();
178    /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
179    /// assert_eq!(
180    ///     engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
181    ///     Value::Error(ErrorKind::Ref),
182    /// );
183    /// ```
184    pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
185        self.evaluate_with_resolver_at(formula, resolver, None)
186    }
187
188    /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
189    /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
190    /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
191    /// `now_serial` is not finite.
192    pub fn evaluate_with_resolver_at(
193        &self,
194        formula: &str,
195        resolver: &mut impl Resolver,
196        now_serial: Option<f64>,
197    ) -> Value {
198        if let Some(n) = now_serial {
199            if !n.is_finite() {
200                return Value::Error(ErrorKind::Num);
201            }
202        }
203        if self.flavor == EngineFlavor::Excel {
204            return Value::Error(ErrorKind::Unsupported);
205        }
206        match parse_formula(formula) {
207            Err(_) => Value::Error(ErrorKind::Value),
208            Ok(expr) => {
209                let mut ctx = Context::empty();
210                ctx.now_serial = now_serial;
211                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
212                evaluate_expr(&expr, &mut eval_ctx)
213            }
214        }
215    }
216
217    /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
218    /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
219    /// this degrades to the non-deterministic SystemTime fallback in RAND.
220    pub fn evaluate_with_resolver_at_keyed(
221        &self,
222        formula: &str,
223        resolver: &mut dyn Resolver,
224        now_serial: Option<f64>,
225        now_utc_nanos: Option<i64>,
226        rng_cell: Option<(u64, u32, u32, u32)>,
227    ) -> Value {
228        self.evaluate_with_resolver_at_keyed_hooked(
229            formula,
230            resolver,
231            now_serial,
232            now_utc_nanos,
233            rng_cell,
234            None,
235        )
236    }
237
238    /// Like [`Engine::evaluate_with_resolver_at_keyed`], but additionally
239    /// wires an opt-in per-node [`EvalHook`] (issue #743) onto the
240    /// [`EvalCtx`] built for this evaluation. `hook: None` is exactly
241    /// [`Engine::evaluate_with_resolver_at_keyed`] — same code path, same
242    /// value, no observation overhead beyond the `Option` check already paid
243    /// by [`evaluate_expr`]'s per-node hook branch. This is the seam the
244    /// workbook layer's single-cell tracer (`Workbook::trace_cell`) uses to
245    /// reach a real cell's evaluation with the same resolver-backed
246    /// semantics `recalc` uses, rather than re-deriving its own `EvalCtx`.
247    pub fn evaluate_with_resolver_at_keyed_hooked<'r>(
248        &'r self,
249        formula: &str,
250        resolver: &'r mut dyn Resolver,
251        now_serial: Option<f64>,
252        now_utc_nanos: Option<i64>,
253        rng_cell: Option<(u64, u32, u32, u32)>,
254        hook: Option<&'r mut dyn EvalHook>,
255    ) -> Value {
256        if let Some(n) = now_serial {
257            if !n.is_finite() {
258                return Value::Error(ErrorKind::Num);
259            }
260        }
261        if self.flavor == EngineFlavor::Excel {
262            return Value::Error(ErrorKind::NA);
263        }
264        match parse_formula(formula) {
265            Err(_) => Value::Error(ErrorKind::Value),
266            Ok(expr) => {
267                let mut ctx = Context::empty();
268                ctx.now_serial = now_serial;
269                ctx.now_utc_nanos = now_utc_nanos;
270                ctx.rng_cell = rng_cell;
271                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
272                eval_ctx.hook = hook;
273                evaluate_expr(&expr, &mut eval_ctx)
274            }
275        }
276    }
277
278    fn evaluate_inner(
279        &self,
280        formula: &str,
281        variables: &HashMap<String, Value>,
282        now_serial: Option<f64>,
283    ) -> Value {
284        if self.flavor == EngineFlavor::Excel {
285            // Excel evaluation semantics are not implemented yet.
286            return Value::Error(ErrorKind::Unsupported);
287        }
288        match parse_formula(formula) {
289            Err(_) => Value::Error(ErrorKind::Value),
290            Ok(expr) => {
291                let mut ctx = Context::new(variables.clone());
292                ctx.now_serial = now_serial;
293                let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
294                evaluate_expr(&expr, &mut eval_ctx)
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests;