This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
/**
* 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!`.
* - 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!`.
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - Division by zero -> `#DIV/0!`.
*/
import type { Node } from './parser';
import { getFunction } from './functions';
import { expandRange } from './refs';
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);
}
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);
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 'func': {
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);
}
}
}
/** Evaluate one function argument into a flat list of values (ranges expand). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
if (node.kind === 'range') {
const tableId =
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
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];
}
/** 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`);
}