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 pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
72 parse_formula(formula)
73 }
74
75 /// Validate that a formula string is syntactically correct without
76 /// returning the AST.
77 pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
78 self.parse(formula).map(|_| ())
79 }
80
81 /// Shift every relative axis of every cell/range reference in `formula`
82 /// by `(d_row, d_col)` — the fill / copy-paste reference-adjustment
83 /// transform. `$`-absolute axes are left unchanged. An axis that shifts
84 /// out of the Sheets grid becomes a literal `#REF!` for that corner.
85 ///
86 /// Sheets flavor only: `Engine::excel().translate_formula(...)` returns
87 /// `Err` until Excel grid bounds are established.
88 pub fn translate_formula(&self, formula: &str, d_row: i64, d_col: i64) -> Result<String, ParseError> {
89 if self.flavor == EngineFlavor::Excel {
90 return Err(ParseError {
91 message: "translate_formula: Excel flavor not yet supported".into(),
92 position: 0,
93 });
94 }
95 translate::translate_text(formula, d_row, d_col)
96 }
97
98 /// Rewrite the sheet qualifier of every cell/range reference in `formula`
99 /// that points at `old` to point at `new` instead — the sheet-rename
100 /// reference-rewrite transform. Sheet-name matching is case-insensitive
101 /// (mirrors the workbook crate's own sheet-identity rule: sheet names are
102 /// unique case-insensitively, and a pure case-change rename is allowed).
103 /// Requoting is handled automatically. Unqualified refs, refs to other
104 /// sheets, string literals, function names, and defined names are left
105 /// untouched. No-op if `formula` has no `old`-qualified refs.
106 pub fn rename_sheet_refs(&self, formula: &str, old: &str, new: &str) -> Result<String, ParseError> {
107 rename::rename_sheet_refs_text(formula, old, new)
108 }
109
110 /// Evaluate a formula string with named variables.
111 ///
112 /// Array results flow through **unspilled**: a formula producing an array
113 /// returns the full [`Value::Array`] — spilling it across cells (or
114 /// collapsing it for a single-cell view) is the workbook/surface layer's
115 /// job, not the evaluator's (P1.4, issue #526).
116 ///
117 /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
118 /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
119 pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
120 self.evaluate_inner(formula, variables, None)
121 }
122
123 /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
124 /// pinned to `now_serial`, a local-time spreadsheet serial datetime
125 /// (integer part = day serial in this engine's date system, fractional
126 /// part = time of day).
127 ///
128 /// Same formula + same variables + same `now_serial` ⇒ identical result.
129 /// This is the core-level hook the workbook layer's `RecalcContext`
130 /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
131 /// the caller converts its UTC instant + timezone to a local serial and
132 /// passes it here. Conformance fixture rows for volatile formulas are
133 /// verified by pinning `now_serial` to the fixture's recorded
134 /// `meta.evaluatedAt`.
135 ///
136 /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
137 pub fn evaluate_at(
138 &self,
139 formula: &str,
140 variables: &HashMap<String, Value>,
141 now_serial: f64,
142 ) -> Value {
143 if !now_serial.is_finite() {
144 return Value::Error(ErrorKind::Num);
145 }
146 self.evaluate_inner(formula, variables, Some(now_serial))
147 }
148
149 /// Evaluate a formula string, resolving references through `resolver`.
150 ///
151 /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
152 /// (which reads references from a variable map and treats anything unbound
153 /// as [`Value::Empty`]), every cell, range, and name reference that is not
154 /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
155 /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
156 /// undefined name, ranges materialized to [`Value::Array`]. See
157 /// [`Resolver`].
158 ///
159 /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
160 /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
161 /// [`Engine::evaluate`].
162 ///
163 /// ```
164 /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
165 ///
166 /// struct OneSheet;
167 /// impl Resolver for OneSheet {
168 /// fn resolve(&mut self, r: &Ref) -> Value {
169 /// match r {
170 /// Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
171 /// Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
172 /// _ => Value::Empty,
173 /// }
174 /// }
175 /// }
176 ///
177 /// let engine = Engine::sheets();
178 /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
179 /// assert_eq!(
180 /// engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
181 /// Value::Error(ErrorKind::Ref),
182 /// );
183 /// ```
184 pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
185 self.evaluate_with_resolver_at(formula, resolver, None)
186 }
187
188 /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
189 /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
190 /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
191 /// `now_serial` is not finite.
192 pub fn evaluate_with_resolver_at(
193 &self,
194 formula: &str,
195 resolver: &mut impl Resolver,
196 now_serial: Option<f64>,
197 ) -> Value {
198 if let Some(n) = now_serial {
199 if !n.is_finite() {
200 return Value::Error(ErrorKind::Num);
201 }
202 }
203 if self.flavor == EngineFlavor::Excel {
204 return Value::Error(ErrorKind::Unsupported);
205 }
206 match parse_formula(formula) {
207 Err(_) => Value::Error(ErrorKind::Value),
208 Ok(expr) => {
209 let mut ctx = Context::empty();
210 ctx.now_serial = now_serial;
211 let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
212 evaluate_expr(&expr, &mut eval_ctx)
213 }
214 }
215 }
216
217 /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
218 /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
219 /// this degrades to the non-deterministic SystemTime fallback in RAND.
220 pub fn evaluate_with_resolver_at_keyed(
221 &self,
222 formula: &str,
223 resolver: &mut dyn Resolver,
224 now_serial: Option<f64>,
225 now_utc_nanos: Option<i64>,
226 rng_cell: Option<(u64, u32, u32, u32)>,
227 ) -> Value {
228 self.evaluate_with_resolver_at_keyed_hooked(
229 formula,
230 resolver,
231 now_serial,
232 now_utc_nanos,
233 rng_cell,
234 None,
235 )
236 }
237
238 /// Like [`Engine::evaluate_with_resolver_at_keyed`], but additionally
239 /// wires an opt-in per-node [`EvalHook`] (issue #743) onto the
240 /// [`EvalCtx`] built for this evaluation. `hook: None` is exactly
241 /// [`Engine::evaluate_with_resolver_at_keyed`] — same code path, same
242 /// value, no observation overhead beyond the `Option` check already paid
243 /// by [`evaluate_expr`]'s per-node hook branch. This is the seam the
244 /// workbook layer's single-cell tracer (`Workbook::trace_cell`) uses to
245 /// reach a real cell's evaluation with the same resolver-backed
246 /// semantics `recalc` uses, rather than re-deriving its own `EvalCtx`.
247 pub fn evaluate_with_resolver_at_keyed_hooked<'r>(
248 &'r self,
249 formula: &str,
250 resolver: &'r mut dyn Resolver,
251 now_serial: Option<f64>,
252 now_utc_nanos: Option<i64>,
253 rng_cell: Option<(u64, u32, u32, u32)>,
254 hook: Option<&'r mut dyn EvalHook>,
255 ) -> Value {
256 if let Some(n) = now_serial {
257 if !n.is_finite() {
258 return Value::Error(ErrorKind::Num);
259 }
260 }
261 if self.flavor == EngineFlavor::Excel {
262 return Value::Error(ErrorKind::NA);
263 }
264 match parse_formula(formula) {
265 Err(_) => Value::Error(ErrorKind::Value),
266 Ok(expr) => {
267 let mut ctx = Context::empty();
268 ctx.now_serial = now_serial;
269 ctx.now_utc_nanos = now_utc_nanos;
270 ctx.rng_cell = rng_cell;
271 let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
272 eval_ctx.hook = hook;
273 evaluate_expr(&expr, &mut eval_ctx)
274 }
275 }
276 }
277
278 fn evaluate_inner(
279 &self,
280 formula: &str,
281 variables: &HashMap<String, Value>,
282 now_serial: Option<f64>,
283 ) -> Value {
284 if self.flavor == EngineFlavor::Excel {
285 // Excel evaluation semantics are not implemented yet.
286 return Value::Error(ErrorKind::Unsupported);
287 }
288 match parse_formula(formula) {
289 Err(_) => Value::Error(ErrorKind::Value),
290 Ok(expr) => {
291 let mut ctx = Context::new(variables.clone());
292 ctx.now_serial = now_serial;
293 let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
294 evaluate_expr(&expr, &mut eval_ctx)
295 }
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests;