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:
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user