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
+134
View File
@@ -677,6 +677,128 @@ describe('fill handle (drag to copy/fill)', () => {
});
});
describe('custom functions (document-level library)', () => {
it('addCustomFunction stores the definition and a call evaluates through it', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '1200');
setCell(s, name, 'A2', '800');
const result = s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
expect(typeof result).toBe('string'); // the new function's id, not { error }
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'MARGE', params: ['revenue', 'cost'] });
setCell(s, name, 'A3', '=MARGE(A1;A2)');
expect(val(s, name, 'A3')).toBeCloseTo((1200 - 800) / 1200, 10);
// recalculates when a precedent changes
setCell(s, name, 'A1', '2000');
expect(val(s, name, 'A3')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('rejects a name colliding with a built-in function', () => {
const s = fresh();
const result = s.addCustomFunction('SUM', ['x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a name colliding with another custom function (case-insensitive)', () => {
const s = fresh();
s.addCustomFunction('Marge', ['x'], 'x');
const result = s.addCustomFunction('MARGE', ['y'], 'y') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(1);
});
it('rejects a name or parameter that looks like a cell address', () => {
const s = fresh();
expect((s.addCustomFunction('A1', ['x'], 'x') as { error: string }).error).toBeTruthy();
expect((s.addCustomFunction('OK', ['B2'], 'B2') as { error: string }).error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects duplicate parameter names', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x', 'x'], 'x') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a body that fails to parse', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['x'], 'x +') as { error: string };
expect(result.error).toBeTruthy();
});
it('rejects a direct self-reference cycle (A calls A)', () => {
const s = fresh();
const result = s.addCustomFunction('A', ['x'], 'A(x) + 1') as { error: string };
expect(result.error).toBeTruthy();
expect(s.doc.customFunctions).toHaveLength(0);
});
it('rejects a mutual cycle (A calls B, B calls A) at the point it would close', () => {
const s = fresh();
const a = s.addCustomFunction('A', ['x'], 'x') as string;
expect(typeof a).toBe('string');
// B calling A is fine so far -- no cycle yet
const b = s.addCustomFunction('B', ['x'], 'A(x)') as string;
expect(typeof b).toBe('string');
// now redefine A to call B -- this closes the cycle A -> B -> A
const result = s.updateCustomFunction(a, 'A', ['x'], 'B(x)') as { error: string } | undefined;
expect(result?.error).toBeTruthy();
// rejected -- A's body is unchanged
expect(s.doc.customFunctions.find((f) => f.id === a)!.body).toBe('x');
});
it('updateCustomFunction changes name/params/body and existing calls re-resolve', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.updateCustomFunction(fnId, 'MARGE', ['x', 'y'], 'x + y');
expect(val(s, name, 'A1')).toBe(13);
});
it('deleteCustomFunction removes the definition; existing calls become #NAME?', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
setCell(s, name, 'A1', '=MARGE(10;3)');
expect(val(s, name, 'A1')).toBe(7);
s.deleteCustomFunction(fnId);
expect(s.doc.customFunctions).toHaveLength(0);
expect(isError(val(s, name, 'A1'))).toBe(true);
});
it('undo/redo covers a custom-function change', () => {
vi.useFakeTimers();
try {
const s = fresh();
expect(s.canUndo.value).toBe(false);
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
vi.advanceTimersByTime(600);
expect(s.doc.customFunctions).toHaveLength(1);
s.undo();
expect(s.doc.customFunctions).toHaveLength(0);
s.redo();
expect(s.doc.customFunctions).toHaveLength(1);
expect(s.doc.customFunctions[0].name).toBe('MARGE');
} 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
@@ -1023,6 +1145,9 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addToSelection(umsatzId, 2, 2);
s.addToSelection(umsatzId, 4, 5);
// a custom function
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
return { analyse, umsatzId, chartId, txtId };
}
@@ -1086,6 +1211,14 @@ 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);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
body: '(revenue - cost) / revenue',
});
});
it('export -> import is lossless for the fully-loaded document', () => {
@@ -1124,6 +1257,7 @@ describe('v1 -> v2 migration', () => {
expect(v2.version).toBe(2);
expect(v2.sheets).toHaveLength(2);
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
expect(v2.customFunctions).toEqual([]); // the feature didn't exist in v1
// cross-sheet formula still resolves after loading it
const store = useDocument();
store.replaceDocument(v2);
+159 -3
View File
@@ -25,9 +25,13 @@ import {
tokenize,
looksLikeCell,
parseA1,
parseFormula,
hasFunction,
collectCalledFunctionNames,
type CellValue,
type TableModel,
type Token,
type FunctionDef,
} from '../engine';
import {
formatValue,
@@ -40,6 +44,7 @@ import type {
CellCoord,
CellFormat,
ChartObject,
CustomFunction,
DocumentState,
Sheet,
SelectionState,
@@ -76,6 +81,10 @@ function toTableModels(doc: DocumentState): TableModel[] {
}));
}
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body }));
}
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
type Rect = { c0: number; c1: number; r0: number; r1: number };
@@ -182,7 +191,7 @@ function loadInitial(): DocumentState {
function createStore() {
const doc = reactive<DocumentState>(loadInitial());
let wb = new Workbook(toTableModels(doc));
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
const recalcTick = ref(0);
const bump = () => (recalcTick.value += 1);
@@ -202,7 +211,7 @@ function createStore() {
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() {
wb = new Workbook(toTableModels(doc));
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
bump();
}
@@ -247,7 +256,12 @@ function createStore() {
const undoStack: DocumentState[] = [];
const redoStack: DocumentState[] = [];
const contentFingerprint = (d: DocumentState) =>
JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter });
JSON.stringify({
sheets: d.sheets,
locale: d.locale,
csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions,
});
let historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot);
let historyTimer: ReturnType<typeof setTimeout> | undefined;
@@ -618,6 +632,142 @@ function createStore() {
else bump();
}
// ---- custom functions (document-level formula library) ----------------
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
// existing formula text is left as-is and becomes #NAME? / #REF on the next
// syncWorkbook(), there is no find-and-replace across stored formulas.
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
/** Every (upper-cased) function name a stored custom function's body calls,
* tolerant of a body that -- in theory -- no longer parses (shouldn't
* happen; a save always validates first, but this is only used for a
* best-effort cycle check, so fail open rather than block unrelated saves). */
function bodyCallSet(params: string[], body: string): Set<string> {
try {
return collectCalledFunctionNames(parseFormula(body, params));
} catch {
return new Set();
}
}
/** Does the call graph across every custom function (built-ins are never
* nodes -- they can't call back into user functions) contain a cycle? */
function callGraphHasCycle(graph: Map<string, Set<string>>): boolean {
const state = new Map<string, 1 | 2>(); // 1 = on the current DFS path, 2 = fully explored
function dfs(node: string): boolean {
const st = state.get(node);
if (st === 1) return true; // back-edge -> cycle
if (st === 2) return false;
state.set(node, 1);
for (const callee of graph.get(node) ?? []) {
if (graph.has(callee) && dfs(callee)) return true;
}
state.set(node, 2);
return false;
}
for (const node of graph.keys()) {
if (dfs(node)) return true;
}
return false;
}
/**
* Full validation for add/update: name & parameter shape, no collision with
* a built-in or another custom function, the body parses, and -- simulating
* the *whole* call graph with this function's (possibly new) definition in
* place -- no cycle. Returns a user-facing (German) error string, or null
* when the definition is safe to save. `ignoreId` excludes the function
* being edited from the collision/graph checks against its own old state.
*/
function validateCustomFunction(
name: string,
params: string[],
body: string,
ignoreId: string | null,
): string | null {
const cleanName = name.trim();
if (!IDENT_RE.test(cleanName)) {
return `"${name}" ist kein gültiger Funktionsname.`;
}
if (looksLikeCell(cleanName)) {
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`;
}
if (hasFunction(cleanName)) {
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
}
const collision = doc.customFunctions.find(
(f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(),
);
if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`;
const cleanParams = params.map((p) => p.trim());
for (const p of cleanParams) {
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
}
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
return 'Parameternamen müssen eindeutig sein.';
}
let ast;
try {
ast = parseFormula(body, cleanParams);
} catch (e) {
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
}
const graph = new Map<string, Set<string>>();
for (const f of doc.customFunctions) {
if (f.id === ignoreId) continue;
graph.set(f.name.toUpperCase(), bodyCallSet(f.params, f.body));
}
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
if (callGraphHasCycle(graph)) {
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
}
return null;
}
function addCustomFunction(name: string, params: string[], body: string): string | { error: string } {
const error = validateCustomFunction(name, params, body, null);
if (error) return { error };
const id = uid('fn');
const custom: CustomFunction = {
id,
name: name.trim(),
params: params.map((p) => p.trim()),
body: body.startsWith('=') ? body.slice(1) : body,
};
doc.customFunctions.push(custom);
syncWorkbook();
return id;
}
function updateCustomFunction(
id: string,
name: string,
params: string[],
body: string,
): void | { error: string } {
const f = doc.customFunctions.find((x) => x.id === id);
if (!f) return { error: 'Funktion nicht gefunden.' };
const error = validateCustomFunction(name, params, body, id);
if (error) return { error };
f.name = name.trim();
f.params = params.map((p) => p.trim());
f.body = body.startsWith('=') ? body.slice(1) : body;
syncWorkbook();
}
function deleteCustomFunction(id: string) {
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
}
function moveObject(objId: string, x: number, y: number) {
const o = objectById(objId);
if (!o) return;
@@ -1299,6 +1449,7 @@ function createStore() {
doc.selection = next.selection ?? null;
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
doc.customFunctions = next.customFunctions ?? [];
syncWorkbook();
}
/** Loading a different document outright (JSON import "replace", "reset to
@@ -1388,6 +1539,11 @@ function createStore() {
renameObject,
deleteObject,
moveObject,
// custom functions
customFunctionNames,
addCustomFunction,
updateCustomFunction,
deleteCustomFunction,
resizeChart,
bringToFront,
setChartView,
+15
View File
@@ -149,6 +149,20 @@ export interface SelectionState {
ranges?: SelectionRange[];
}
/** A document-wide, user-defined formula function, callable from any cell
* like a built-in (e.g. `=MARGE(Umsatz!B2; Umsatz!C2)`). Created, edited and
* removed exclusively through the "𝑓 Funktionen" UI -- see
* src/engine/userFunctions.ts for how a call is expanded into a cell's AST. */
export interface CustomFunction {
id: string;
/** document-unique, compared case-insensitively; never a built-in name. */
name: string;
/** parameter names, in call order; may be empty (a plain named formula). */
params: string[];
/** raw formula text, no leading "=". */
body: string;
}
export interface DocumentState {
version: 2;
sheets: Sheet[];
@@ -157,4 +171,5 @@ export interface DocumentState {
selection: SelectionState | null;
locale: NumberLocale;
csvDelimiter: ',' | ';';
customFunctions: CustomFunction[];
}