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;