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 rng_cell: Option<(u64, u32, u32, u32)>,
194 ) -> Value {
195 if let Some(n) = now_serial {
196 if !n.is_finite() {
197 return Value::Error(ErrorKind::Num);
198 }
199 }
200 if self.flavor == EngineFlavor::Excel {
201 return Value::Error(ErrorKind::NA);
202 }
203 match parse_formula(formula) {
204 Err(_) => Value::Error(ErrorKind::Value),
205 Ok(expr) => {
206 let mut ctx = Context::empty();
207 ctx.now_serial = now_serial;
208 ctx.rng_cell = rng_cell;
209 let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
210 evaluate_expr(&expr, &mut eval_ctx)
211 }
212 }
213 }
214
215 fn evaluate_inner(
216 &self,
217 formula: &str,
218 variables: &HashMap<String, Value>,
219 now_serial: Option<f64>,
220 ) -> Value {
221 if self.flavor == EngineFlavor::Excel {
222 // Excel evaluation semantics are not implemented yet.
223 return Value::Error(ErrorKind::Unsupported);
224 }
225 match parse_formula(formula) {
226 Err(_) => Value::Error(ErrorKind::Value),
227 Ok(expr) => {
228 let mut ctx = Context::new(variables.clone());
229 ctx.now_serial = now_serial;
230 let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
231 evaluate_expr(&expr, &mut eval_ctx)
232 }
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests;