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:
wkh
2026-09-02 15:37:04 +02:00
co-authored by Claude Sonnet 5
parent fb5915d5be
commit c3fde283c8
16 changed files with 1196 additions and 24 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* User-defined functions: a document-level library of named, parameterised
* formulas (`MARGE(revenue; cost) = (revenue - cost) / revenue`) callable
* from any cell like a built-in (`=MARGE(Umsatz!B2; Umsatz!C2)`).
*
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
* which has no room for a "body formula with its own cell references and a
* parameter environment". Rather than rework the evaluator, a call to a
* user-defined function is *inlined* into the caller's AST at parse time,
* before the graph/evaluator ever see it: the `FuncNode` is replaced by a
* (recursively expanded) copy of the function body, with every `ParamNode`
* swapped for the caller's own argument subtree. After that, the stored AST
* looks exactly as if the user had written the expanded formula by hand --
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
* and correctly pick up cell references that live *only* inside the
* function body, not just the ones passed as arguments.
*/
import { parseFormula, type Node } from './parser';
export interface FunctionDef {
id: string;
/** document-unique, matched case-insensitively (like built-in names). */
name: string;
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=". */
body: string;
}
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
* rejected at definition time (see the store's cycle check), this is only
* a last-resort safety net so a bug there degrades to a cell error rather
* than a stack overflow. */
const MAX_EXPANSION_DEPTH = 32;
export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, FunctionDef> {
return new Map(defs.map((d) => [d.name.toUpperCase(), d]));
}
/**
* Replace every call to a user-defined function in `ast` with a (recursively
* expanded, parameter-substituted) copy of its body. Calls to built-in or
* genuinely unknown names are left untouched -- `evaluate()`'s existing
* `#NAME?` path already covers "unknown function", built-in or not.
*
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
* just structural problems: wrong argument count, or a definition cycle
* `MAX_EXPANSION_DEPTH` didn't catch at save time) on failure; the caller
* (`Workbook.parseCell`) wraps this the same way it already wraps
* `parseFormula` errors, turning it into a `#ERR` cell value.
*/
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
if (index.size === 0) return ast; // fast path: no user functions defined at all
return expand(ast, index, new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
depth: number,
): Node {
switch (node.kind) {
case 'number':
case 'string':
case 'cell':
case 'range':
case 'param':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
case 'binary':
return {
...node,
left: expand(node.left, index, bodyCache, depth),
right: expand(node.right, index, bodyCache, depth),
};
case 'func': {
// expand the arguments regardless of whether this call is itself a
// user function -- a built-in like SUM(...) may still take a
// user-function call as one of its arguments.
const args = node.args.map((a) => expand(a, index, bodyCache, depth));
const def = index.get(node.name.toUpperCase());
if (!def) return { ...node, args };
if (depth >= MAX_EXPANSION_DEPTH) {
throw new Error(`'${def.name}' expansion too deep (likely a definition cycle)`);
}
if (args.length !== def.params.length) {
throw new Error(
`'${def.name}' expects ${def.params.length} argument(s), got ${args.length}`,
);
}
let body = bodyCache.get(def.id);
if (!body) {
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
bodyCache.set(def.id, body);
}
// expand any calls *inside* the body first (it may call other user
// functions -- possibly reusing the same parameter names as this one;
// resolving inside-out like this keeps each function's own parameters
// scoped correctly no matter what the caller's happen to be named),
// then substitute this function's own parameters last.
const expandedBody = expand(body, index, bodyCache, depth + 1);
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
return substitute(expandedBody, bound);
}
}
}
function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
switch (node.kind) {
case 'param':
// always found in practice (arity is checked before this runs, and
// parameter names are exactly what the body was parsed against) --
// the fallback just leaves an unresolved param for evaluate()'s own
// defensive #ERR case rather than assuming it can't happen.
return bound.get(node.name.toUpperCase()) ?? node;
case 'number':
case 'string':
case 'cell':
case 'range':
return node;
case 'unary':
return { ...node, operand: substitute(node.operand, bound) };
case 'binary':
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
case 'func':
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
}
}
/**
* Every distinct function name called anywhere in `ast` (built-in or not --
* callers filter against their own registry). Used by the store to build a
* definition-time call graph between user functions for cycle detection,
* without a second AST-walking implementation alongside `collectRefs`.
*/
export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set()): Set<string> {
switch (ast.kind) {
case 'func':
out.add(ast.name.toUpperCase());
ast.args.forEach((a) => collectCalledFunctionNames(a, out));
break;
case 'binary':
collectCalledFunctionNames(ast.left, out);
collectCalledFunctionNames(ast.right, out);
break;
case 'unary':
collectCalledFunctionNames(ast.operand, out);
break;
default:
break; // number/string/cell/range/param -- no nested calls
}
return out;
}