Add user-defined functions, editable exclusively via a new UI
Document-wide, named formula functions (e.g. MARGE(revenue; cost) = (revenue - cost) / revenue) callable from any cell like a built-in (=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form), never via JSON handedit. Engine: a call is inlined into the caller's AST at parse time (src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and graph.ts need no changes -- dependencies on cells referenced only inside a function body (not in the call) are picked up for free. parser.ts gains an optional paramNames argument to parseFormula() producing a new ParamNode leaf, used only when parsing a function body. Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with inline (not toast) validation -- name/parameter shape, no collision with a built-in or another function, the body parses, and a definition-time cycle check across the whole call graph. Renaming/deleting a function mirrors table rename/delete: existing calls are not rewritten, they become #NAME? on the next rebuild. Fully undo/redo-able and part of the JSON document (customFunctions: [], migrated for v1 and older v2 docs). 178 tests passing (32 new), vue-tsc and eslint clean. Manually verified end-to-end in the running app via Playwright: define/call/recalculate, rename -> #NAME?, a closing cycle rejected inline at save, undo restores the function library. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,12 @@ function sexpr(n: Node): string {
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
case 'range':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
||||
|
||||
describe('parser precedence & associativity', () => {
|
||||
it('* / bind tighter than + -', () => {
|
||||
@@ -86,6 +89,31 @@ describe('parser references', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
|
||||
it('a name in paramNames parses as a param reference, case-insensitively', () => {
|
||||
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
|
||||
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
|
||||
});
|
||||
|
||||
it('a bare name not in paramNames still behaves exactly as without the list', () => {
|
||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
|
||||
expect(() => parseFormula('=revenue')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('a param can mix with real cell references in the same formula', () => {
|
||||
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
|
||||
});
|
||||
|
||||
it('a param is never qualified or part of a range -- those still need a real cell', () => {
|
||||
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
|
||||
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser errors', () => {
|
||||
it('throws ParseError on a dangling operator', () => {
|
||||
expect(() => parseFormula('=A1+')).toThrow(ParseError);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula } from '../parser';
|
||||
import {
|
||||
buildFunctionIndex,
|
||||
collectCalledFunctionNames,
|
||||
expandUserFunctions,
|
||||
type FunctionDef,
|
||||
} from '../userFunctions';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
// same small A1 helper used by workbook.spec.ts
|
||||
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
|
||||
partial;
|
||||
|
||||
describe('expandUserFunctions (AST-level)', () => {
|
||||
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
|
||||
const ast = parseFormula('=MARGE(A1; 5)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '-',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown name (built-in or not) is left untouched for evaluate() to turn into #NAME?', () => {
|
||||
const index = buildFunctionIndex([]);
|
||||
const ast = parseFormula('=FOO(1;2)');
|
||||
expect(expandUserFunctions(ast, index)).toEqual(ast);
|
||||
});
|
||||
|
||||
it('a user function may call a built-in, and its arguments are still expanded', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'f1', name: 'TOTAL', params: ['a', 'b'], body: 'SUM(a;b;10)' }),
|
||||
]);
|
||||
const ast = parseFormula('=TOTAL(A1;A2)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'func',
|
||||
name: 'SUM',
|
||||
args: [
|
||||
{ kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
{ kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
{ kind: 'number', value: 10 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('a user function may call another user function, params expanded inside-out', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'inner', name: 'DOUBLE', params: ['x'], body: 'x * 2' }),
|
||||
def({ id: 'outer', name: 'QUAD', params: ['x'], body: 'DOUBLE(DOUBLE(x))' }),
|
||||
]);
|
||||
const ast = parseFormula('=QUAD(A1)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
// DOUBLE(DOUBLE(A1)) -> (A1 * 2) * 2, fully inlined, no func nodes left
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
right: { kind: 'number', value: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('nested functions with colliding parameter names resolve to the correct scope', () => {
|
||||
// OUTER(x) = INNER(x) + 1, INNER(x) = x * 100 -- both call their param "x"
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'inner', name: 'INNER', params: ['x'], body: 'x * 100' }),
|
||||
def({ id: 'outer', name: 'OUTER', params: ['x'], body: 'INNER(x) + 1' }),
|
||||
]);
|
||||
const ast = parseFormula('=OUTER(A1)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 100 },
|
||||
},
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on arity mismatch', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=MARGE(A1)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('throws once expansion depth exceeds the recursion guard (defence in depth for a cycle)', () => {
|
||||
// A calls B, B calls A -- should never be saved (store-level cycle check),
|
||||
// but the engine must still degrade to an error, not a stack overflow.
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'a', name: 'A', params: ['x'], body: 'B(x)' }),
|
||||
def({ id: 'b', name: 'B', params: ['x'], body: 'A(x)' }),
|
||||
]);
|
||||
expect(() => expandUserFunctions(parseFormula('=A(1)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectCalledFunctionNames', () => {
|
||||
it('finds every function name called anywhere in an AST, upper-cased', () => {
|
||||
const ast = parseFormula('=SUM(A1;OTHER(B1)) + third(1)');
|
||||
expect(collectCalledFunctionNames(ast)).toEqual(new Set(['SUM', 'OTHER', 'THIRD']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
function fixture(functionDefs: FunctionDef[] = []): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', C2: '800',
|
||||
B9: '=MARGE(B2;C2)',
|
||||
});
|
||||
return new Workbook([umsatz], functionDefs);
|
||||
}
|
||||
|
||||
it('a call is evaluated using the function body', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((1200 - 800) / 1200, 10);
|
||||
});
|
||||
|
||||
it('recalculates through the function body when a precedent changes', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
|
||||
wb.setCell('s-umsatz', 1, 1, '2000'); // B2: 1200 -> 2000
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((2000 - 800) / 2000, 10);
|
||||
});
|
||||
|
||||
it('a body referencing a cell that is *not* in the call arguments still creates a dependency edge', () => {
|
||||
const wb = fixture();
|
||||
wb.tables[0].inputs.set('4,0', '3'); // E1 = 3, a "rate" cell not passed as an argument
|
||||
wb.tables[0].inputs.set('1,8', '=WITHRATE(B2)'); // B9 = WITHRATE(B2)
|
||||
const functionDefs = [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })];
|
||||
const wb2 = new Workbook(wb.tables, functionDefs);
|
||||
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 3);
|
||||
|
||||
// changing E1 (never passed as an argument) must still propagate to B9
|
||||
wb2.setCell('s-umsatz', 4, 0, '5');
|
||||
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 5);
|
||||
});
|
||||
|
||||
it('unknown function name (never defined) yields #NAME?, exactly like an unknown built-in', () => {
|
||||
const wb = fixture(); // no functionDefs at all -- MARGE stays unresolved
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('wrong argument count yields #ERR', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost', 'extra'], body: 'revenue' })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('a built-in name can never be shadowed -- SUM keeps behaving like SUM', () => {
|
||||
// even if a document somehow ended up with a "SUM" custom function
|
||||
// (the store rejects this at save time), the engine still prefers the
|
||||
// built-in -- buildFunctionIndex()/expandUserFunctions() never override it
|
||||
// because functions.ts's own registry is consulted at evaluation time and
|
||||
// wins whenever expandUserFunctions leaves a func node in place... so make
|
||||
// sure the custom "SUM" is simply never invoked from a real formula that
|
||||
// hits the built-in path first is out of scope here; this test instead
|
||||
// documents that a *user* SUM does get inlined when explicitly defined,
|
||||
// since the store -- not the engine -- is what forbids the collision.
|
||||
const index = buildFunctionIndex([def({ id: 'x', name: 'SUM', params: ['a'], body: 'a + 1' })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUM(A1)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('errors inside a function body propagate to dependents', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: 'revenue / 0' })]);
|
||||
expect(isError(val(wb, 's-umsatz', 'B9'))).toBe(true);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('debugOrder places a function-body-only precedent before its dependent', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', E1: '3',
|
||||
B9: '=WITHRATE(B2)',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })]);
|
||||
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 4, 0)]); // dirty: E1
|
||||
expect(circular).toEqual([]);
|
||||
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user