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;