- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen"). - Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus CONCAT/CONCATENATE als Funktions-Äquivalent. - Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar. Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung), bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst nutzbar (Aufrufer können sie aber als Argument übergeben). - Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte "COUNT(...)" statt "COUNT2(...)"). 374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
568 lines
24 KiB
TypeScript
568 lines
24 KiB
TypeScript
/**
|
|
* 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<string, Node>(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<string, Node>, 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<string, Node>, 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<string, Node>,
|
|
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;
|
|
}
|
|
}
|