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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user