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:
wkh
2026-09-02 22:41:55 +02:00
co-authored by Claude Sonnet 5
parent 6daea9f956
commit 01ea18106c
13 changed files with 1143 additions and 484 deletions
+155 -1
View File
@@ -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') {