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 grid_edit;
9
10pub use grid_edit::{Axis, AxisMove, GridEdit};
11mod rename;
12mod translate;
13
14/// Which spreadsheet product's semantics the engine targets.
15///
16/// The engine flavor also locks the **date serial system** (P1.4, issue #526):
17///
18/// - `Sheets`: day 0 = 1899-12-30; no leap-year bug (1900-02-28 = serial 60,
19/// 1900-03-01 = serial 61, no serial for the nonexistent 1900-02-29).
20/// - `Excel`: 1900 date system — serial 1 = 1900-01-01, **including** the
21/// historical Lotus 1-2-3 leap-year bug (serial 60 = the fictitious
22/// 1900-02-29). Conversion helpers live in
23/// `eval::functions::date::serial`; Excel evaluation itself is still
24/// stubbed (`evaluate` returns `#UNSUPPORTED!`).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
28pub enum EngineFlavor {
29 Sheets,
30 Excel,
31}
32
33pub struct Engine {
34 flavor: EngineFlavor,
35 registry: Registry,
36}
37
38impl Engine {
39 /// Engine targeting Google Sheets conformance.
40 pub fn sheets() -> Self {
41 Self { flavor: EngineFlavor::Sheets, registry: Registry::new() }
42 }
43
44 /// Engine targeting Excel conformance.
45 ///
46 /// Excel evaluation semantics are not implemented yet (they land in a
47 /// later phase): [`Engine::evaluate`] returns
48 /// `Value::Error(ErrorKind::Unsupported)` for every formula. [`Engine::parse`]
49 /// and [`Engine::validate`] work.
50 pub fn excel() -> Self {
51 Self { flavor: EngineFlavor::Excel, registry: Registry::new() }
52 }
53
54 /// Deprecated alias for [`Engine::sheets`].
55 #[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")]
56 pub fn google_sheets() -> Self {
57 Self::sheets()
58 }
59
60 /// The engine flavor this instance targets.
61 ///
62 /// Flavor is fixed at construction (`Engine::sheets()` / `Engine::excel()`);
63 /// there is no way to change it on an existing engine (engine-flavor ADR
64 /// 2026-04-27). The workbook layer uses this to assert a workbook's locked
65 /// [`EngineFlavor`] matches the engine driving its recalc.
66 pub fn flavor(&self) -> EngineFlavor {
67 self.flavor
68 }
69
70 /// Parse a formula string into an expression tree.
71 ///
72 /// The formula may start with `=`. Returns a [`ParseError`] if the input
73 /// is not a valid formula.
74 ///
75 /// Parsing is flavor-independent and never reads the function registry, so
76 /// a caller that only needs the AST can call [`crate::parse_formula`]
77 /// directly instead of constructing an engine (issue #900).
78 pub fn parse(&self, formula: &str) -> Result<Expr, ParseError> {
79 parse_formula(formula)
80 }
81
82 /// Validate that a formula string is syntactically correct without
83 /// returning the AST.
84 ///
85 /// A syntax check is exactly a parse: see [`Engine::parse`] for why a
86 /// caller that only validates need not build an engine.
87 pub fn validate(&self, formula: &str) -> Result<(), ParseError> {
88 self.parse(formula).map(|_| ())
89 }
90
91 /// Shift every relative axis of every cell/range reference in `formula`
92 /// by `(d_row, d_col)` — the fill / copy-paste reference-adjustment
93 /// transform. `$`-absolute axes are left unchanged. An axis that shifts
94 /// out of the Sheets grid becomes a literal `#REF!` for that corner.
95 ///
96 /// Sheets flavor only: `Engine::excel().translate_formula(...)` returns
97 /// `Err` until Excel grid bounds are established.
98 pub fn translate_formula(&self, formula: &str, d_row: i64, d_col: i64) -> Result<String, ParseError> {
99 if self.flavor == EngineFlavor::Excel {
100 return Err(ParseError {
101 message: "translate_formula: Excel flavor not yet supported".into(),
102 position: 0,
103 });
104 }
105 translate::translate_text(formula, d_row, d_col)
106 }
107
108 /// Rewrite the sheet qualifier of every cell/range reference in `formula`
109 /// that points at `old` to point at `new` instead — the sheet-rename
110 /// reference-rewrite transform. Sheet-name matching is case-insensitive
111 /// (mirrors the workbook crate's own sheet-identity rule: sheet names are
112 /// unique case-insensitively, and a pure case-change rename is allowed).
113 /// Requoting is handled automatically. Unqualified refs, refs to other
114 /// sheets, string literals, function names, and defined names are left
115 /// untouched. No-op if `formula` has no `old`-qualified refs.
116 pub fn rename_sheet_refs(&self, formula: &str, old: &str, new: &str) -> Result<String, ParseError> {
117 rename::rename_sheet_refs_text(formula, old, new)
118 }
119
120 /// Rewrite the cell/range references in `formula` for a row/column insert
121 /// or delete — the structural-edit reference-rewrite transform.
122 ///
123 /// Unlike [`Engine::translate_formula`], which applies a uniform offset,
124 /// a [`GridEdit`] moves references *conditionally*: those before the edit
125 /// index stay put, those at or after it shift by `count`, a range
126 /// straddling the edit grows (insert) or shrinks (delete), and a
127 /// reference whose every row/column was deleted becomes `#REF!` — the
128 /// whole reference, sheet qualifier included, since `Sheet1!#REF!` does
129 /// not re-parse.
130 ///
131 /// `$` anchors do **not** exempt an axis here: `$` governs how a
132 /// reference is *copied*, not which cell it points at, so `$A$5` tracks
133 /// its cell through an insert exactly as `A5` does. The anchors are
134 /// preserved in the output.
135 ///
136 /// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
137 /// resolves to; `edited_sheet` is the sheet the rows/columns were
138 /// inserted into or deleted from. Only references resolving to
139 /// `edited_sheet` are touched, so a formula's references to other sheets
140 /// never move. Matching is case-insensitive, as in
141 /// [`Engine::rename_sheet_refs`]. String literals, function names,
142 /// defined names and `LET`/`LAMBDA` bindings are left untouched.
143 ///
144 /// Returns `Err` if `formula` does not parse, if the edit's `at` is `0`
145 /// (rows and columns are 1-based), or for `EngineFlavor::Excel`, whose
146 /// grid bounds are not established yet — the same guard
147 /// [`Engine::translate_formula`] carries.
148 ///
149 /// ```
150 /// use truecalc_core::{Engine, GridEdit};
151 ///
152 /// let engine = Engine::sheets();
153 /// let edit = GridEdit::DeleteRows { at: 2, count: 2 };
154 ///
155 /// // A formula on Sheet1, and rows deleted from Sheet1: the range shrinks
156 /// // and the cell inside the deleted band is gone.
157 /// let out = engine.shift_refs_for_grid_edit("=SUM(A1:A5)+A3", "Sheet1", "Sheet1", edit).unwrap();
158 /// assert_eq!(out, "=SUM(A1:A3)+#REF!");
159 ///
160 /// // The same formula living on Sheet2 instead: its bare refs mean
161 /// // Sheet2, which the Sheet1 edit does not touch, so nothing moves. Note
162 /// // the argument order — `formula_sheet` first, then `edited_sheet`.
163 /// let out = engine.shift_refs_for_grid_edit("=SUM(A1:A5)+A3", "Sheet2", "Sheet1", edit).unwrap();
164 /// assert_eq!(out, "=SUM(A1:A5)+A3");
165 ///
166 /// // ...but its explicitly Sheet1-qualified refs still move.
167 /// let out = engine.shift_refs_for_grid_edit("=SUM(Sheet1!A1:A5)", "Sheet2", "Sheet1", edit).unwrap();
168 /// assert_eq!(out, "=SUM(Sheet1!A1:A3)");
169 /// ```
170 pub fn shift_refs_for_grid_edit(
171 &self,
172 formula: &str,
173 formula_sheet: &str,
174 edited_sheet: &str,
175 edit: GridEdit,
176 ) -> Result<String, ParseError> {
177 if self.flavor == EngineFlavor::Excel {
178 return Err(ParseError {
179 message: "shift_refs_for_grid_edit: Excel flavor not yet supported".into(),
180 position: 0,
181 });
182 }
183 grid_edit::shift_refs_text(formula, formula_sheet, edited_sheet, edit)
184 }
185
186 /// Rewrite the cell/range references in `formula` for a row/column
187 /// **move** — relocating the contiguous band `mv.start..=mv.end` on
188 /// `edited_sheet` so it starts at `mv.at`, without inserting or deleting
189 /// anything.
190 ///
191 /// Unlike [`Engine::shift_refs_for_grid_edit`], which can shift a
192 /// reference away or drop it as `#REF!`, a move is a *total* remap:
193 /// nothing is created or destroyed, so every coordinate on the moved
194 /// axis maps to exactly one output coordinate. A coordinate inside the
195 /// moved band translates onto the band's new start; a coordinate
196 /// between the band's old and new position slides by the band's width
197 /// in the opposite direction, closing the gap the band left; everything
198 /// else is unchanged.
199 ///
200 /// `mv.at` landing inside `mv.start..=mv.end` has no well-defined
201 /// destination — there is no way to "move a band into the middle of
202 /// itself" — so it is a silent no-op, the same way
203 /// [`GridEdit`]'s own `count: 0` is. `mv.at == mv.end + 1` is *not* part
204 /// of that no-op range: it is the smallest genuine forward move,
205 /// swapping the band with the immediately following equal-width block.
206 ///
207 /// Mapping a range's two endpoints independently can flip their
208 /// relative order even when they were written ascending — moving rows
209 /// 5:7 to before row 2 sends row 3's content to row 6 and row 6's
210 /// content to row 3, so `A4:A6` maps to `A3:A7`, not `A7:A3`. The same
211 /// correction runs in the mirror direction too: a range deliberately
212 /// written backwards (`A6:A4`) that this same move would otherwise
213 /// "uncross" into ascending order is swapped back so it stays
214 /// backwards, the way [`Engine::shift_refs_for_grid_edit`] preserves a
215 /// backwards-written range through insert/delete. A backwards range
216 /// unaffected by the move (`A7:A5`, nowhere near the band) simply keeps
217 /// its written orientation, since nothing about it changed.
218 ///
219 /// `$` anchors do **not** exempt an axis here either: `$` governs how a
220 /// reference is *copied*, not which cell it points at, so `$A$6` moves
221 /// exactly as `A6` does. The anchors are preserved in the output.
222 ///
223 /// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
224 /// resolves to; `edited_sheet` is the sheet the band moved on. Only
225 /// references resolving to `edited_sheet` are touched. Matching is
226 /// case-insensitive, as in [`Engine::rename_sheet_refs`].
227 ///
228 /// Returns `Err` if `formula` does not parse, if `mv.start` or `mv.at`
229 /// is `0` (rows and columns are 1-based), if `mv.start > mv.end`, if
230 /// `mv.at` would push the band off the grid, or for `EngineFlavor::Excel`,
231 /// whose grid bounds are not established yet — the same guard
232 /// [`Engine::shift_refs_for_grid_edit`] carries. A move never grows the
233 /// sheet, so an off-grid *result* cannot happen from a well-formed
234 /// [`AxisMove`]; only an off-grid *request* is rejected, once, here.
235 ///
236 /// ```
237 /// use truecalc_core::{Axis, AxisMove, Engine};
238 ///
239 /// let engine = Engine::sheets();
240 ///
241 /// // Moving rows 5:7 to row 2: independently-mapped endpoints invert
242 /// // (row 4's content ends up at row 7, row 6's at row 3), so the
243 /// // range is normalized back to ascending order.
244 /// let mv = AxisMove { axis: Axis::Row, start: 5, end: 7, at: 2 };
245 /// let out = engine.shift_refs_for_move("=SUM(A4:A6)", "Sheet1", "Sheet1", mv).unwrap();
246 /// assert_eq!(out, "=SUM(A3:A7)");
247 ///
248 /// // The same move applied to a range deliberately written backwards
249 /// // (A6:A4) would otherwise "uncross" it into ascending order — it is
250 /// // swapped back so it stays backwards, mirroring the ascending case.
251 /// let out = engine.shift_refs_for_move("=SUM(A6:A4)", "Sheet1", "Sheet1", mv).unwrap();
252 /// assert_eq!(out, "=SUM(A7:A3)");
253 ///
254 /// // `$` governs how a reference copies, not what it points at, so it
255 /// // does not exempt an axis from a move either.
256 /// let out = engine.shift_refs_for_move("=$A$6", "Sheet1", "Sheet1", mv).unwrap();
257 /// assert_eq!(out, "=$A$3");
258 ///
259 /// // `at` inside the band itself has no well-defined destination: a no-op.
260 /// let noop = AxisMove { axis: Axis::Row, start: 5, end: 7, at: 6 };
261 /// let out = engine.shift_refs_for_move("=SUM(A1:A10)", "Sheet1", "Sheet1", noop).unwrap();
262 /// assert_eq!(out, "=SUM(A1:A10)");
263 /// ```
264 pub fn shift_refs_for_move(
265 &self,
266 formula: &str,
267 formula_sheet: &str,
268 edited_sheet: &str,
269 mv: AxisMove,
270 ) -> Result<String, ParseError> {
271 if self.flavor == EngineFlavor::Excel {
272 return Err(ParseError {
273 message: "shift_refs_for_move: Excel flavor not yet supported".into(),
274 position: 0,
275 });
276 }
277 grid_edit::shift_refs_for_move(formula, formula_sheet, edited_sheet, mv)
278 }
279
280 /// Evaluate a formula string with named variables.
281 ///
282 /// Array results flow through **unspilled**: a formula producing an array
283 /// returns the full [`Value::Array`] — spilling it across cells (or
284 /// collapsing it for a single-cell view) is the workbook/surface layer's
285 /// job, not the evaluator's (P1.4, issue #526).
286 ///
287 /// Volatile date functions (`NOW`, `TODAY`) read the ambient local clock.
288 /// Use [`Engine::evaluate_at`] to pin them for deterministic evaluation.
289 pub fn evaluate(&self, formula: &str, variables: &HashMap<String, Value>) -> Value {
290 self.evaluate_inner(formula, variables, None)
291 }
292
293 /// Evaluate a formula with the volatile date functions (`NOW`, `TODAY`)
294 /// pinned to `now_serial`, a local-time spreadsheet serial datetime
295 /// (integer part = day serial in this engine's date system, fractional
296 /// part = time of day).
297 ///
298 /// Same formula + same variables + same `now_serial` ⇒ identical result.
299 /// This is the core-level hook the workbook layer's `RecalcContext`
300 /// (timestamp + IANA timezone, scope ADR 2026-06-07 Decision 3) builds on:
301 /// the caller converts its UTC instant + timezone to a local serial and
302 /// passes it here. Conformance fixture rows for volatile formulas are
303 /// verified by pinning `now_serial` to the fixture's recorded
304 /// `meta.evaluatedAt`.
305 ///
306 /// Returns `Value::Error(ErrorKind::Num)` if `now_serial` is not finite.
307 pub fn evaluate_at(
308 &self,
309 formula: &str,
310 variables: &HashMap<String, Value>,
311 now_serial: f64,
312 ) -> Value {
313 if !now_serial.is_finite() {
314 return Value::Error(ErrorKind::Num);
315 }
316 self.evaluate_inner(formula, variables, Some(now_serial))
317 }
318
319 /// Evaluate a formula string, resolving references through `resolver`.
320 ///
321 /// This is the workbook-facing entry point: unlike [`Engine::evaluate`]
322 /// (which reads references from a variable map and treats anything unbound
323 /// as [`Value::Empty`]), every cell, range, and name reference that is not
324 /// shadowed by a LAMBDA parameter is read through `resolver`. The resolver
325 /// owns workbook semantics -- `#REF!` for a missing sheet, `#NAME?` for an
326 /// undefined name, ranges materialized to [`Value::Array`]. See
327 /// [`Resolver`].
328 ///
329 /// The engine flavor stays explicit: `Engine::excel().evaluate_with_resolver`
330 /// returns `#UNSUPPORTED!` until Excel evaluation lands, exactly like
331 /// [`Engine::evaluate`].
332 ///
333 /// ```
334 /// use truecalc_core::{Engine, ErrorKind, Ref, Resolver, Value};
335 ///
336 /// struct OneSheet;
337 /// impl Resolver for OneSheet {
338 /// fn resolve(&mut self, r: &Ref) -> Value {
339 /// match r {
340 /// Ref::Cell { sheet: Some(s), .. } if s == "Data" => Value::Number(10.0),
341 /// Ref::Cell { sheet: Some(_), .. } => Value::Error(ErrorKind::Ref),
342 /// _ => Value::Empty,
343 /// }
344 /// }
345 /// }
346 ///
347 /// let engine = Engine::sheets();
348 /// assert_eq!(engine.evaluate_with_resolver("=Data!A1", &mut OneSheet), Value::Number(10.0));
349 /// assert_eq!(
350 /// engine.evaluate_with_resolver("=Gone!A1", &mut OneSheet),
351 /// Value::Error(ErrorKind::Ref),
352 /// );
353 /// ```
354 pub fn evaluate_with_resolver(&self, formula: &str, resolver: &mut impl Resolver) -> Value {
355 self.evaluate_with_resolver_at(formula, resolver, None)
356 }
357
358 /// Like [`Engine::evaluate_with_resolver`], but with the volatile date
359 /// functions (`NOW`, `TODAY`) pinned to `now_serial` (see
360 /// [`Engine::evaluate_at`]). Returns `Value::Error(ErrorKind::Num)` if
361 /// `now_serial` is not finite.
362 pub fn evaluate_with_resolver_at(
363 &self,
364 formula: &str,
365 resolver: &mut impl Resolver,
366 now_serial: Option<f64>,
367 ) -> Value {
368 if let Some(n) = now_serial {
369 if !n.is_finite() {
370 return Value::Error(ErrorKind::Num);
371 }
372 }
373 if self.flavor == EngineFlavor::Excel {
374 return Value::Error(ErrorKind::Unsupported);
375 }
376 match parse_formula(formula) {
377 Err(_) => Value::Error(ErrorKind::Value),
378 Ok(expr) => {
379 let mut ctx = Context::empty();
380 ctx.now_serial = now_serial;
381 let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
382 evaluate_expr(&expr, &mut eval_ctx)
383 }
384 }
385 }
386
387 /// Like [`Engine::evaluate_with_resolver_at`] but also injects a per-cell
388 /// RNG key. `rng_cell` is `(seed, sheet_index, row, col)`; when `None`
389 /// this degrades to the non-deterministic SystemTime fallback in RAND.
390 pub fn evaluate_with_resolver_at_keyed(
391 &self,
392 formula: &str,
393 resolver: &mut dyn Resolver,
394 now_serial: Option<f64>,
395 now_utc_nanos: Option<i64>,
396 rng_cell: Option<(u64, u32, u32, u32)>,
397 ) -> Value {
398 self.evaluate_with_resolver_at_keyed_hooked(
399 formula,
400 resolver,
401 now_serial,
402 now_utc_nanos,
403 rng_cell,
404 None,
405 )
406 }
407
408 /// Like [`Engine::evaluate_with_resolver_at_keyed`], but additionally
409 /// wires an opt-in per-node [`EvalHook`] (issue #743) onto the
410 /// [`EvalCtx`] built for this evaluation. `hook: None` is exactly
411 /// [`Engine::evaluate_with_resolver_at_keyed`] — same code path, same
412 /// value, no observation overhead beyond the `Option` check already paid
413 /// by [`evaluate_expr`]'s per-node hook branch. This is the seam the
414 /// workbook layer's single-cell tracer (`Workbook::trace_cell`) uses to
415 /// reach a real cell's evaluation with the same resolver-backed
416 /// semantics `recalc` uses, rather than re-deriving its own `EvalCtx`.
417 pub fn evaluate_with_resolver_at_keyed_hooked<'r>(
418 &'r self,
419 formula: &str,
420 resolver: &'r mut dyn Resolver,
421 now_serial: Option<f64>,
422 now_utc_nanos: Option<i64>,
423 rng_cell: Option<(u64, u32, u32, u32)>,
424 hook: Option<&'r mut dyn EvalHook>,
425 ) -> Value {
426 if let Some(n) = now_serial {
427 if !n.is_finite() {
428 return Value::Error(ErrorKind::Num);
429 }
430 }
431 if self.flavor == EngineFlavor::Excel {
432 return Value::Error(ErrorKind::NA);
433 }
434 match parse_formula(formula) {
435 Err(_) => Value::Error(ErrorKind::Value),
436 Ok(expr) => {
437 let mut ctx = Context::empty();
438 ctx.now_serial = now_serial;
439 ctx.now_utc_nanos = now_utc_nanos;
440 ctx.rng_cell = rng_cell;
441 let mut eval_ctx = EvalCtx::with_resolver(ctx, &self.registry, resolver);
442 eval_ctx.hook = hook;
443 evaluate_expr(&expr, &mut eval_ctx)
444 }
445 }
446 }
447
448 fn evaluate_inner(
449 &self,
450 formula: &str,
451 variables: &HashMap<String, Value>,
452 now_serial: Option<f64>,
453 ) -> Value {
454 if self.flavor == EngineFlavor::Excel {
455 // Excel evaluation semantics are not implemented yet.
456 return Value::Error(ErrorKind::Unsupported);
457 }
458 match parse_formula(formula) {
459 Err(_) => Value::Error(ErrorKind::Value),
460 Ok(expr) => {
461 let mut ctx = Context::new(variables.clone());
462 ctx.now_serial = now_serial;
463 let mut eval_ctx = EvalCtx::new(ctx, &self.registry);
464 evaluate_expr(&expr, &mut eval_ctx)
465 }
466 }
467 }
468}
469
470#[cfg(test)]
471mod tests;