Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere
Replaces the previous FOR-only, single-statement-body, static-unrolling design with genuine control flow: FOR var IN source DO <block> END loop bodies with any number of statements (including nested loops/IFs), IF cond THEN <block> [ELSE <block>] END branching (nestable in any block), and RETURN as an ordinary statement usable anywhere -- it aborts execution immediately with its value, including breaking out of a loop early. This required a real, if small, architecture change: an IF branch or an early RETURN depends on actual cell values, which the previous "unroll everything into one static formula at parse time" approach can't resolve (loop *counts* are knowable up front from a range's size; branch *outcomes* aren't). A kind: 'block' call is now inlined only as far as resolving nested function calls (expandBlock, parse-time, same inside-out principle as before) and wrapped in a new BlockCallNode; its own control flow is interpreted at evaluation time by a small tree-walking interpreter added to evaluator.ts (execBlock/execStatement, a thrown ReturnSignal for early exit), reusing userFunctions.ts's substitute() (now exported) to resolve parameter/local-variable names against the current environment. Dependency tracking stays fully correct and conservative: collectRefs() walks into a BlockCallNode's block (new collectRefsInBlock), so a cell referenced only in a branch that doesn't happen to run is still a tracked precedent -- same principle as an ordinary IF() in any spreadsheet. Also added: comparison operators (= <> > < >= <=, result 1/0) as a new, loosest-binding precedence level in the *general* expression grammar (not just inside a block's IF) -- needed for IF conditions to be useful at all. A definition-time static check (blockAlwaysReturns) rejects a function at save time when it's not obviously guaranteed to reach RETURN on every path (an IF without an ELSE, or one where only one branch returns); the interpreter's own runtime "ended without RETURN" -> #ERR remains the actual enforcement for cases the static check can't see (e.g. a loop over an empty range). Nested loops (FOR inside FOR) and nested IFs work as an emergent property of the recursive statement grammar -- verified with dedicated tests, not just assumed. 257 tests passing (30 net new/rewritten for this change), vue-tsc/ eslint/build clean. Verified live in the running app: a FIRSTOVER(range; threshold) function using a FOR loop with a nested IF and an early RETURN, confirmed it returns the first match, confirmed changing a cell *past* the match point doesn't affect the result while changing the match point itself does, and confirmed a function without a guaranteed RETURN is rejected inline at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+155
-1
@@ -11,16 +11,26 @@
|
||||
* ----------------
|
||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||
* string is coerced; any other string yields `#VALUE!`.
|
||||
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
|
||||
* - 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!`.
|
||||
*
|
||||
* `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 { Node } from './parser';
|
||||
import type { BlockCallNode, BlockNode, CellNode, Node, 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 {
|
||||
@@ -61,6 +71,18 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
return l * r;
|
||||
case '/':
|
||||
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
||||
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;
|
||||
}
|
||||
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
||||
}
|
||||
@@ -99,9 +121,141 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `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);
|
||||
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 or a 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. */
|
||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||
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 === 'range') {
|
||||
|
||||
Reference in New Issue
Block a user