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 `#N/A`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Flavor {
21    Sheets,
22    Excel,
23}
24
25pub struct Engine {
26    flavor: Flavor,
27    registry: Registry,
28}
29
30impl Engine {
31    /// Engine targeting Google Sheets conformance.
32    pub fn sheets() -> Self {
33        Self { flavor: Flavor::Sheets, registry: Registry::new() }
34    }
35
36    /// Engine targeting Excel conformance.
37    ///
38    /// Excel evaluation semantics are not implemented yet (they land in a
39    /// later phase): [`Engine::evaluate`] returns
40    /// `Value::Error(ErrorKind::NA)` for every formula. [`Engine::parse`]
41    /// and [`Engine::validate`] work.
42    pub fn excel() -> Self {
43        Self { flavor: Flavor::Excel, registry: Registry::new() }
44    }
45
46    /// Deprecated alias for [`Engine::sheets`].
47    #[deprecated(note = "use Engine::sheets() — engine flavor is required; see ADR 2026-04-27")]
48    pub fn google_sheets() -> Self {
49        Self::sheets()
50    }
51
52    /// Parse a formula string into an expression tree.
53    ///
54    /// The formula may start with `=`. Returns a [`ParseError`] if the input
55    /// is not a valid formula.
56    pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
57        parse_formula(formula)
58    }
59
60    /// Validate that a formula string is syntactically correct without
61    /// returning the AST.
62    pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
63        self.parse(formula).map(|_| ())
64    }
65
66    /// Evaluate a formula string with named variables.
67    ///
68    /// Array results flow through **unspilled**: a formula producing an array
69    /// returns the full [`Value::Array`] — spilling it across cells (or
70    /// collapsing it for a single-cell view) is the workbook/surface layer's
71    /// job, not the evaluator's (P1.4, issue #526).
72    ///
73    /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
74    /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
75    pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
76        self.evaluate_inner(formula, variables, None)
77    }
78
79    /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
80    /// pinned to `now_serial`, a local-time spreadsheet serial datetime
81    /// (integer part = day serial in this engine's date system, fractional
82    /// part = time of day).
83    ///
84    /// Same formula + same variables + same `now_serial` ⇒ identical result.
85    /// This is the core-level hook the workbook layer's `RecalcContext`
86    /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
87    /// the caller converts its UTC instant + timezone to a local serial and
88    /// passes it here. Conformance fixture rows for volatile formulas are
89    /// verified by pinning `now_serial` to the fixture's recorded
90    /// `meta.evaluatedAt`.
91    ///
92    /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
93    pub fn evaluate_at(
94        &self,
95        formula: &str,
96        variables: &HashMap<String, Value>,
97        now_serial: f64,
98    ) -> Value {
99        if !now_serial.is_finite() {
100            return Value::Error(ErrorKind::Num);
101        }
102        self.evaluate_inner(formula, variables, Some(now_serial))
103    }
104
105    /// Evaluate a formula string, resolving references through `resolver`.
106    ///
107    /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
108    /// (which reads references from a variable map and treats anything unbound
109    /// as [`Value::Empty`]), every cell, range, and name reference that is not
110    /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
111    /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
112    /// undefined name, ranges materialized to [`Value::Array`]. See
113    /// [`Resolver`].
114    ///
115    /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
116    /// returns `#N/A` until Excel evaluation lands, exactly like
117    /// [`Engine::evaluate`].
118    ///
119    /// ```
120    /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
121    ///
122    /// struct OneSheet;
123    /// impl Resolver for OneSheet {
124    ///     fn resolve(&mut self, r: &Ref) -> Value {
125    ///         match r {
126    ///             Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
127    ///             Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
128    ///             _ => Value::Empty,
129    ///         }
130    ///     }
131    /// }
132    ///
133    /// let engine = Engine::sheets();
134    /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
135    /// assert_eq!(
136    ///     engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
137    ///     Value::Error(ErrorKind::Ref),
138    /// );
139    /// ```
140    pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
141        self.evaluate_with_resolver_at(formula, resolver, None)
142    }
143
144    /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
145    /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
146    /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
147    /// `now_serial` is not finite.
148    pub fn evaluate_with_resolver_at(
149        &self,
150        formula: &str,
151        resolver: &mut impl Resolver,
152        now_serial: Option<f64>,
153    ) -> Value {
154        if let Some(n) = now_serial {
155            if !n.is_finite() {
156                return Value::Error(ErrorKind::Num);
157            }
158        }
159        if self.flavor == Flavor::Excel {
160            return Value::Error(ErrorKind::NA);
161        }
162        match parse_formula(formula) {
163            Err(_) => Value::Error(ErrorKind::Value),
164            Ok(expr) => {
165                let mut ctx = Context::empty();
166                ctx.now_serial = now_serial;
167                let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
168                evaluate_expr(&expr, &mut eval_ctx)
169            }
170        }
171    }
172
173    fn evaluate_inner(
174        &self,
175        formula: &str,
176        variables: &HashMap<String, Value>,
177        now_serial: Option<f64>,
178    ) -> Value {
179        if self.flavor == Flavor::Excel {
180            // Excel evaluation semantics are not implemented yet.
181            return Value::Error(ErrorKind::NA);
182        }
183        match parse_formula(formula) {
184            Err(_) => Value::Error(ErrorKind::Value),
185            Ok(expr) => {
186                let mut ctx = Context::new(variables.clone());
187                ctx.now_serial = now_serial;
188                let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
189                evaluate_expr(&expr, &mut eval_ctx)
190            }
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests;