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, Resolver};
5use crate::parser::{parse_formula, Expr};
6use crate::types::{ErrorKind, ParseError, Value};
7
8/// Which spreadsheet product's semantics the engine targets.
9///
10/// The engine flavor also locks the **date serial system** (P1.4, issue #526):
11///
12/// - `Sheets`: day 0 = 1899-12-30; no leap-year bug (1900-02-28 = serial 60,
13///   1900-03-01 = serial 61, no serial for the nonexistent 1900-02-29).
14/// - `Excel`: 1900 date system — serial 1 = 1900-01-01, **including** the
15///   historical Lotus 1-2-3 leap-year bug (serial 60 = the fictitious
16///   1900-02-29). Conversion helpers live in
17///   `eval::functions::date::serial`; Excel evaluation itself is still
18///   stubbed (`evaluate` returns `#UNSUPPORTED!`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
22pub enum EngineFlavor {
23    Sheets,
24    Excel,
25}
26
27pub struct Engine {
28    flavor: EngineFlavor,
29    registry: Registry,
30}
31
32impl Engine {
33    /// Engine targeting Google Sheets conformance.
34    pub fn sheets() -> Self {
35        Self { flavor: EngineFlavor::Sheets, registry: Registry::new() }
36    }
37
38    /// Engine targeting Excel conformance.
39    ///
40    /// Excel evaluation semantics are not implemented yet (they land in a
41    /// later phase): [`Engine::evaluate`] returns
42    /// `Value::Error(ErrorKind::Unsupported)` for every formula. [`Engine::parse`]
43    /// and [`Engine::validate`] work.
44    pub fn excel() -> Self {
45        Self { flavor: EngineFlavor::Excel, registry: Registry::new() }
46    }
47
48    /// Deprecated alias for [`Engine::sheets`].
49    #[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")]
50    pub fn google_sheets() -> Self {
51        Self::sheets()
52    }
53
54    /// The engine flavor this instance targets.
55    ///
56    /// Flavor is fixed at construction (`Engine::sheets()` / `Engine::excel()`);
57    /// there is no way to change it on an existing engine (engine-flavor ADR
58    /// 2026-04-27). The workbook layer uses this to assert a workbook's locked
59    /// [`EngineFlavor`] matches the engine driving its recalc.
60    pub fn flavor(&self) -> EngineFlavor {
61        self.flavor
62    }
63
64    /// Parse a formula string into an expression tree.
65    ///
66    /// The formula may start with `=`. Returns a [`ParseError`] if the input
67    /// is not a valid formula.
68    pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
69        parse_formula(formula)
70    }
71
72    /// Validate that a formula string is syntactically correct without
73    /// returning the AST.
74    pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
75        self.parse(formula).map(|_| ())
76    }
77
78    /// Evaluate a formula string with named variables.
79    ///
80    /// Array results flow through **unspilled**: a formula producing an array
81    /// returns the full [`Value::Array`] — spilling it across cells (or
82    /// collapsing it for a single-cell view) is the workbook/surface layer's
83    /// job, not the evaluator's (P1.4, issue #526).
84    ///
85    /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
86    /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
87    pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
88        self.evaluate_inner(formula, variables, None)
89    }
90
91    /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
92    /// pinned to `now_serial`, a local-time spreadsheet serial datetime
93    /// (integer part = day serial in this engine's date system, fractional
94    /// part = time of day).
95    ///
96    /// Same formula + same variables + same `now_serial` ⇒ identical result.
97    /// This is the core-level hook the workbook layer's `RecalcContext`
98    /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
99    /// the caller converts its UTC instant + timezone to a local serial and
100    /// passes it here. Conformance fixture rows for volatile formulas are
101    /// verified by pinning `now_serial` to the fixture's recorded
102    /// `meta.evaluatedAt`.
103    ///
104    /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
105    pub fn evaluate_at(
106        &self,
107        formula: &str,
108        variables: &HashMap<String, Value>,
109        now_serial: f64,
110    ) -> Value {
111        if !now_serial.is_finite() {
112            return Value::Error(ErrorKind::Num);
113        }
114        self.evaluate_inner(formula, variables, Some(now_serial))
115    }
116
117    /// Evaluate a formula string, resolving references through `resolver`.
118    ///
119    /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
120    /// (which reads references from a variable map and treats anything unbound
121    /// as [`Value::Empty`]), every cell, range, and name reference that is not
122    /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
123    /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
124    /// undefined name, ranges materialized to [`Value::Array`]. See
125    /// [`Resolver`].
126    ///
127    /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
128    /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
129    /// [`Engine::evaluate`].
130    ///
131    /// ```
132    /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
133    ///
134    /// struct OneSheet;
135    /// impl Resolver for OneSheet {
136    ///     fn resolve(&mut self, r: &Ref) -> Value {
137    ///         match r {
138    ///             Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
139    ///             Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
140    ///             _ => Value::Empty,
141    ///         }
142    ///     }
143    /// }
144    ///
145    /// let engine = Engine::sheets();
146    /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
147    /// assert_eq!(
148    ///     engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
149    ///     Value::Error(ErrorKind::Ref),
150    /// );
151    /// ```
152    pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
153        self.evaluate_with_resolver_at(formula, resolver, None)
154    }
155
156    /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
157    /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
158    /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
159    /// `now_serial` is not finite.
160    pub fn evaluate_with_resolver_at(
161        &self,
162        formula: &str,
163        resolver: &mut impl Resolver,
164        now_serial: Option<f64>,
165    ) -> Value {
166        if let Some(n) = now_serial {
167            if !n.is_finite() {
168                return Value::Error(ErrorKind::Num);
169            }
170        }
171        if self.flavor == EngineFlavor::Excel {
172            return Value::Error(ErrorKind::Unsupported);
173        }
174        match parse_formula(formula) {
175            Err(_) => Value::Error(ErrorKind::Value),
176            Ok(expr) => {
177                let mut ctx = Context::empty();
178                ctx.now_serial = now_serial;
179                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
180                evaluate_expr(&expr, &mut eval_ctx)
181            }
182        }
183    }
184
185    /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
186    /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
187    /// this degrades to the non-deterministic SystemTime fallback in RAND.
188    pub fn evaluate_with_resolver_at_keyed(
189        &self,
190        formula: &str,
191        resolver: &mut dyn Resolver,
192        now_serial: Option<f64>,
193        now_utc_nanos: Option<i64>,
194        rng_cell: Option<(u64, u32, u32, u32)>,
195    ) -> Value {
196        if let Some(n) = now_serial {
197            if !n.is_finite() {
198                return Value::Error(ErrorKind::Num);
199            }
200        }
201        if self.flavor == EngineFlavor::Excel {
202            return Value::Error(ErrorKind::NA);
203        }
204        match parse_formula(formula) {
205            Err(_) => Value::Error(ErrorKind::Value),
206            Ok(expr) => {
207                let mut ctx = Context::empty();
208                ctx.now_serial = now_serial;
209                ctx.now_utc_nanos = now_utc_nanos;
210                ctx.rng_cell = rng_cell;
211                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
212                evaluate_expr(&expr, &mut eval_ctx)
213            }
214        }
215    }
216
217    fn evaluate_inner(
218        &self,
219        formula: &str,
220        variables: &HashMap<String, Value>,
221        now_serial: Option<f64>,
222    ) -> Value {
223        if self.flavor == EngineFlavor::Excel {
224            // Excel evaluation semantics are not implemented yet.
225            return Value::Error(ErrorKind::Unsupported);
226        }
227        match parse_formula(formula) {
228            Err(_) => Value::Error(ErrorKind::Value),
229            Ok(expr) => {
230                let mut ctx = Context::new(variables.clone());
231                ctx.now_serial = now_serial;
232                let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
233                evaluate_expr(&expr, &mut eval_ctx)
234            }
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests;