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
+81 -11
View File
@@ -815,10 +815,10 @@ describe('custom functions (document-level library)', () => {
});
});
describe('custom functions: "block" kind (FOR loops)', () => {
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
it('adds a block-kind function and a call evaluates through the unrolled loop', () => {
it('adds a block-kind function and a call evaluates through the loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
@@ -870,7 +870,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['bereich'],
'do = 1 FOR x IN bereich DO do = do * x RETURN do',
'do = 1 FOR x IN bereich DO do = do * x END RETURN do',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -881,7 +881,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['bereich'],
'A1 = 1 FOR x IN bereich DO A1 = A1 * x RETURN A1',
'A1 = 1 FOR x IN bereich DO A1 = A1 * x END RETURN A1',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -892,7 +892,18 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'SHADOWTEST',
['bereich'],
'bereich = 1 FOR x IN bereich DO bereich = bereich * x RETURN bereich',
'bereich = 1 FOR x IN bereich DO bereich = bereich * x END RETURN bereich',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a local name (declared inside an IF branch) that looks like a cell address', () => {
const s = fresh();
const result = s.addCustomFunction(
'F',
['x'],
'IF x > 0 THEN B2 = 1 RETURN B2 ELSE RETURN 0 END',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -912,13 +923,24 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['x'],
'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects malformed code (missing RETURN)', () => {
it('rejects a FOR source nested inside an IF that is structurally invalid', () => {
const s = fresh();
const result = s.addCustomFunction(
'F',
['x'],
'total = 0 IF x > 0 THEN FOR y IN x + 1 DO total = total + y END END RETURN total',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects malformed code (missing RETURN entirely)', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
error: string;
@@ -926,6 +948,31 @@ describe('custom functions: "block" kind (FOR loops)', () => {
expect(result.error).toBeTruthy();
});
it('rejects code where RETURN is missing on one branch (IF without ELSE is never guaranteed to return)', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 END', 'block') as {
error: string;
};
expect(result.error).toBeTruthy();
});
it('rejects code where only one IF/ELSE branch returns', () => {
const s = fresh();
const result = s.addCustomFunction(
'F',
['x'],
'IF x > 0 THEN RETURN 1 ELSE y = 1 END',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('accepts code where BOTH IF/ELSE branches return, with nothing after', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 ELSE RETURN 0 END', 'block');
expect(typeof result).toBe('string');
});
it('a block-kind function can call another custom function inside its loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
@@ -937,16 +984,39 @@ describe('custom functions: "block" kind (FOR loops)', () => {
s.addCustomFunction(
'SUMDOUBLED',
['bereich'],
'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
'block',
);
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2);
});
it('an early RETURN inside an IF inside a FOR finds the first match', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '1');
setCell(s, name, 'A2', '999');
setCell(s, name, 'A3', '4');
s.addCustomFunction(
'FIRSTOVER',
['bereich', 'schwelle'],
'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
'block',
);
setCell(s, name, 'B1', '=FIRSTOVER(A1:A3; 100)');
expect(val(s, name, 'B1')).toBe(999);
});
it('detects a cycle that runs through a block-kind function\'s loop body', () => {
const s = fresh();
const a = s.addCustomFunction('A', ['bereich'], 'total = 0 FOR x IN bereich DO total = total + B(x) RETURN total', 'block') as string;
const a = s.addCustomFunction(
'A',
['bereich'],
'total = 0 FOR x IN bereich DO total = total + B(x) END RETURN total',
'block',
) as string;
expect(typeof a).toBe('string');
// B calling A closes the cycle A -> B -> A
const result = s.addCustomFunction('B', ['x'], 'A(x) + 1') as { error: string };
@@ -1406,7 +1476,7 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addCustomFunction(
'PRODUCT2',
['bereich'],
'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
'block',
);
+8
View File
@@ -32,6 +32,7 @@ import {
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
blockAlwaysReturns,
type CellValue,
type TableModel,
type Token,
@@ -787,6 +788,13 @@ function createStore() {
if (badFor) {
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
}
// conservative static check (see blockAlwaysReturns's own doc comment)
// -- a real "no RETURN reached" (e.g. an empty FOR range) still
// degrades to #ERR at the affected cell regardless, this just catches
// the common case immediately, before ever saving.
if (!blockAlwaysReturns(block)) {
return 'Nicht jeder Codepfad endet mit RETURN.';
}
ownCalls = collectCalledFunctionNamesInBlock(block);
} else {
let ast;