truecalc_core/eval/mod.rs
1pub mod context;
2pub mod coercion;
3pub mod functions;
4pub mod resolver;
5
6pub use context::Context;
7pub use functions::{EvalCtx, EvalHook, EvalOp, FunctionMeta, Registry};
8pub use resolver::{extract_refs, Resolver};
9/// Re-exported so hook implementors don't need to reach into `crate::parser`.
10pub use crate::parser::ast::Span;
11
12use crate::parser::ast::{BinaryOp, Expr, UnaryOp};
13use crate::types::{ErrorKind, Value};
14
15use coercion::{to_number, to_string_val};
16use functions::{FunctionKind, FN_NAME_PLACEHOLDER};
17
18/// Fill the function-name placeholder in an arity diagnostic with the actual
19/// name of the function being dispatched. Arity errors are produced by
20/// `check_arity`, which does not know the caller's name, so the message carries
21/// [`FN_NAME_PLACEHOLDER`] until it reaches the dispatch site here. The name is
22/// upper-cased for Google-Sheets parity (`=date()` → "... to DATE ..."). Any
23/// value without the placeholder passes through untouched.
24fn finalize_call_result(v: Value, name: &str) -> Value {
25 match v {
26 Value::ErrorMsg(kind, msg) if msg.contains(FN_NAME_PLACEHOLDER) => {
27 Value::ErrorMsg(kind, msg.replace(FN_NAME_PLACEHOLDER, &name.to_uppercase()))
28 }
29 other => other,
30 }
31}
32
33/// Walk an expression tree and produce a [`Value`].
34///
35/// Variables are resolved from `ctx.ctx`; functions are dispatched through
36/// `ctx.registry`. Eager functions receive pre-evaluated arguments; lazy
37/// functions (e.g. `IF`) receive raw [`Expr`] nodes and control their own
38/// evaluation order.
39///
40/// This is the single per-node entry point: every node that is *reduced to a
41/// [`Value`]* flows through exactly one `evaluate_expr` call (recursion
42/// re-enters here), so the opt-in [`EvalHook`] fires once per such node in
43/// post-order (children before parents), carrying the node's own [`Span`] —
44/// see [`EvalHook`] for why the span is needed and how a consumer uses it to
45/// reconstruct a tree from the flat stream. Nodes that are structurally
46/// destructured rather than evaluated — e.g. the `LAMBDA(...)` callee of an
47/// `Apply`, which [`eval_apply`] pattern-matches without evaluating — never
48/// produce a value and so never fire *as a node in their own right*; each
49/// LAMBDA parameter is the one exception (see [`eval_apply`], and — for the
50/// six higher-order functions (MAP/REDUCE/BYROW/BYCOL/SCAN/MAKEARRAY) that
51/// bind lambda parameters through their own `apply_lambda` helper rather
52/// than `eval_apply` — `crate::eval::functions::array::apply_lambda`, which
53/// fires the same per-parameter event once per invocation). When
54/// [`EvalCtx::hook`] is `None` the observation costs a single branch and
55/// nothing else; the real tree-walk lives in [`eval_node`].
56pub fn evaluate_expr(expr: &Expr, ctx: &mut EvalCtx<'_>) -> Value {
57 let value = eval_node(expr, ctx);
58 // Per-node observation seam (issue #732; span-carrying per D10). Opt-in:
59 // `None` ⇒ no descriptor is built. The hook receives shared/by-value data
60 // only — it observes the node's operation, span, and resulting value and
61 // can never alter `value`. `Span` is `Copy` (two `usize`s), so this is a
62 // cheap copy, not an allocation.
63 if let Some(hook) = ctx.hook.as_deref_mut() {
64 hook.on_node(EvalOp::of(expr), *expr.span(), &value);
65 }
66 value
67}
68
69/// Tree-walk one node to its [`Value`]. Recursion goes back through
70/// [`evaluate_expr`] (never here directly) so the per-node hook fires for every
71/// node exactly once.
72fn eval_node(expr: &Expr, ctx: &mut EvalCtx<'_>) -> Value {
73 match expr {
74 // ── Leaf nodes ──────────────────────────────────────────────────────
75 Expr::Number(n, _) => {
76 if n.is_finite() {
77 Value::Number(*n)
78 } else {
79 Value::Error(ErrorKind::Num)
80 }
81 }
82 Expr::Text(s, _) => Value::Text(s.clone()),
83 Expr::Bool(b, _) => Value::Bool(*b),
84 Expr::Error(kind, _) => Value::Error(kind.clone()),
85 // Bare identifiers: a local binding (LAMBDA parameter, caller-supplied
86 // variable, or canonical-text variable) wins; otherwise the name is
87 // classified into a `Ref` and read through the resolver (P1.3, #525).
88 // The local-binding lookup strips `$` (never legitimate in a bare
89 // name/parameter — only in a $-anchored cell/range reference, see
90 // `dollar_cell_ref`) so a binding set under `LET($A$1, ...)` is
91 // found; `Ref::classify` still gets the original `name` so the
92 // resolved `Ref`'s col_abs/row_abs flags are preserved.
93 Expr::Variable(name, _) => match ctx.ctx.lookup(&name.replace('$', "")) {
94 Some(v) => v,
95 None => ctx.resolve_ref(&crate::parser::refs::Ref::classify(name)),
96 },
97 // Sheet-qualified references: a binding under the canonical reference
98 // text wins (back-compat with variable-supplied refs); otherwise the
99 // reference is read through the resolver. Looked up via
100 // `relative_display` (not `to_string`) so `$` anchors don't affect
101 // which override binding is found.
102 Expr::Reference(r, _) => match ctx.ctx.lookup(&r.relative_display()) {
103 Some(v) => v,
104 None => ctx.resolve_ref(r),
105 },
106
107 // ── Unary ops ───────────────────────────────────────────────────────
108 Expr::UnaryOp { op, operand, .. } => {
109 let val = evaluate_expr(operand, ctx);
110 match to_number(val) {
111 Err(e) => e,
112 Ok(n) => match op {
113 UnaryOp::Neg => Value::Number(-n),
114 UnaryOp::Percent => Value::Number(n / 100.0),
115 },
116 }
117 }
118
119 // ── Binary ops ──────────────────────────────────────────────────────
120 Expr::BinaryOp { op, left, right, .. } => {
121 let lv = evaluate_expr(left, ctx);
122 let rv = evaluate_expr(right, ctx);
123 eval_binary(op, lv, rv)
124 }
125
126 // ── Array literals ──────────────────────────────────────────────────
127 Expr::Array(elems, _) => {
128 let mut values = Vec::with_capacity(elems.len());
129 for elem in elems {
130 let v = evaluate_expr(elem, ctx);
131 values.push(v);
132 }
133 Value::Array(values)
134 }
135
136 // ── Immediately-invoked apply: LAMBDA(x, body)(arg) ────────────────
137 Expr::Apply { func, call_args, .. } => {
138 eval_apply(func, call_args, ctx)
139 }
140
141 // ── Function calls ──────────────────────────────────────────────────
142 Expr::FunctionCall { name, args, .. } => {
143 match ctx.registry.get(name) {
144 None => Value::Error(ErrorKind::Name),
145 Some(FunctionKind::Lazy(f)) => {
146 // Copy the fn pointer out to avoid holding a borrow on ctx.registry
147 // while also mutably borrowing ctx itself.
148 let f: functions::LazyFn = *f;
149 finalize_call_result(f(args, ctx), name)
150 }
151 Some(FunctionKind::Eager(f)) => {
152 let f: functions::EagerFn = *f;
153 // Evaluate all args; return first error encountered.
154 let mut evaluated = Vec::with_capacity(args.len());
155 for arg in args {
156 let v = evaluate_expr(arg, ctx);
157 if v.is_error() {
158 return v;
159 }
160 evaluated.push(v);
161 }
162 finalize_call_result(f(&evaluated), name)
163 }
164 }
165 }
166
167 }
168}
169
170/// Evaluate an immediately-invoked function application `func(call_args)`.
171///
172/// Hook coverage (D10 / review finding F2): the `LAMBDA(...)` callee itself
173/// is pattern-matched below, never passed through [`evaluate_expr`], so its
174/// `FunctionCall("LAMBDA")` node never fires — there's no [`Value`] that
175/// honestly represents a lambda. Each *parameter*, however, is bound to a
176/// real argument [`Value`] at call time, so once bound it fires as an
177/// ordinary [`EvalOp::Variable`] event carrying the parameter's own span
178/// (its position inside the `LAMBDA(...)` parameter list) and its bound
179/// value — giving a trace of e.g. `LAMBDA(x, x*2)(5)` an explicit `x = 5`
180/// event even if `body` never happens to read `x`. This is the same
181/// operation tag [`Expr::Variable`] nodes normally use, carrying the
182/// parameter exactly as written in the source (matching how an ordinary
183/// variable-read event names it) even though binding itself keys off an
184/// upper-cased, `$`-stripped form; it is fired directly here (not through
185/// `evaluate_expr`) because a parameter's "value" is the call-site argument,
186/// not the result of evaluating the parameter token itself (which is never
187/// evaluated — it's destructured).
188///
189/// This covers only the standalone `LAMBDA(...)(...)` call form (`Apply`).
190/// The six higher-order functions (MAP, REDUCE, BYROW, BYCOL, SCAN,
191/// MAKEARRAY) bind lambda parameters through a separate helper,
192/// `crate::eval::functions::array::apply_lambda`, which fires the same
193/// per-parameter [`EvalOp::Variable`] event — once per parameter, once per
194/// invocation (e.g. an N-element `MAP` fires N sets of parameter events, one
195/// per element, all sharing the parameter's span but each carrying that
196/// element's bound value).
197fn eval_apply(func: &Expr, call_args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
198 // Each entry is (as-written name for the hook event, upper-cased/`$`-
199 // stripped bind key for `ctx.ctx`, the parameter token's own span).
200 let (lambda_params, body) = match func {
201 Expr::FunctionCall { name, args: lambda_args, .. } if name == "LAMBDA" => {
202 if lambda_args.is_empty() {
203 return Value::Error(ErrorKind::NA);
204 }
205 let param_count = lambda_args.len() - 1;
206 let mut params: Vec<(String, String, Span)> = Vec::with_capacity(param_count);
207 for param_expr in &lambda_args[..param_count] {
208 match param_expr {
209 // Strip `$` for the same reason as the Variable read arm
210 // above: a $-shaped bare token is now syntactically legal
211 // (issue #708) but must bind/read under the same key.
212 Expr::Variable(n, span) => {
213 let bind_key = n.to_uppercase().replace('$', "");
214 params.push((n.clone(), bind_key, *span));
215 }
216 _ => return Value::Error(ErrorKind::Name),
217 }
218 }
219 let body = &lambda_args[lambda_args.len() - 1];
220 (params, body)
221 }
222 _ => return Value::Error(ErrorKind::Value),
223 };
224
225 if call_args.len() != lambda_params.len() {
226 return Value::Error(ErrorKind::NA);
227 }
228
229 let mut evaluated_args: Vec<Value> = Vec::with_capacity(call_args.len());
230 for arg in call_args {
231 let v = evaluate_expr(arg, ctx);
232 if v.is_error() {
233 return v;
234 }
235 evaluated_args.push(v);
236 }
237
238 let mut saved: Vec<(String, Option<Value>)> = Vec::with_capacity(lambda_params.len());
239 for ((display_name, bind_key, span), val) in lambda_params.iter().zip(evaluated_args) {
240 // Parameter-binding event — see the function doc comment above.
241 if let Some(hook) = ctx.hook.as_deref_mut() {
242 hook.on_node(EvalOp::Variable(display_name), *span, &val);
243 }
244 let old = ctx.ctx.set(bind_key.clone(), val);
245 saved.push((bind_key.clone(), old));
246 }
247
248 let result = evaluate_expr(body, ctx);
249
250 for (name, old_val) in saved.into_iter().rev() {
251 match old_val {
252 Some(v) => { ctx.ctx.set(name, v); }
253 None => { ctx.ctx.remove(&name); }
254 }
255 }
256
257 result
258}
259
260// ── Type ordering for cross-type comparisons (Excel semantics) ───────────────
261// Number < Text < Bool (Empty counts as Number)
262fn type_rank(v: &Value) -> u8 {
263 match v {
264 Value::Number(_) | Value::Date(_) | Value::Empty | Value::Zoned(_) => 0,
265 Value::Text(_) => 1,
266 Value::Bool(_) => 2,
267 // Error and Array cannot reach compare_values through the normal eval path
268 // (eval_binary guards against errors before calling compare_values).
269 Value::Error(_) | Value::ErrorMsg(_, _) | Value::Array(_) => 3,
270 // A sparkline outranks every scalar (google.tsv: `>1`, `>"zzzz"` and
271 // `>TRUE` are all TRUE, and `=SPARKLINE(...)=""` is FALSE).
272 Value::Sparkline(_) => 4,
273 }
274}
275
276fn eval_binary(op: &BinaryOp, lv: Value, rv: Value) -> Value {
277 // ── Array broadcasting ───────────────────────────────────────────────────
278 match (&lv, &rv) {
279 (Value::Array(lelems), Value::Array(relems)) => {
280 // Element-wise operation when both operands are arrays of the same length.
281 if lelems.len() != relems.len() {
282 return Value::Error(ErrorKind::Value);
283 }
284 let result: Vec<Value> = lelems
285 .iter()
286 .zip(relems.iter())
287 .map(|(l, r)| eval_binary(op, l.clone(), r.clone()))
288 .collect();
289 return Value::Array(result);
290 }
291 (Value::Array(elems), _) => {
292 let result: Vec<Value> = elems
293 .iter()
294 .map(|e| eval_binary(op, e.clone(), rv.clone()))
295 .collect();
296 return Value::Array(result);
297 }
298 (_, Value::Array(elems)) => {
299 let result: Vec<Value> = elems
300 .iter()
301 .map(|e| eval_binary(op, lv.clone(), e.clone()))
302 .collect();
303 return Value::Array(result);
304 }
305 _ => {}
306 }
307 match op {
308 // ── Arithmetic ──────────────────────────────────────────────────────
309 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Pow => {
310 // Date-type production (schema spec §6: date-typed iff Sheets keeps
311 // the result rendering as a date). Sheets treats date ± offset as a
312 // date:
313 // - `+`: date + number (either operand order) stays a date
314 // (workbook.tsv `=DATE(2026,6,7)+1` → date).
315 // - `−`: date − number stays a date (a date a week earlier is still
316 // a date), while date − date is a plain day count
317 // (workbook.tsv `=DATE(2026,6,7)-DATE(2026,6,1)` → 6, number) and
318 // number − date is a plain number.
319 // `×`, `÷`, `^` on a date are not date operations and stay numbers.
320 let date_typed = match op {
321 BinaryOp::Add => {
322 matches!(lv, Value::Date(_)) != matches!(rv, Value::Date(_))
323 }
324 BinaryOp::Sub => {
325 matches!(lv, Value::Date(_)) && !matches!(rv, Value::Date(_))
326 }
327 _ => false,
328 };
329 let ln = match to_number(lv) { Ok(n) => n, Err(e) => return e };
330 let rn = match to_number(rv) { Ok(n) => n, Err(e) => return e };
331 let result = match op {
332 BinaryOp::Add => ln + rn,
333 BinaryOp::Sub => ln - rn,
334 BinaryOp::Mul => ln * rn,
335 BinaryOp::Div => {
336 if rn == 0.0 {
337 return Value::Error(ErrorKind::DivByZero);
338 }
339 ln / rn
340 }
341 BinaryOp::Pow => libm::pow(ln, rn),
342 // Safety: outer match arm covers exactly Add|Sub|Mul|Div|Pow; Concat and comparison ops are handled separately.
343 _ => unreachable!(),
344 };
345 if !result.is_finite() {
346 return Value::Error(ErrorKind::Num);
347 }
348 if date_typed {
349 Value::Date(result)
350 } else {
351 Value::Number(result)
352 }
353 }
354
355 // ── Concatenation ───────────────────────────────────────────────────
356 BinaryOp::Concat => {
357 // The `&` *operator* rejects a sparkline (google.tsv:
358 // `="x"&SPARKLINE({1,2,3})` is `#VALUE!`) even though `CONCATENATE`
359 // of the same value concatenates it as empty text. That asymmetry
360 // is Sheets'; it lives here because every other text context goes
361 // through the permissive `to_string_val`.
362 if matches!(lv, Value::Sparkline(_)) || matches!(rv, Value::Sparkline(_)) {
363 return Value::Error(ErrorKind::Value);
364 }
365 let ls = match to_string_val(lv) { Ok(s) => s, Err(e) => return e };
366 let rs = match to_string_val(rv) { Ok(s) => s, Err(e) => return e };
367 Value::Text(ls + &rs)
368 }
369
370 // ── Comparisons ─────────────────────────────────────────────────────
371 BinaryOp::Eq | BinaryOp::Ne
372 | BinaryOp::Lt | BinaryOp::Gt
373 | BinaryOp::Le | BinaryOp::Ge => {
374 // Error propagation: left side first.
375 if lv.is_error() { return lv; }
376 if rv.is_error() { return rv; }
377 // Mixed naive/aware comparison is rejected (a zoned instant cannot be
378 // ordered against a naive value).
379 if matches!(&lv, Value::Zoned(_)) ^ matches!(&rv, Value::Zoned(_)) {
380 return Value::Error(ErrorKind::Value);
381 }
382
383 let result = compare_values(op, &lv, &rv);
384 Value::Bool(result)
385 }
386 }
387}
388
389/// Compare two (non-error) values with Excel ordering semantics.
390fn compare_values(op: &BinaryOp, lv: &Value, rv: &Value) -> bool {
391 match (lv, rv) {
392 (Value::Number(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
393 (Value::Date(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
394 (Value::Date(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
395 (Value::Number(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
396 // Zoned instants compare on the absolute instant only (same moment in a
397 // different zone compares equal). Cross-type Zoned is rejected in eval_binary.
398 (Value::Zoned(a), Value::Zoned(b)) => apply_cmp(op, Some(a.utc_nanos.cmp(&b.utc_nanos))),
399 // Any two sparklines compare equal, whatever they plot (google.tsv:
400 // `=SPARKLINE({1,2,3})=SPARKLINE({9,9,9})` is TRUE, `<>` is FALSE, and
401 // `<`/`>` between two sparklines are both FALSE while `>=` is TRUE).
402 (Value::Sparkline(_), Value::Sparkline(_)) => {
403 apply_cmp(op, Some(std::cmp::Ordering::Equal))
404 }
405 (Value::Text(a), Value::Text(b)) => apply_cmp(op, Some(a.cmp(b))),
406 (Value::Bool(a), Value::Bool(b)) => apply_cmp(op, Some(a.cmp(b))),
407 (Value::Empty, Value::Empty) => apply_cmp(op, Some(std::cmp::Ordering::Equal)),
408 // Empty acts as Number(0)
409 (Value::Empty, Value::Number(b)) => apply_cmp(op, 0.0f64.partial_cmp(b)),
410 (Value::Number(a), Value::Empty) => apply_cmp(op, a.partial_cmp(&0.0f64)),
411 // Cross-type: use type rank
412 _ => {
413 let lr = type_rank(lv);
414 let rr = type_rank(rv);
415 match op {
416 BinaryOp::Eq => false,
417 BinaryOp::Ne => true,
418 BinaryOp::Lt => lr < rr,
419 BinaryOp::Gt => lr > rr,
420 BinaryOp::Le => lr <= rr,
421 BinaryOp::Ge => lr >= rr,
422 // Safety: outer match arm covers exactly Eq|Ne|Lt|Gt|Le|Ge; arithmetic and Concat ops are handled separately.
423 _ => unreachable!(),
424 }
425 }
426 }
427}
428
429fn apply_cmp(op: &BinaryOp, ord: Option<std::cmp::Ordering>) -> bool {
430 match ord {
431 // NaN: per Value::Number invariant this should not occur after the is_finite() guard;
432 // returning false matches Excel semantics if it somehow does.
433 None => false,
434 Some(o) => match op {
435 BinaryOp::Eq => o.is_eq(),
436 BinaryOp::Ne => o.is_ne(),
437 BinaryOp::Lt => o.is_lt(),
438 BinaryOp::Gt => o.is_gt(),
439 BinaryOp::Le => o.is_le(),
440 BinaryOp::Ge => o.is_ge(),
441 // Safety: apply_cmp is only called from compare_values which is only called from eval_binary's comparison arm (Eq|Ne|Lt|Gt|Le|Ge).
442 _ => unreachable!(),
443 },
444 }
445}
446
447// ── Tests ────────────────────────────────────────────────────────────────────
448#[cfg(test)]
449mod tests;