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
+182 -3
View File
@@ -815,6 +815,164 @@ 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';
it('adds a block-kind function and a call evaluates through the unrolled loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '2');
setCell(s, name, 'A2', '3');
setCell(s, name, 'A3', '4');
const result = s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
expect(typeof result).toBe('string');
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
setCell(s, name, 'B1', '=PRODUCT2(A1:A3)');
expect(val(s, name, 'B1')).toBe(24);
});
it('recalculates when a cell inside the passed range changes', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '2');
setCell(s, name, 'A2', '3');
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
setCell(s, name, 'B1', '=PRODUCT2(A1:A2)');
expect(val(s, name, 'B1')).toBe(6);
setCell(s, name, 'A1', '10');
expect(val(s, name, 'B1')).toBe(30);
});
it('defaults to "expression" when kind is omitted (backward-compatible call signature)', () => {
const s = fresh();
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
expect(s.doc.customFunctions[0].kind).toBe('expression');
});
it('rejects a reserved word (FOR/IN/DO/RETURN) as a parameter name', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['for'], 'for + 1', 'block') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a reserved word as a local/loop variable name', () => {
const s = fresh();
// "do" parses fine as an ordinary name here (DO the keyword only ever
// appears in the one position the FOR grammar expects it) -- this
// specifically exercises the dedicated reserved-word name check, not a
// parser-level keyword clash.
const result = s.addCustomFunction(
'F',
['bereich'],
'do = 1 FOR x IN bereich DO do = do * x RETURN do',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a local/loop variable name that looks like a cell address', () => {
const s = fresh();
const result = s.addCustomFunction(
'F',
['bereich'],
'A1 = 1 FOR x IN bereich DO A1 = A1 * x RETURN A1',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a local name that shadows a parameter', () => {
const s = fresh();
const result = s.addCustomFunction(
'SHADOWTEST',
['bereich'],
'bereich = 1 FOR x IN bereich DO bereich = bereich * x RETURN bereich',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('re-assigning the same local across statements (the accumulator pattern) is not a collision', () => {
const s = fresh();
// "ergebnis" is declared once and re-assigned inside the loop -- exactly
// productBody's shape -- this must save cleanly, not be flagged as a
// duplicate name.
const result = s.addCustomFunction('REASSIGNTEST', ['bereich'], productBody, 'block');
expect(typeof result).toBe('string');
});
it('rejects a FOR source that is structurally not a parameter, cell, or range', () => {
const s = fresh();
const result = s.addCustomFunction(
'F',
['x'],
'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects malformed code (missing RETURN)', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
error: string;
};
expect(result.error).toBeTruthy();
});
it('a block-kind function can call another custom function inside its loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '2');
setCell(s, name, 'A2', '3');
s.addCustomFunction('DOUBLE', ['n'], 'n * 2');
s.addCustomFunction(
'SUMDOUBLED',
['bereich'],
'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
'block',
);
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2);
});
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;
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 };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(1);
});
it('undo/redo covers a block-kind function change', () => {
vi.useFakeTimers();
try {
const s = fresh();
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
vi.advanceTimersByTime(600);
expect(s.doc.customFunctions).toHaveLength(1);
s.undo();
expect(s.doc.customFunctions).toHaveLength(0);
s.redo();
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
} finally {
vi.runOnlyPendingTimers();
vi.useRealTimers();
}
});
});
describe('undo / redo', () => {
// rapid-fire changes (a resize drag, or several store calls in one
// synchronous action) coalesce into a single undo step -- tests that want
@@ -1243,8 +1401,14 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addToSelection(umsatzId, 2, 2);
s.addToSelection(umsatzId, 4, 5);
// a custom function
// a custom function -- one of each kind
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
s.addCustomFunction(
'PRODUCT2',
['bereich'],
'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
'block',
);
return { analyse, umsatzId, chartId, txtId };
}
@@ -1310,13 +1474,15 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
// multi-range selection
expect(doc.selection.ranges).toHaveLength(2);
// custom function
expect(doc.customFunctions).toHaveLength(1);
// custom functions -- one of each kind
expect(doc.customFunctions).toHaveLength(2);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
body: '(revenue - cost) / revenue',
kind: 'expression',
});
expect(doc.customFunctions[1]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
});
it('export -> import is lossless for the fully-loaded document', () => {
@@ -1362,4 +1528,17 @@ describe('v1 -> v2 migration', () => {
const rep = tableId(store, 'Report');
expect(store.cellValue(rep, 0, 0)).toBe(7);
});
it('an older v2 document\'s customFunctions without a "kind" field default to "expression"', () => {
const s = fresh();
const before = s.snapshot();
const legacy = {
...before,
customFunctions: [{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' }], // no "kind"
};
const parsed = parseDocument(JSON.stringify(legacy));
expect(parsed.customFunctions).toEqual([
{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y', kind: 'expression' },
]);
});
});