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:
@@ -31,8 +31,9 @@ const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
|
||||
partial;
|
||||
const def = (
|
||||
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
|
||||
): FunctionDef => ({ kind: 'expression', ...partial });
|
||||
|
||||
describe('expandUserFunctions (AST-level)', () => {
|
||||
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
||||
@@ -243,3 +244,194 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
||||
});
|
||||
});
|
||||
|
||||
const productBlock = {
|
||||
params: ['bereich'],
|
||||
kind: 'block' as const,
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||
};
|
||||
|
||||
describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
|
||||
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 },
|
||||
},
|
||||
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', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||
def({
|
||||
id: 's1',
|
||||
name: 'SUMDOUBLED',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) 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({
|
||||
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 },
|
||||
},
|
||||
},
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
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', () => {
|
||||
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 })]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
});
|
||||
|
||||
it('recalculates when a cell inside the passed range changes', () => {
|
||||
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 })]);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('exceeding the unroll 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');
|
||||
});
|
||||
|
||||
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user