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