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:
@@ -248,63 +248,28 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
const productBlock = {
|
||||
params: ['bereich'],
|
||||
kind: 'block' as const,
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
|
||||
};
|
||||
|
||||
describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
|
||||
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
|
||||
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
|
||||
// ((1 * A1) * A2) * A3
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
expect(expanded.kind).toBe('blockcall');
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
expect(expanded.label).toBe('PRODUCT2');
|
||||
expect(expanded.paramNames).toEqual(['bereich']);
|
||||
expect(expanded.args).toEqual([
|
||||
{
|
||||
kind: 'range',
|
||||
qualifier: null,
|
||||
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
|
||||
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
|
||||
},
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a single-cell FOR source (no range) unrolls to exactly one iteration', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'number', value: 1 },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a loop variable / local stays visible (holding its last value) after its own loop', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'LASTPLUSONE',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'acc = 0 FOR x IN bereich DO acc = acc + x RETURN x + 1', // returns the loop VAR, not acc
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=LASTPLUSONE(A1:A3)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 2 }, // A3 -- the last iterated cell
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a call inside a FOR body to another (expression-kind) function is expanded too', () => {
|
||||
it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||
def({
|
||||
@@ -312,93 +277,37 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
name: 'SUMDOUBLED',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
|
||||
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left
|
||||
expect(expanded).toEqual({
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
const forStmt = expanded.block.statements[1];
|
||||
if (forStmt.kind !== 'for') throw new Error('expected a for statement');
|
||||
const assignInBody = forStmt.body.statements[0];
|
||||
if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
|
||||
// DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
|
||||
// "DOUBLE" remains anywhere in the block
|
||||
expect(assignInBody.value).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'number', value: 0 },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
},
|
||||
left: { kind: 'param', name: 'total' },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
left: { kind: 'param', name: 'x' },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops each accumulate correctly', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'c1',
|
||||
name: 'COMBINED',
|
||||
params: ['sumRange', 'prodRange'],
|
||||
kind: 'block',
|
||||
body:
|
||||
'total = 0 FOR a IN sumRange DO total = total + a ' +
|
||||
'prod = 1 FOR b IN prodRange DO prod = prod * b ' +
|
||||
'RETURN total + prod',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=COMBINED(A1:A2; B1:B2)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'binary', op: '+', left: { kind: 'number', value: 0 }, right: { kind: 'cell', qualifier: null, col: 0, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
},
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'binary', op: '*', left: { kind: 'number', value: 1 }, right: { kind: 'cell', qualifier: null, col: 1, row: 0 } },
|
||||
right: { kind: 'cell', qualifier: null, col: 1, row: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('arity mismatch is still checked for block-kind functions', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR source that is not a cell/range after substitution throws', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
}),
|
||||
]);
|
||||
// x substituted with a NumberNode -> the FOR source becomes "5 + 1", not a cell/range
|
||||
expect(() => expandUserFunctions(parseFormula('=BAD(5)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('a FOR loop over more cells than the unroll cap throws', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
// A1:A20000 -> 20000 cells, well over the 10,000-cell cap
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1:A20000)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: block-kind (FOR loop) user-defined functions', () => {
|
||||
describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined functions', () => {
|
||||
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
@@ -412,7 +321,107 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('exceeding the unroll cap degrades to #ERR at the cell, not a crash', () => {
|
||||
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||
const doubleSum = def({
|
||||
id: 'f1',
|
||||
name: 'DOUBLESUM',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [doubleSum]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
|
||||
});
|
||||
|
||||
it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '2',
|
||||
A2: '3',
|
||||
B1: '10',
|
||||
B2: '20',
|
||||
B9: '=OUTERPROD(A1:A2; B1:B2)',
|
||||
});
|
||||
const outerProd = def({
|
||||
id: 'f1',
|
||||
name: 'OUTERPROD',
|
||||
params: ['r1', 'r2'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [outerProd]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
|
||||
});
|
||||
|
||||
it('IF/ELSE branches on the real, current cell value', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
|
||||
const sign2 = def({
|
||||
id: 'f1',
|
||||
name: 'SIGN2',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [sign2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 0, '-5');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
|
||||
});
|
||||
|
||||
it('comparison operators work inside an IF condition', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
|
||||
const eq2 = def({
|
||||
id: 'f1',
|
||||
name: 'EQ2',
|
||||
params: ['a', 'b'],
|
||||
kind: 'block',
|
||||
body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [eq2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
|
||||
});
|
||||
|
||||
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
|
||||
// A3 is the first cell > 100; A4 must never affect the result
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '1',
|
||||
A2: '2',
|
||||
A3: '999',
|
||||
A4: '4',
|
||||
B9: '=FIRSTOVER(A1:A4; 100)',
|
||||
});
|
||||
const firstOver = def({
|
||||
id: 'f1',
|
||||
name: 'FIRSTOVER',
|
||||
params: ['bereich', 'schwelle'],
|
||||
kind: 'block',
|
||||
body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [firstOver]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
// changing the never-reached A4 doesn't change the (already-found) result
|
||||
wb.setCell('s-umsatz', 0, 3, '99999');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
});
|
||||
|
||||
it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
|
||||
const branchy = def({
|
||||
id: 'f1',
|
||||
name: 'BRANCHY',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [branchy]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
|
||||
wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2);
|
||||
});
|
||||
|
||||
it('exceeding the loop-step cap degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
@@ -428,10 +437,17 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
|
||||
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
|
||||
const wb = new Workbook([umsatz], [noReturn]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user