Add FOR-loop "code" bodies to user-defined functions (kind: 'block')

A custom function body could previously only be a single expression --
no way to iterate over a range's elements to build something like
PRODUCT (multiply every cell together), since none of the built-ins
(SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing
functions, not define a new reduction. This adds a second body kind,
'block': a sequence of `name = expression` assignments and
`FOR var IN source DO name = expression` loops, ending in
`RETURN expression`. Flat scope -- every introduced name stays visible
for the rest of the body, including after its own loop, holding its
last iteration's value.

No runtime loop in the evaluator: a loop's source size is already known
once the caller's real argument is substituted in, so
userFunctions.ts's new compileBlock() unrolls every FOR loop into a
single nested expression (a left fold) at the same parse-time inlining
point Phase 1 already used -- the result is exactly the same shape of
static AST as any other inlined function call, so evaluator.ts and
graph.ts need no changes, dependency edges on every unrolled cell are
picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps
it crash/hang-free. parser.ts's parseFunctionBlock() parses the new
grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are
plain case-matched IDENTs, not new reserved tokens) into a BlockNode
kept outside the existing Node union, so no existing switch needs a
new case.

Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'`
(defaulted for every function saved before this existed, including via
JSON import). validateCustomFunction validates the new grammar's local/
loop-variable names the same way as parameters (not cell-shaped, no
reserved words, no shadowing a parameter -- but reusing the same local
across statements, the accumulator pattern, is correctly not a
collision) and that every FOR source is structurally a parameter, cell,
or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel"
and "Mit Schleife", with its own placeholder/hint (a PRODUCT example)
and a small badge on block-kind rows in the list.

