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 }
271}
272
273fn eval_binary(op: &BinaryOp, lv: Value, rv: Value) -> Value {
274 // ── Array broadcasting ───────────────────────────────────────────────────
275 match (&lv, &rv) {
276 (Value::Array(lelems), Value::Array(relems)) => {
277 // Element-wise operation when both operands are arrays of the same length.
278 if lelems.len() != relems.len() {
279 return Value::Error(ErrorKind::Value);
280 }
281 let result: Vec<Value> = lelems
282 .iter()
283 .zip(relems.iter())
284 .map(|(l, r)| eval_binary(op, l.clone(), r.clone()))
285 .collect();
286 return Value::Array(result);
287 }
288 (Value::Array(elems), _) => {
289 let result: Vec<Value> = elems
290 .iter()
291 .map(|e| eval_binary(op, e.clone(), rv.clone()))
292 .collect();
293 return Value::Array(result);
294 }
295 (_, Value::Array(elems)) => {
296 let result: Vec<Value> = elems
297 .iter()
298 .map(|e| eval_binary(op, lv.clone(), e.clone()))
299 .collect();
300 return Value::Array(result);
301 }
302 _ => {}
303 }
304 match op {
305 // ── Arithmetic ──────────────────────────────────────────────────────
306 BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Pow => {
307 // Date-type production (schema spec §6: date-typed iff Sheets keeps
308 // the result rendering as a date). Sheets treats date ± offset as a
309 // date:
310 // - `+`: date + number (either operand order) stays a date
311 // (workbook.tsv `=DATE(2026,6,7)+1` → date).
312 // - `−`: date − number stays a date (a date a week earlier is still
313 // a date), while date − date is a plain day count
314 // (workbook.tsv `=DATE(2026,6,7)-DATE(2026,6,1)` → 6, number) and
315 // number − date is a plain number.
316 // `×`, `÷`, `^` on a date are not date operations and stay numbers.
317 let date_typed = match op {
318 BinaryOp::Add => {
319 matches!(lv, Value::Date(_)) != matches!(rv, Value::Date(_))
320 }
321 BinaryOp::Sub => {
322 matches!(lv, Value::Date(_)) && !matches!(rv, Value::Date(_))
323 }
324 _ => false,
325 };
326 let ln = match to_number(lv) { Ok(n) => n, Err(e) => return e };
327 let rn = match to_number(rv) { Ok(n) => n, Err(e) => return e };
328 let result = match op {
329 BinaryOp::Add => ln + rn,
330 BinaryOp::Sub => ln - rn,
331 BinaryOp::Mul => ln * rn,
332 BinaryOp::Div => {
333 if rn == 0.0 {
334 return Value::Error(ErrorKind::DivByZero);
335 }
336 ln / rn
337 }
338 BinaryOp::Pow => libm::pow(ln, rn),
339 // Safety: outer match arm covers exactly Add|Sub|Mul|Div|Pow; Concat and comparison ops are handled separately.
340 _ => unreachable!(),
341 };
342 if !result.is_finite() {
343 return Value::Error(ErrorKind::Num);
344 }
345 if date_typed {
346 Value::Date(result)
347 } else {
348 Value::Number(result)
349 }
350 }
351
352 // ── Concatenation ───────────────────────────────────────────────────
353 BinaryOp::Concat => {
354 let ls = match to_string_val(lv) { Ok(s) => s, Err(e) => return e };
355 let rs = match to_string_val(rv) { Ok(s) => s, Err(e) => return e };
356 Value::Text(ls + &rs)
357 }
358
359 // ── Comparisons ─────────────────────────────────────────────────────
360 BinaryOp::Eq | BinaryOp::Ne
361 | BinaryOp::Lt | BinaryOp::Gt
362 | BinaryOp::Le | BinaryOp::Ge => {
363 // Error propagation: left side first.
364 if lv.is_error() { return lv; }
365 if rv.is_error() { return rv; }
366 // Mixed naive/aware comparison is rejected (a zoned instant cannot be
367 // ordered against a naive value).
368 if matches!(&lv, Value::Zoned(_)) ^ matches!(&rv, Value::Zoned(_)) {
369 return Value::Error(ErrorKind::Value);
370 }
371
372 let result = compare_values(op, &lv, &rv);
373 Value::Bool(result)
374 }
375 }
376}
377
378/// Compare two (non-error) values with Excel ordering semantics.
379fn compare_values(op: &BinaryOp, lv: &Value, rv: &Value) -> bool {
380 match (lv, rv) {
381 (Value::Number(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
382 (Value::Date(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
383 (Value::Date(a), Value::Number(b)) => apply_cmp(op, a.partial_cmp(b)),
384 (Value::Number(a), Value::Date(b)) => apply_cmp(op, a.partial_cmp(b)),
385 // Zoned instants compare on the absolute instant only (same moment in a
386 // different zone compares equal). Cross-type Zoned is rejected in eval_binary.
387 (Value::Zoned(a), Value::Zoned(b)) => apply_cmp(op, Some(a.utc_nanos.cmp(&b.utc_nanos))),
388 (Value::Text(a), Value::Text(b)) => apply_cmp(op, Some(a.cmp(b))),
389 (Value::Bool(a), Value::Bool(b)) => apply_cmp(op, Some(a.cmp(b))),
390 (Value::Empty, Value::Empty) => apply_cmp(op, Some(std::cmp::Ordering::Equal)),
391 // Empty acts as Number(0)
392 (Value::Empty, Value::Number(b)) => apply_cmp(op, 0.0f64.partial_cmp(b)),
393 (Value::Number(a), Value::Empty) => apply_cmp(op, a.partial_cmp(&0.0f64)),
394 // Cross-type: use type rank
395 _ => {
396 let lr = type_rank(lv);
397 let rr = type_rank(rv);
398 match op {
399 BinaryOp::Eq => false,
400 BinaryOp::Ne => true,
401 BinaryOp::Lt => lr < rr,
402 BinaryOp::Gt => lr > rr,
403 BinaryOp::Le => lr <= rr,
404 BinaryOp::Ge => lr >= rr,
405 // Safety: outer match arm covers exactly Eq|Ne|Lt|Gt|Le|Ge; arithmetic and Concat ops are handled separately.
406 _ => unreachable!(),
407 }
408 }
409 }
410}
411
412fn apply_cmp(op: &BinaryOp, ord: Option<std::cmp::Ordering>) -> bool {
413 match ord {
414 // NaN: per Value::Number invariant this should not occur after the is_finite() guard;
415 // returning false matches Excel semantics if it somehow does.
416 None => false,
417 Some(o) => match op {
418 BinaryOp::Eq => o.is_eq(),
419 BinaryOp::Ne => o.is_ne(),
420 BinaryOp::Lt => o.is_lt(),
421 BinaryOp::Gt => o.is_gt(),
422 BinaryOp::Le => o.is_le(),
423 BinaryOp::Ge => o.is_ge(),
424 // 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).
425 _ => unreachable!(),
426 },
427 }
428}
429
430// ── Tests ────────────────────────────────────────────────────────────────────
431#[cfg(test)]
432mod tests;