/** * AST evaluator. * * `evaluate(ast, host)` walks the tree and returns a {@link CellValue}. It never * recurses into other cells directly -- it asks the {@link EngineHost} for their * *already computed* values, which the recalculation driver guarantees are ready * (topological order). Circular cells are detected up front by the graph module * and never reach the evaluator. * * Evaluation model * ---------------- * - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking * string is coerced; any other string yields `#VALUE!`. * - Comparisons (`= <> > < >= <=`) result `1`/`0`. Two numbers (or an empty * cell, which takes on `0`) compare numerically; two texts (or an empty * cell, which takes on `''`) compare case-insensitively and trimmed -- * same convention as XLOOKUP's own text matching, see evalXLookup(). A * number against a real (non-empty) text never crossmatches: `=`/`<>` * answer definitively false/true, `> < >= <=` have no sane answer and * yield `#VALUE!` (see compareValues()). * - `&` concatenates as text: a number renders plain (`String(n)`, no * locale formatting), an empty cell renders as `''` (see toText()). * - Any error among operands / arguments propagates unchanged (first wins). * - A range may only appear as a function argument. Used in scalar position it * yields `#VALUE!`. * - A `NamedRefNode` (a bare, unqualified name that isn't a cell) resolves * live against `host.resolveNamedRange()` into the cell/range it stands * for, then evaluates exactly like that reference would have (see * `resolveNamedRef()`). Unknown name -> `#NAME?`. * - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`. * - Division by zero -> `#DIV/0!`. * * `kind: 'block'` custom functions (FOR/IF/RETURN "code" bodies -- see * userFunctions.ts and src/engine/README.md's "Schleifen" section) are the * one thing here that isn't a pure, static AST walk: a `BlockCallNode` * embedded in a cell's AST is *interpreted* right here, at evaluation time * (`evaluateBlockCall`, below `evalScalar`) -- real cell values are needed * to decide an `IF` branch or when an early `RETURN` fires, so unlike an * ordinary function call this can't be resolved any earlier. */ import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import { getFunction } from './functions'; import { expandRange } from './refs'; import { substitute } from './userFunctions'; import { FormulaError, isError, type CellValue, type EngineHost } from './types'; export function evaluate(ast: Node, host: EngineHost): CellValue { return evalScalar(ast, host); } function evalScalar(node: Node, host: EngineHost): CellValue { switch (node.kind) { case 'number': return node.value; case 'string': return node.value; case 'unary': { const v = toNumber(evalScalar(node.operand, host)); if (isError(v)) return v; return node.op === '-' ? -v : v; } case 'binary': { if (node.op === '^') { const l = toNumber(evalScalar(node.left, host)); if (isError(l)) return l; const r = toNumber(evalScalar(node.right, host)); if (isError(r)) return r; return Math.pow(l, r); } if (isComparisonOp(node.op)) { const lv = evalScalar(node.left, host); if (isError(lv)) return lv; const rv = evalScalar(node.right, host); if (isError(rv)) return rv; return compareValues(node.op, lv, rv); } if (node.op === '&') { const lv = toText(evalScalar(node.left, host)); if (isError(lv)) return lv; const rv = toText(evalScalar(node.right, host)); if (isError(rv)) return rv; return lv + rv; } const l = toNumber(evalScalar(node.left, host)); if (isError(l)) return l; const r = toNumber(evalScalar(node.right, host)); if (isError(r)) return r; switch (node.op) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return r === 0 ? new FormulaError('#DIV/0!') : l / r; } return new FormulaError('#ERR', `unknown operator ${node.op}`); } case 'cell': { const tableId = node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier, node.sheetQualifier ?? null); if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`); return host.getComputed(tableId, node.col, node.row); } case 'range': // A bare range can't collapse to a scalar. return new FormulaError('#VALUE!', 'range used where a value is expected'); case 'namedref': { const resolved = resolveNamedRef(node, host); if (isError(resolved)) return resolved; return evalScalar(resolved, host); // a resolved 1x1 range is a 'cell', so this recursion terminates } case 'func': { // IF is the one built-in that must NOT evaluate its arguments eagerly // (see functions.ts's FUNCTIONS.IF comment): only the taken branch may // be computed, so e.g. IF(B1=0; 0; A1/B1) never divides by zero when // B1=0. Special-cased here, before the generic eager-args dispatch. const upperName = node.name.toUpperCase(); if (upperName === 'IF') return evalIf(node, host); // XLOOKUP needs its two range arguments kept separate (not flattened // into one combined list like every other function gets, see // functions.ts's FUNCTIONS.XLOOKUP comment) and its if_not_found // argument evaluated lazily -- both require handling here, before the // generic eager/flattening dispatch below. if (upperName === 'XLOOKUP') return evalXLookup(node, host); const fn = getFunction(node.name); if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`); const values: CellValue[] = []; for (const arg of node.args) { const part = evalArg(arg, host); if (isError(part)) return part; for (const v of part) { if (isError(v)) return v; values.push(v); } } return fn(values); } case 'param': // userFunctions.ts's expandUserFunctions() substitutes every ParamNode // away before a cell's AST is ever stored -- this should be unreachable // in practice. Fails as a cell error rather than however an unhandled // case would otherwise behave, matching the engine's "no crash" rule. return new FormulaError('#ERR', `unresolved parameter ${node.name}`); case 'blockcall': return evaluateBlockCall(node, host); case 'omitted': // an empty argument slot (`XLOOKUP(x;a;b;;-1)`'s skipped 4th // argument) evaluates like any other missing value -- same as an // empty cell. A function that needs to distinguish "omitted" from // "evaluates to null" checks the raw arg Node's kind directly before // ever calling evalScalar on it (see evalXLookup below). return null; } } /** IF(cond; then; else?) -- the real, short-circuiting implementation (see * the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF * comment). Only `node.args[0]` is always evaluated; whichever of the * other two branches is taken is evaluated next, and the other is never * touched at all -- so an error (or, in a block-mode call, any other side * effect) in the untaken branch never surfaces. Same truthy convention as * everywhere else in this engine (`execStatement`'s `ifstmt` case, above): * a number is truthy unless it's exactly 0; text/refs are truthy unless * empty. */ function evalIf(node: FuncNode, host: EngineHost): CellValue { if (node.args.length !== 2 && node.args.length !== 3) { return new FormulaError('#ERR', 'IF expects 2 or 3 arguments'); } const cond = evalScalar(node.args[0], host); if (isError(cond)) return cond; const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== ''; if (truthy) return evalScalar(node.args[1], host); return node.args.length === 3 ? evalScalar(node.args[2], host) : 0; } /** Exact-match equality for XLOOKUP -- deliberately its own thing, not the * `=` operator (which coerces both sides through `toNumber()` and so can * never match on text, XLOOKUP's single most common real-world use: "find * the row where this name/ID matches"). Numbers compare numerically, text * compares case-insensitively with surrounding whitespace ignored (matches * this engine's existing case-insensitive conventions -- function/table * names, `!`/`::` qualifiers). A number is never treated as equal to text * (no cross-type coercion -- predictable, no "5" == 5 surprises); two * empty cells are equal to each other. */ function xlookupEquals(a: CellValue, b: CellValue): boolean { if (isError(a) || isError(b)) return false; if (a === null || b === null) return a === b; if (typeof a === 'number' && typeof b === 'number') return a === b; if (typeof a === 'string' && typeof b === 'string') { return a.trim().toLowerCase() === b.trim().toLowerCase(); } return false; } /** * XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; * [Vergleichsmodus]; [Suchmodus]) -- see functions.ts's FUNCTIONS.XLOOKUP * comment for why this has to live here instead. `Suchbereich` and * `Rückgabebereich` are evaluated separately via `evalArg` (which already * returns one argument's values as their own array -- it's only `case * 'func'`'s generic dispatch above that flattens every argument together) * and must come out the same length. * * `Vergleichsmodus`: `0` (default) exact match; `-1` exact match or next * *smaller*; `1` exact match or next *larger* (both numeric-only -- a * nearest match against text isn't supported, documented scope limit, * see README.md). `2` (Excel's wildcard mode) is not supported. * `Suchmodus`: `1` (default) first-to-last; `-1` last-to-first. Excel's * binary-search modes (`2`/`-2`) are not supported -- pointless at this * app's table sizes and would require an assumed-sorted precondition this * engine can't verify. * * No match and no `WennNichtGefunden` given -> `#N/A` (a documented * extension to this engine's original error set, exactly the same * reasoning `#VALUE!` was added under). `WennNichtGefunden` is evaluated * lazily -- only when actually needed, same short-circuit principle as * `IF`'s branches -- so an error inside it never surfaces on a successful * match. */ function evalXLookup(node: FuncNode, host: EngineHost): CellValue { const args = node.args; if (args.length < 3 || args.length > 6) { return new FormulaError('#ERR', 'XLOOKUP expects 3 to 6 arguments'); } // An optional argument counts as "given" only if the slot both exists AND // wasn't left empty (`XLOOKUP(x;a;b;;-1)`'s skipped 4th argument, an // OmittedNode -- see parser.ts). `args.length` alone can't tell "not // provided at all" apart from "explicitly skipped to reach a later one". const given = (i: number) => i < args.length && args[i].kind !== 'omitted'; const lookupValue = evalScalar(args[0], host); if (isError(lookupValue)) return lookupValue; const lookupArr = evalArg(args[1], host); if (isError(lookupArr)) return lookupArr; const returnArr = evalArg(args[2], host); if (isError(returnArr)) return returnArr; if (lookupArr.length !== returnArr.length) { return new FormulaError( '#VALUE!', 'XLOOKUP: Suchbereich und Rückgabebereich müssen gleich viele Zellen haben', ); } for (const v of lookupArr) if (isError(v)) return v; for (const v of returnArr) if (isError(v)) return v; let matchMode = 0; if (given(4)) { const mm = toNumber(evalScalar(args[4], host)); if (isError(mm)) return mm; if (mm !== -1 && mm !== 0 && mm !== 1) { return new FormulaError( '#ERR', 'XLOOKUP: Vergleichsmodus muss -1, 0 oder 1 sein (2 wird nicht unterstützt)', ); } matchMode = mm; } let searchMode = 1; if (given(5)) { const sm = toNumber(evalScalar(args[5], host)); if (isError(sm)) return sm; if (sm !== 1 && sm !== -1) { return new FormulaError( '#ERR', 'XLOOKUP: Suchmodus muss 1 oder -1 sein (2/-2 werden nicht unterstützt)', ); } searchMode = sm; } const n = lookupArr.length; const order = searchMode === -1 ? [...Array(n).keys()].reverse() : [...Array(n).keys()]; if (matchMode === 0) { for (const i of order) { if (xlookupEquals(lookupValue, lookupArr[i])) return returnArr[i]; } } else if (typeof lookupValue === 'number') { // Nearest-match modes: numeric only. An exact match still wins outright // wherever it's found; otherwise track the closest qualifying candidate. let bestIdx = -1; let bestVal = 0; for (const i of order) { const cand = lookupArr[i]; if (xlookupEquals(lookupValue, cand)) return returnArr[i]; if (typeof cand !== 'number') continue; const qualifies = matchMode === -1 ? cand < lookupValue : cand > lookupValue; if (!qualifies) continue; const better = bestIdx === -1 || (matchMode === -1 ? cand > bestVal : cand < bestVal); if (better) { bestIdx = i; bestVal = cand; } } if (bestIdx !== -1) return returnArr[bestIdx]; } if (given(3)) return evalScalar(args[3], host); return new FormulaError('#N/A', 'XLOOKUP: kein Treffer'); } // --------------------------------------------------------------------------- // `kind: 'block'` custom functions: a small tree-walking interpreter for a // BlockCallNode's `block` (see parser.ts's grammar comment and // src/engine/README.md's "Schleifen" section). Everything else in this file // is a pure, static AST walk; this is the one place real control flow // (branching, early exit) happens, so it needs real cell values as it goes // -- unlike a `kind: 'expression'` call, which is fully resolved to a plain // expression at parse time and never reaches any special-case code here. // --------------------------------------------------------------------------- /** Thrown by a `return` statement to unwind out of `execBlock`/`execStatement` * immediately, carrying the function's result. Caught only by * `evaluateBlockCall`, right where it's thrown from -- a plain, standard * "unwind to a known boundary" pattern for early-exit control flow. */ class ReturnSignal { constructor(public readonly value: CellValue) {} } /** Total loop iterations one `evaluateBlockCall` may run, summed across * every FOR loop (including nested ones) it executes -- a safety net * against a pathologically large range (e.g. `FOR x IN A1:Z10000 DO ...`), * matching the "no crash, no hang" guarantee. A chain of nested * block-kind calls is separately bounded by expandUserFunctions()'s own * MAX_EXPANSION_DEPTH, so total work across one formula stays finite * either way. Generous for anything this app's tables realistically hold. */ const MAX_LOOP_STEPS = 10_000; function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue { const env = new Map(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]])); const budget = { steps: 0 }; try { execBlock(node.block, env, host, budget); } catch (e) { if (e instanceof ReturnSignal) return e.value; throw e; // a real problem (bad FOR source, step budget, ...) -- propagates // to evaluateCell()'s existing catch-all in workbook.ts, becoming #ERR, // exactly like a plain evaluation exception already does. } throw new Error(`'${node.label}' ended without RETURN`); } function execBlock(block: BlockNode, env: Map, host: EngineHost, budget: { steps: number }): void { for (const stmt of block.statements) { execStatement(stmt, env, host, budget); } } /** Resolve `expr` against the current environment (substitute() replaces * any name reference with its bound Node -- the caller's argument, or an * earlier statement's/iteration's literal result) and evaluate it. */ function evalInEnv(expr: Node, env: Map, host: EngineHost): CellValue { return evalScalar(substitute(expr, env), host); } /** Wrap an already-computed scalar back into a literal `Node` so it can be * stored in `env` and later be resolved/evaluated exactly like any other * bound name (a parameter, or the caller's argument expression). `null` * (an empty cell's value) becomes the number `0`, matching how every other * arithmetic context in this engine already treats an empty cell. Callers * check `isError` before ever calling this -- an error value is never * passed in (it aborts the whole function via `ReturnSignal` instead). */ function literalNodeOf(v: number | string | null): Node { if (v === null) return { kind: 'number', value: 0 }; return typeof v === 'number' ? { kind: 'number', value: v } : { kind: 'string', value: v }; } function execStatement( stmt: Statement, env: Map, host: EngineHost, budget: { steps: number }, ): void { switch (stmt.kind) { case 'assign': { const v = evalInEnv(stmt.value, env, host); if (isError(v)) throw new ReturnSignal(v); // an error in a plain assignment aborts the function with that error env.set(stmt.name.toUpperCase(), literalNodeOf(v)); return; } case 'return': { const v = evalInEnv(stmt.value, env, host); throw new ReturnSignal(v); } case 'ifstmt': { const c = evalInEnv(stmt.cond, env, host); if (isError(c)) throw new ReturnSignal(c); const truthy = typeof c === 'number' ? c !== 0 : c !== null && c !== ''; if (truthy) execBlock(stmt.then, env, host, budget); else if (stmt.else) execBlock(stmt.else, env, host, budget); return; } case 'for': { const source = substitute(stmt.source, env); const cells = rangeOrCellToCellNodes(source, stmt.varName, host); for (const cell of cells) { budget.steps++; if (budget.steps > MAX_LOOP_STEPS) { throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`); } env.set(stmt.varName.toUpperCase(), cell); execBlock(stmt.body, env, host, budget); } return; } } } /** A FOR loop's source, once names are resolved against the current * environment, must be a single cell, a range, or a named range -- never a * computed value (mirrors "a range may only appear as a function argument" * for ordinary formulas). Expands a range to its individual cells via * `expandRange` (the same helper `evalArg` uses for a range function * argument), carrying the same table/sheet qualifier through to each. A * named range resolves first (via `host`), then falls into the same * cell/range handling as any other reference. */ function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHost): CellNode[] { if (node.kind === 'namedref') { const resolved = resolveNamedRef(node, host); if (isError(resolved)) { throw new Error(`FOR ${loopVarName} IN ...: unbekannter Name '${node.name}'`); } node = resolved; } if (node.kind === 'cell') return [node]; if (node.kind === 'range') { return expandRange(node.start, node.end).map( (c): CellNode => ({ kind: 'cell', qualifier: node.qualifier, ...(node.sheetQualifier === undefined ? {} : { sheetQualifier: node.sheetQualifier }), col: c.col, row: c.row, }), ); } throw new Error(`FOR ${loopVarName} IN ...: die Quelle muss ein Zell- oder Bereichsbezug sein`); } /** Evaluate one function argument into a flat list of values (ranges expand). */ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { if (node.kind === 'namedref') { const resolved = resolveNamedRef(node, host); if (isError(resolved)) return resolved; return evalArg(resolved, host); // now a plain 'cell'/'range', handled below/by evalScalar } if (node.kind === 'range') { const tableId = node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier, node.sheetQualifier ?? null); if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`); return expandRange(node.start, node.end).map((c) => host.getComputed(tableId, c.col, c.row)); } const v = evalScalar(node, host); return [v]; } /** Resolve a `NamedRefNode` to the concrete `CellNode`/`RangeNode` it stands * for right now (via `host.resolveNamedRange()`), so every other place that * already knows how to evaluate a cell/range (`evalScalar`, `evalArg`, * `rangeOrCellToCellNodes`) can just fall through to that existing logic * instead of duplicating it. `#NAME?` when the name is unknown -- the same * error class an unknown function name gets. */ function resolveNamedRef(node: NamedRefNode, host: EngineHost): CellNode | RangeNode | FormulaError { const def = host.resolveNamedRange(node.name); if (!def) return new FormulaError('#NAME?', `unknown name '${node.name}'`); const sheetField = def.sheetQualifier === undefined ? {} : { sheetQualifier: def.sheetQualifier }; if (def.start.col === def.end.col && def.start.row === def.end.row) { return { kind: 'cell', qualifier: def.qualifier, ...sheetField, col: def.start.col, row: def.start.row }; } return { kind: 'range', qualifier: def.qualifier, ...sheetField, start: def.start, end: def.end }; } /** Coerce a cell value to a number for arithmetic, or return an error value. */ function toNumber(v: CellValue): number | FormulaError { if (isError(v)) return v; if (v === null) return 0; if (typeof v === 'number') return v; const trimmed = v.trim(); if (trimmed === '') return 0; const n = Number(trimmed); return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`); } /** Coerce a cell value to text for `&` concatenation, or return an error * value. An empty cell becomes `''` (not `'0'`), matching how it's ignored * everywhere else in this engine rather than treated as a literal number. * A number renders plain (`String(n)`, no thousands separator/locale -- * same "locale-neutral" convention as CSV/XLSX export), never `Intl`. */ function toText(v: CellValue): string | FormulaError { if (isError(v)) return v; if (v === null) return ''; return typeof v === 'string' ? v : String(v); } type ComparisonOp = '=' | '<>' | '>' | '<' | '>=' | '<='; function isComparisonOp(op: string): op is ComparisonOp { return op === '=' || op === '<>' || op === '>' || op === '<' || op === '>=' || op === '<='; } /** `= <> > < >= <=` on two already-evaluated scalars -- see the module * comment's "Comparisons" bullet for the full rule. An empty cell (`null`) * takes on whichever type the other side is (`0` for a number, `''` for * text), so both `=A1=0` and `=A1=""` work on a genuinely empty `A1`. Text * is matched the same way XLOOKUP matches text (case-insensitive, trimmed) * -- kept as one convention across the engine rather than two. */ function compareValues(op: ComparisonOp, l: CellValue, r: CellValue): CellValue { const lIsText = typeof l === 'string'; const rIsText = typeof r === 'string'; // A real number against real (non-empty) text is a genuine type mismatch // -- an empty cell (null) is handled below, taking on whichever type the // OTHER side is, so it never reaches this branch. if ((typeof l === 'number' && rIsText) || (lIsText && typeof r === 'number')) { if (op === '=') return 0; if (op === '<>') return 1; return new FormulaError('#VALUE!', 'text and number are not comparable with > < >= <='); } if (lIsText || rIsText) { const lt = (l ?? '').toString().trim().toLowerCase(); const rt = (r ?? '').toString().trim().toLowerCase(); return applyCompare(op, lt, rt); } const ln = l === null ? 0 : (l as number); const rn = r === null ? 0 : (r as number); return applyCompare(op, ln, rn); } function applyCompare(op: ComparisonOp, l: number, r: number): CellValue; function applyCompare(op: ComparisonOp, l: string, r: string): CellValue; function applyCompare(op: ComparisonOp, l: number | string, r: number | string): CellValue { switch (op) { case '=': return l === r ? 1 : 0; case '<>': return l !== r ? 1 : 0; case '>': return l > r ? 1 : 0; case '<': return l < r ? 1 : 0; case '>=': return l >= r ? 1 : 0; case '<=': return l <= r ? 1 : 0; } }