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 // Bare identifiers: a local binding (LAMBDA parameter, caller-supplied
85 // variable, or canonical-text variable) wins; otherwise the name is
86 // classified into a `Ref` and read through the resolver (P1.3, #525).
87 // The local-binding lookup strips `$` (never legitimate in a bare
88 // name/parameter — only in a $-anchored cell/range reference, see
89 // `dollar_cell_ref`) so a binding set under `LET($A$1, ...)` is
90 // found; `Ref::classify` still gets the original `name` so the
91 // resolved `Ref`'s col_abs/row_abs flags are preserved.
92 Expr::Variable(name, _) => match ctx.ctx.lookup(&name.replace('$', "")) {
93 Some(v) => v,
94 None => ctx.resolve_ref(&crate::parser::refs::Ref::classify(name)),
95 },
96 // Sheet-qualified references: a binding under the canonical reference
97 // text wins (back-compat with variable-supplied refs); otherwise the
98 // reference is read through the resolver. Looked up via
99 // `relative_display` (not `to_string`) so `$` anchors don't affect
100 // which override binding is found.
101 Expr::Reference(r, _) => match ctx.ctx.lookup(&r.relative_display()) {
102 Some(v) => v,
103 None => ctx.resolve_ref(r),
104 },
105
106 // ── Unary ops ───────────────────────────────────────────────────────
107 Expr::UnaryOp { op, operand, .. } => {
108 let val = evaluate_expr(operand, ctx);
109 match to_number(val) {
110 Err(e) => e,
111 Ok(n) => match op {
112 UnaryOp::Neg => Value::Number(-n),
113 UnaryOp::Percent => Value::Number(n / 100.0),
114 },
115 }
116 }
117
118 // ── Binary ops ──────────────────────────────────────────────────────
119 Expr::BinaryOp { op, left, right, .. } => {
120 let lv = evaluate_expr(left, ctx);
121 let rv = evaluate_expr(right, ctx);
122 eval_binary(op, lv, rv)
123 }
124
125 // ── Array literals ──────────────────────────────────────────────────
126 Expr::Array(elems, _) => {
127 let mut values = Vec::with_capacity(elems.len());
128 for elem in elems {
129 let v = evaluate_expr(elem, ctx);
130 values.push(v);
131 }
132 Value::Array(values)
133 }
134
135 // ── Immediately-invoked apply: LAMBDA(x, body)(arg) ────────────────
136 Expr::Apply { func, call_args, .. } => {
137 eval_apply(func, call_args, ctx)
138 }
139
140 // ── Function calls ──────────────────────────────────────────────────
141 Expr::FunctionCall { name, args, .. } => {
142 match ctx.registry.get(name) {
143 None => Value::Error(ErrorKind::Name),
144 Some(FunctionKind::Lazy(f)) => {
145 // Copy the fn pointer out to avoid holding a borrow on ctx.registry
146 // while also mutably borrowing ctx itself.
147 let f: functions::LazyFn = *f;
148 finalize_call_result(f(args, ctx), name)
149 }
150 Some(FunctionKind::Eager(f)) => {
151 let f: functions::EagerFn = *f;
152 // Evaluate all args; return first error encountered.
153 let mut evaluated = Vec::with_capacity(args.len());
154 for arg in args {
155 let v = evaluate_expr(arg, ctx);
156 if v.is_error() {
157 return v;
158 }
159 evaluated.push(v);
160 }
161 finalize_call_result(f(&evaluated), name)
162 }
163 }
164 }
165
166 }
167}
168
169/// Evaluate an immediately-invoked function application `func(call_args)`.
170///
171/// Hook coverage (D10 / review finding F2): the `LAMBDA(...)` callee itself
172/// is pattern-matched below, never passed through [`evaluate_expr`], so its
173/// `FunctionCall("LAMBDA")` node never fires — there's no [`Value`] that
174/// honestly represents a lambda. Each *parameter*, however, is bound to a
175/// real argument [`Value`] at call time, so once bound it fires as an
176/// ordinary [`EvalOp::Variable`] event carrying the parameter's own span
177/// (its position inside the `LAMBDA(...)` parameter list) and its bound
178/// value — giving a trace of e.g. `LAMBDA(x, x*2)(5)` an explicit `x = 5`
179/// event even if `body` never happens to read `x`. This is the same
180/// operation tag [`Expr::Variable`] nodes normally use, carrying the
181/// parameter exactly as written in the source (matching how an ordinary
182/// variable-read event names it) even though binding itself keys off an
183/// upper-cased, `$`-stripped form; it is fired directly here (not through
184/// `evaluate_expr`) because a parameter's "value" is the call-site argument,
185/// not the result of evaluating the parameter token itself (which is never
186/// evaluated — it's destructured).
187///
188/// This covers only the standalone `LAMBDA(...)(...)` call form (`Apply`).
189/// The six higher-order functions (MAP, REDUCE, BYROW, BYCOL, SCAN,
190/// MAKEARRAY) bind lambda parameters through a separate helper,
191/// `crate::eval::functions::array::apply_lambda`, which fires the same
192/// per-parameter [`EvalOp::Variable`] event — once per parameter, once per
193/// invocation (e.g. an N-element `MAP` fires N sets of parameter events, one
194/// per element, all sharing the parameter's span but each carrying that
195/// element's bound value).
196fn eval_apply(func: &Expr, call_args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
197 // Each entry is (as-written name for the hook event, upper-cased/`$`-
198 // stripped bind key for `ctx.ctx`, the parameter token's own span).
199 let (lambda_params, body) = match func {
200 Expr::FunctionCall { name, args: lambda_args, .. } if name == "LAMBDA" => {
201 if lambda_args.is_empty() {
202 return Value::Error(ErrorKind::NA);
203 }
204 let param_count = lambda_args.len() - 1;
205 let mut params: Vec<(String, String, Span)> = Vec::with_capacity(param_count);
206 for param_expr in &lambda_args[..param_count] {
207 match param_expr {
208 // Strip `$` for the same reason as the Variable read arm
209 // above: a $-shaped bare token is now syntactically legal
210 // (issue #708) but must bind/read under the same key.
211 Expr::Variable(n, span) => {
212 let bind_key = n.to_uppercase().replace('$', "");
213 params.push((n.clone(), bind_key, *span));
214 }
215 _ => return Value::Error(ErrorKind::Name),
216 }
217 }
218 let body = &lambda_args[lambda_args.len() - 1];
219 (params, body)
220 }
221 _ => return Value::Error(ErrorKind::Value),
222 };
223
224 if call_args.len() != lambda_params.len() {
225 return Value::Error(ErrorKind::NA);
226 }
227
228 let mut evaluated_args: Vec<Value> = Vec::with_capacity(call_args.len());
229 for arg in call_args {
230 let v = evaluate_expr(arg, ctx);
231 if v.is_error() {
232 return v;
233 }
234 evaluated_args.push(v);
235 }
236
237 let mut saved: Vec<(String, Option<Value>)> = Vec::with_capacity(lambda_params.len());
238 for ((display_name, bind_key, span), val) in lambda_params.iter().zip(evaluated_args) {
239 // Parameter-binding event — see the function doc comment above.
240 if let Some(hook) = ctx.hook.as_deref_mut() {
241 hook.on_node(EvalOp::Variable(display_name), *span, &val);
242 }
243 let old = ctx.ctx.set(bind_key.clone(), val);
244 saved.push((bind_key.clone(), old));
245 }
246
247 let result = evaluate_expr(body, ctx);
248
249 for (name, old_val) in saved.into_iter().rev() {
250 match old_val {
251 Some(v) => { ctx.ctx.set(name, v); }
252 None => { ctx.ctx.remove(&name); }
253 }
254 }
255
256 result
257}
258
259// ── Type ordering for cross-type comparisons (Excel semantics) ───────────────
260// Number < Text < Bool (Empty counts as Number)
261fn type_rank(v: &Value) -> u8 {
262 match v {
263 Value::Number(_) | Value::Date(_) | Value::Empty | Value::Zoned(_) => 0,
264 Value::Text(_) => 1,
265 Value::Bool(_) => 2,
266 // Error and Array cannot reach compare_values through the normal eval path
267 // (eval_binary guards against errors before calling compare_values).
268 Value::Error(_) | Value::ErrorMsg(_, _) | Value::Array(_) => 3,
269 }
270}
271
272fn eval_binary(op: &BinaryOp, lv: Value, rv: Value) -> Value {
273 // ── Array broadcasting ───────────────────────────────────────────────────
274 match (&lv, &rv) {
275 (Value::Array(lelems), Value::Array(relems)) => {
276 // Element-wise operation when both operands are arrays of the same length.
277 if lelems.len() != relems.len() {
278 return Value::Error(ErrorKind::Value);
279 }
280 let result: Vec<Value> = lelems
281 .iter()
282 .zip(relems.iter())
283 .map(|(l, r)| eval_binary(op, l.clone(), r.clone()))
284 .collect();
285 return Value::Array(result);
286 }
287 (Value::Array(elems), _) => {
288 let result: Vec<Value> = elems
289 .iter()
290 .map(|e| eval_binary(op, e.clone(), rv.clone()))
291 .collect();
292 return Value::Array(result);
293 }
294 (_, Value::Array(elems)) => {
295 let result: Vec<Value> = elems
296 .iter()
297 .map(|e| eval_binary(op, lv.clone(), e.clone()))
298 .collect();
299 return Value::Array(result);
300 }
301 _ => {}
302 }
303 match op {
304 // ── Arithmetic ──────────────────────────────────────────────────────
305 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Pow => {
306 // Date-type production (schema spec §6: date-typed iff Sheets keeps
307 // the result rendering as a date). Sheets treats date ± offset as a
308 // date:
309 // - `+`: date + number (either operand order) stays a date
310 // (workbook.tsv `=DATE(2026,6,7)+1` → date).
311 // - `−`: date − number stays a date (a date a week earlier is still
312 // a date), while date − date is a plain day count
313 // (workbook.tsv `=DATE(2026,6,7)-DATE(2026,6,1)` → 6, number) and
314 // number − date is a plain number.
315 // `×`, `÷`, `^` on a date are not date operations and stay numbers.
316 let date_typed = match op {
317 BinaryOp::Add => {
318 matches!(lv, Value::Date(_)) != matches!(rv, Value::Date(_))
319 }
320 BinaryOp::Sub => {
321 matches!(lv, Value::Date(_)) && !matches!(rv, Value::Date(_))
322 }
323 _ => false,
324 };
325 let ln = match to_number(lv) { Ok(n) => n, Err(e) => return e };
326 let rn = match to_number(rv) { Ok(n) => n, Err(e) => return e };
327 let result = match op {
328 BinaryOp::Add => ln + rn,
329 BinaryOp::Sub => ln - rn,
330 BinaryOp::Mul => ln * rn,
331 BinaryOp::Div => {
332 if rn == 0.0 {
333 return Value::Error(ErrorKind::DivByZero);
334 }
335 ln / rn
336 }
337 BinaryOp::Pow => libm::pow(ln, rn),
338 // Safety: outer match arm covers exactly Add|Sub|Mul|Div|Pow; Concat and comparison ops are handled separately.
339 _ => unreachable!(),
340 };
341 if !result.is_finite() {
342 return Value::Error(ErrorKind::Num);
343 }
344 if date_typed {
345 Value::Date(result)
346 } else {
347 Value::Number(result)
348 }
349 }
350
351 // ── Concatenation ───────────────────────────────────────────────────
352 BinaryOp::Concat => {
353 let ls = match to_string_val(lv) { Ok(s) => s, Err(e) => return e };
354 let rs = match to_string_val(rv) { Ok(s) => s, Err(e) => return e };
355 Value::Text(ls + &rs)
356 }
357
358 // ── Comparisons ─────────────────────────────────────────────────────
359 BinaryOp::Eq | BinaryOp::Ne
360 | BinaryOp::Lt | BinaryOp::Gt
361 | BinaryOp::Le | BinaryOp::Ge => {
362 // Error propagation: left side first.
363 if lv.is_error() { return lv; }
364 if rv.is_error() { return rv; }
365 // Mixed naive/aware comparison is rejected (a zoned instant cannot be
366 // ordered against a naive value).
367 if matches!(&lv, Value::Zoned(_)) ^ matches!(&rv, Value::Zoned(_)) {
368 return Value::Error(ErrorKind::Value);
369 }
370
371 let result = compare_values(op, &lv, &rv);
372 Value::Bool(result)
373 }
374 }
375}
376
377/// Compare two (non-error) values with Excel ordering semantics.
378fn compare_values(op: &BinaryOp, lv: &Value, rv: &Value) -> bool {
379 match (lv, rv) {
380 (Value::Number(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
381 (Value::Date(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
382 (Value::Date(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
383 (Value::Number(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
384 // Zoned instants compare on the absolute instant only (same moment in a
385 // different zone compares equal). Cross-type Zoned is rejected in eval_binary.
386 (Value::Zoned(a), Value::Zoned(b)) => apply_cmp(op, Some(a.utc_nanos.cmp(&b.utc_nanos))),
387 (Value::Text(a), Value::Text(b)) => apply_cmp(op, Some(a.cmp(b))),
388 (Value::Bool(a), Value::Bool(b)) => apply_cmp(op, Some(a.cmp(b))),
389 (Value::Empty, Value::Empty) => apply_cmp(op, Some(std::cmp::Ordering::Equal)),
390 // Empty acts as Number(0)
391 (Value::Empty, Value::Number(b)) => apply_cmp(op, 0.0f64.partial_cmp(b)),
392 (Value::Number(a), Value::Empty) => apply_cmp(op, a.partial_cmp(&0.0f64)),
393 // Cross-type: use type rank
394 _ => {
395 let lr = type_rank(lv);
396 let rr = type_rank(rv);
397 match op {
398 BinaryOp::Eq => false,
399 BinaryOp::Ne => true,
400 BinaryOp::Lt => lr < rr,
401 BinaryOp::Gt => lr > rr,
402 BinaryOp::Le => lr <= rr,
403 BinaryOp::Ge => lr >= rr,
404 // Safety: outer match arm covers exactly Eq|Ne|Lt|Gt|Le|Ge; arithmetic and Concat ops are handled separately.
405 _ => unreachable!(),
406 }
407 }
408 }
409}
410
411fn apply_cmp(op: &BinaryOp, ord: Option<std::cmp::Ordering>) -> bool {
412 match ord {
413 // NaN: per Value::Number invariant this should not occur after the is_finite() guard;
414 // returning false matches Excel semantics if it somehow does.
415 None => false,
416 Some(o) => match op {
417 BinaryOp::Eq => o.is_eq(),
418 BinaryOp::Ne => o.is_ne(),
419 BinaryOp::Lt => o.is_lt(),
420 BinaryOp::Gt => o.is_gt(),
421 BinaryOp::Le => o.is_le(),
422 BinaryOp::Ge => o.is_ge(),
423 // 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).
424 _ => unreachable!(),
425 },
426 }
427}
428
429// ── Tests ────────────────────────────────────────────────────────────────────
430#[cfg(test)]
431mod tests;