237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live
in the running app: defined PRODUCT via the loop UI, called
=PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation
on a change to a cell inside the range, and an inline rejection for an
invalid FOR source at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 21:58:59 +02:00
co-authored by Claude Sonnet 5
parent 7340307a70
commit 6daea9f956
13 changed files with 1094 additions and 48 deletions
+183 -8
View File
@@ -15,9 +15,26 @@
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
* and correctly pick up cell references that live *only* inside the
* function body, not just the ones passed as arguments.
*
* A `kind: 'block'` function (a FOR/DO/RETURN body, see parser.ts's
* `parseFunctionBlock`) works the same way in the end: `compileBlock()`
* below *unrolls* every FOR loop -- whose range size is already known once
* the caller's real argument is substituted in -- into a single nested
* expression tree, one iteration deep per cell. There is no runtime loop in
* the evaluator; by the time `expand()` is done, a block-kind call has
* become exactly the same shape of plain expression tree as any other
* inlined function call. See src/engine/README.md's "Schleifen" section.
*/
import { parseFormula, type Node } from './parser';
import {
parseFormula,
parseFunctionBlock,
type BlockNode,
type CellNode,
type ForNode,
type Node,
} from './parser';
import { expandRange } from './refs';
export interface FunctionDef {
id: string;
@@ -25,10 +42,24 @@ export interface FunctionDef {
name: string;
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=". */
/** raw formula text, no leading "=" -- a single expression for
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
* `kind: 'block'`. */
body: string;
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
* capable). Every function predating this field is 'expression'. */
kind: 'expression' | 'block';
}
/** Total cells one `compileBlock()` call (i.e. one block-kind function call
* site) may unroll, summed across every FOR loop in its body -- 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 still separately bounded by MAX_EXPANSION_DEPTH, so
* total work across a whole formula stays finite either way. Generous for
* anything this app's tables realistically hold. */
const MAX_UNROLL_STEPS = 10_000;
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
* rejected at definition time (see the store's cycle check), this is only
* a last-resort safety net so a bug there degrades to a cell error rather
@@ -53,13 +84,14 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, Fu
*/
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
if (index.size === 0) return ast; // fast path: no user functions defined at all
return expand(ast, index, new Map(), 0);
return expand(ast, index, new Map(), new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
blockCache: Map<string, BlockNode>,
depth: number,
): Node {
switch (node.kind) {
@@ -70,18 +102,18 @@ function expand(
case 'param':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
case 'binary':
return {
...node,
left: expand(node.left, index, bodyCache, depth),
right: expand(node.right, index, bodyCache, depth),
left: expand(node.left, index, bodyCache, blockCache, depth),
right: expand(node.right, index, bodyCache, blockCache, depth),
};
case 'func': {
// expand the arguments regardless of whether this call is itself a
// user function -- a built-in like SUM(...) may still take a
// user-function call as one of its arguments.
const args = node.args.map((a) => expand(a, index, bodyCache, depth));
const args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
const def = index.get(node.name.toUpperCase());
if (!def) return { ...node, args };
@@ -94,6 +126,23 @@ function expand(
);
}
if (def.kind === 'block') {
let block = blockCache.get(def.id);
if (!block) {
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
blockCache.set(def.id, block);
}
// compileBlock() already substitutes this function's own parameters
// (it needs their *real* values to know how many cells a FOR loop
// covers), so -- unlike the expression-kind path below -- there is
// no separate substitute() step here. Its result may still contain
// nested calls (to built-ins or other user functions), so it goes
// through expand() once more, one level deeper, exactly like any
// other function body.
const compiled = compileBlock(block, def.params, args);
return expand(compiled, index, bodyCache, blockCache, depth + 1);
}
let body = bodyCache.get(def.id);
if (!body) {
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
@@ -104,7 +153,7 @@ function expand(
// resolving inside-out like this keeps each function's own parameters
// scoped correctly no matter what the caller's happen to be named),
// then substitute this function's own parameters last.
const expandedBody = expand(body, index, bodyCache, depth + 1);
const expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
return substitute(expandedBody, bound);
}
@@ -133,6 +182,77 @@ function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
}
}
/**
* Compile a `kind: 'block'` function body down to a single plain expression
* `Node`, given the caller's *real* argument nodes for this call. Statements
* run in order against a growing `bound` environment (name -> already-
* substituted expression tree): a plain assignment substitutes its value
* against the current environment and stores it; a FOR loop resolves its
* source to a concrete list of cells (throwing if it isn't one -- can only
* happen if a param turned out, at this call site, not to be a cell/range;
* the store's save-time check catches the far more common case of a
* body that could *never* work), then unrolls one nested substitution per
* cell, each seeing the *previous* iteration's value for whichever name the
* loop body assigns -- exactly a left fold. The loop variable and the
* loop body's assignment target both stay in `bound` afterwards (flat
* scope: visible, holding their last iteration's value, for the rest of the
* block) -- parseFunctionBlock() already parsed the source text with that
* same flat-scope assumption, so this matches.
*
* Throws a plain `Error` on a bad FOR source or exceeding
* {@link MAX_UNROLL_STEPS}; the caller (`expand()`'s `func` case, in turn
* `Workbook.parseCell`) turns it into `#ERR`, same as any other
* expandUserFunctions() failure.
*/
function compileBlock(block: BlockNode, paramNames: readonly string[], args: readonly Node[]): Node {
const bound = new Map<string, Node>(paramNames.map((p, i) => [p.toUpperCase(), args[i]]));
let steps = 0;
for (const stmt of block.statements) {
if (stmt.kind === 'assign') {
bound.set(stmt.name.toUpperCase(), substitute(stmt.value, bound));
continue;
}
const source = substitute(stmt.source, bound);
const cells = rangeOrCellToCellNodes(source, stmt.varName);
steps += cells.length;
if (steps > MAX_UNROLL_STEPS) {
throw new Error(
`FOR-Schleife(n) über zu viele Zellen (> ${MAX_UNROLL_STEPS} insgesamt) -- bitte einen kleineren Bereich verwenden`,
);
}
for (const cell of cells) {
const iterBound = new Map(bound);
iterBound.set(stmt.varName.toUpperCase(), cell);
const value = substitute(stmt.body.value, iterBound);
bound.set(stmt.body.name.toUpperCase(), value);
bound.set(stmt.varName.toUpperCase(), cell); // stays visible after the loop (flat scope)
}
}
return substitute(block.ret, bound);
}
/** A FOR loop's source, once parameters are substituted in, 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` (same helper
* `evaluator.ts#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`);
}
/**
* Every distinct function name called anywhere in `ast` (built-in or not --
* callers filter against their own registry). Used by the store to build a
@@ -157,3 +277,58 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
}
return out;
}
/** Every name a block declares -- an assignment target or a loop variable --
* in the order first declared. Used by the store to validate these local
* names the same way it validates parameters (not cell-shaped, no
* collisions with each other or the parameters, not a reserved word); the
* engine itself has no opinion on naming policy or German error text, so
* it only exposes the raw names here. */
export function declaredBlockNames(block: BlockNode): string[] {
const out: string[] = [];
for (const stmt of block.statements) {
if (stmt.kind === 'assign') {
out.push(stmt.name);
} else {
out.push(stmt.varName, stmt.body.name);
}
}
return out;
}
/** The first FOR loop, if any, whose source isn't (syntactically) a
* parameter, a single cell, or a range -- i.e. can never resolve to a
* concrete list of cells no matter what the caller passes. Checked at
* save time so this is caught immediately, not just on the first real
* call; `compileBlock` re-checks the same thing on the *substituted*
* source at call time (needed regardless, since a parameter's shape isn't
* known until then) as the actual enforcement. */
export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'for' && stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
return stmt;
}
}
return null;
}
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
* over every leaf expression a `BlockNode` contains (each statement's
* value/source, a FOR loop's body assignment, the RETURN expression) and
* merges the results -- `collectCalledFunctionNames` itself needs no
* changes, since a block is never part of the `Node` union it switches on. */
export function collectCalledFunctionNamesInBlock(
block: BlockNode,
out: Set<string> = new Set(),
): Set<string> {
for (const stmt of block.statements) {
if (stmt.kind === 'assign') {
collectCalledFunctionNames(stmt.value, out);
} else {
collectCalledFunctionNames(stmt.source, out);
collectCalledFunctionNames(stmt.body.value, out);
}
}
collectCalledFunctionNames(block.ret, out);
return out;
}