Skip to main content

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;