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