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:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite-Devserver
|
||||
npm run test # Vitest-Suite (Engine + Store + IO), 146 Tests
|
||||
npm run test # Vitest-Suite (Engine + Store + IO), 178 Tests
|
||||
npm run build # Typecheck + Produktionsbuild
|
||||
npm run lint # ESLint (flat config)
|
||||
```
|
||||
@@ -147,6 +147,40 @@ Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster,
|
||||
der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des
|
||||
Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund.
|
||||
|
||||
### Benutzerdefinierte Funktionen
|
||||
|
||||
Der Button **„𝑓 Funktionen"** in der Symbolleiste öffnet die einzige Stelle, an
|
||||
der eigene, dokumentweite Funktionen angelegt, bearbeitet und gelöscht werden
|
||||
— kein JSON-Handedit, keine Konsole. Danach in jeder Zelle aufrufbar wie ein
|
||||
eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
|
||||
|
||||
- **Liste** zeigt jede Funktion als `NAME(param1; param2)` — exakt die
|
||||
Aufruf-Syntax — mit **Bearb.** (öffnet dasselbe Formular, vorausgefüllt)
|
||||
und **🗑** (löscht nach Bestätigung).
|
||||
- **Formular** (Neu wie Bearbeiten): Name, eine dynamische Liste von
|
||||
Parameter-Feldern (**+ Parameter**, `×` zum Entfernen; auch 0 Parameter
|
||||
sind gültig — dann ist es eine benannte Konstante/Formel), und ein
|
||||
Formel-Feld optisch identisch zur Formelleiste — **ohne** führendes `=`.
|
||||
Ein Parametername verhält sich im Funktionskörper wie eine Zelle mit
|
||||
eigenem Wert; er darf selbst nicht wie ein Zellbezug aussehen (`A1`, `$B2`
|
||||
o. ä. sind ungültig).
|
||||
- **Validierung beim Speichern**, mit **Inline-Fehlermeldung** direkt im
|
||||
Formular (kein Toast, nichts geht verloren): Name-Kollision mit einer
|
||||
eingebauten oder einer anderen benutzerdefinierten Funktion, ungültiger
|
||||
Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions-
|
||||
Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt,
|
||||
bevor je eine `#ERR`-Zelle entsteht.
|
||||
- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte —
|
||||
genau wie beim Umbenennen/Löschen einer Tabelle. Zellen, die die
|
||||
betroffene Funktion aufrufen, zeigen danach `#NAME?`.
|
||||
- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
|
||||
(Inlining, keine Laufzeit-Closures) — siehe
|
||||
[`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die
|
||||
Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung,
|
||||
und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im
|
||||
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
|
||||
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
|
||||
|
||||
### Zell-Formatierung
|
||||
|
||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
||||
@@ -250,15 +284,22 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
|
||||
vier Kombinationen.
|
||||
|
||||
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
|
||||
`MIN`, `MAX`, `COUNT`. Argumente gemischt: Einzelzellen, Bereiche (lokal oder
|
||||
`Name!…`), Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`.
|
||||
`MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten
|
||||
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
|
||||
die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen,
|
||||
Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B.
|
||||
`=SUM(Umsatz!B2:E2; 100; A1)`.
|
||||
|
||||
**Fehlermodell** (propagiert an abhängige Zellen):
|
||||
`#REF` (ungültiger/gelöschter Bezug oder unbekannter Tabellenname), `#DIV/0!`,
|
||||
`#NAME?` (unbekannte Funktion), `#ERR` (Parse-Fehler), `#CIRC` (Zirkelbezug,
|
||||
auch tabellenübergreifend), `#VALUE!` (Typfehler in Arithmetik — *dokumentierte
|
||||
Erweiterung*). Referenz auf leere Zelle = `0` in Arithmetik, von `COUNT` nicht
|
||||
gezählt.
|
||||
`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
|
||||
(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten
|
||||
Funktion), `#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!`
|
||||
(Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere
|
||||
Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen
|
||||
benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er
|
||||
wird schon beim **Speichern** der Funktion im UI abgelehnt (kein neuer
|
||||
Fehlercode nötig).
|
||||
|
||||
---
|
||||
|
||||
@@ -277,7 +318,8 @@ Tabellen aller Blätter.
|
||||
| `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. |
|
||||
| `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. |
|
||||
| `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. |
|
||||
| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`. |
|
||||
| `userFunctions.ts` | Benutzerdefinierte Funktionen: `expandUserFunctions()` setzt einen Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure, Evaluator/Graph sehen danach eine ganz normale AST. |
|
||||
| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`; verdrahtet `userFunctions.ts` direkt nach `parseFormula()`. |
|
||||
|
||||
**Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` →
|
||||
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
|
||||
@@ -359,7 +401,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test` — Vitest, 146 Tests:
|
||||
`npm run test` — Vitest, 178 Tests:
|
||||
|
||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||
@@ -421,6 +463,17 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick
|
||||
in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
|
||||
ohne Formel im Puffer greift nichts.
|
||||
- **Benutzerdefinierte Funktionen** — Engine-Ebene (`userFunctions.spec.ts`):
|
||||
Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert,
|
||||
auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch →
|
||||
`#ERR`, unbekannter Name → `#NAME?`, Rekursionstiefen-Sicherheitsnetz,
|
||||
Abhängigkeits-Edges für Zellen, die nur im Funktionskörper vorkommen (nicht
|
||||
im Aufruf) — inkl. korrekter Neuberechnungs-Reihenfolge. Store-Ebene: Name-/
|
||||
Parameter-Validierung, Kollision mit eingebauten/anderen Funktionen,
|
||||
Zyklus-Ablehnung beim Speichern (direkt und über mehrere Stufen), Umbenennen/
|
||||
Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
|
||||
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
|
||||
v1→v2-Migration.
|
||||
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
||||
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
||||
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
||||
@@ -443,9 +496,14 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
||||
reaktiv), **kein** echter Web Worker.
|
||||
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
|
||||
Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
|
||||
- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche, kein
|
||||
Kopieren/Einfügen von Zellbereichen, keine Undo-Historie, kein Snapping der
|
||||
Canvas-Objekte.
|
||||
- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche (nur
|
||||
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
|
||||
Snapping der Canvas-Objekte.
|
||||
- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
|
||||
Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des
|
||||
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
|
||||
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
|
||||
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const toasts = useToasts();
|
||||
|
||||
const fileMenuOpen = ref(false);
|
||||
const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -232,6 +234,11 @@ function onReset() {
|
||||
<button class="btn accent" @click="insertChart">+ Diagramm</button>
|
||||
<button class="btn accent" @click="insertText">+ Text</button>
|
||||
|
||||
<span class="divider" />
|
||||
<button class="btn" title="Benutzerdefinierte Funktionen" @click="functionsOpen = true">
|
||||
𝑓 Funktionen
|
||||
</button>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<div class="menu-wrap">
|
||||
@@ -323,6 +330,9 @@ function onReset() {
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Benutzerdefinierte Funktionen -->
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||
<p class="about-lead">
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
|
||||
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
|
||||
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
|
||||
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
|
||||
* section for the exact mockups this follows.
|
||||
*/
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
type Mode = 'list' | 'edit';
|
||||
const mode = ref<Mode>('list');
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', params: [''] as string[], body: '' });
|
||||
const formError = ref<string | null>(null);
|
||||
|
||||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||||
// -progress never lingers into the next open.
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
mode.value = 'list';
|
||||
formError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.params = [''];
|
||||
form.body = '';
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const f = store.doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return;
|
||||
editingId.value = id;
|
||||
form.name = f.name;
|
||||
form.params = f.params.length ? [...f.params] : [''];
|
||||
form.body = f.body;
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function addParamField() {
|
||||
form.params.push('');
|
||||
}
|
||||
function removeParamField(i: number) {
|
||||
form.params.splice(i, 1);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
|
||||
const result = editingId.value
|
||||
? store.updateCustomFunction(editingId.value, form.name, params, form.body)
|
||||
: store.addCustomFunction(form.name, params, form.body);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
formError.value = result.error;
|
||||
return;
|
||||
}
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function removeFunction(id: string, name: string) {
|
||||
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
|
||||
store.deleteCustomFunction(id);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
|
||||
@close="close"
|
||||
>
|
||||
<!-- ---- list view ---- -->
|
||||
<template v-if="mode === 'list'">
|
||||
<p v-if="!store.doc.customFunctions.length" class="hint">
|
||||
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
|
||||
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
|
||||
</p>
|
||||
<ul v-else class="fn-list">
|
||||
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
|
||||
<code class="fn-sig">{{ f.name }}({{ f.params.join('; ') }})</code>
|
||||
<span class="fn-actions">
|
||||
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
|
||||
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ---- edit / create form ---- -->
|
||||
<template v-else>
|
||||
<label class="row">
|
||||
Name
|
||||
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
||||
</label>
|
||||
|
||||
<div class="row params-row">
|
||||
<span class="params-label">Parameter</span>
|
||||
<div class="params-list">
|
||||
<div v-for="(_, i) in form.params" :key="i" class="param-field">
|
||||
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
|
||||
<button
|
||||
class="mini remove"
|
||||
title="Parameter entfernen"
|
||||
:disabled="form.params.length <= 1"
|
||||
@click="removeParamField(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button class="mini" @click="addParamField">+ Parameter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="row body-row">
|
||||
Formel
|
||||
<textarea
|
||||
v-model="form.body"
|
||||
class="formula-input"
|
||||
rows="4"
|
||||
placeholder="z. B. (revenue - cost) / revenue"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'list'">
|
||||
<button class="primary" @click="openNew">+ Neue Funktion</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit">Abbrechen</button>
|
||||
<button class="primary" @click="save">Speichern</button>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.fn-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.fn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.fn-sig {
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fn-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input[type='text'] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.param-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.param-field input {
|
||||
width: 92px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.remove {
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.body-row .formula-input {
|
||||
min-height: 30px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.formula-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.form-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -135,5 +135,6 @@ export function createSeedDocument(): DocumentState {
|
||||
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
};
|
||||
}
|
||||
|
||||
+34
-4
@@ -16,8 +16,9 @@ löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
|
||||
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. Eingebaute Namen sind nie überschreibbar. |
|
||||
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt jeden Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
|
||||
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
|
||||
@@ -73,6 +74,35 @@ darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
|
||||
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
|
||||
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
|
||||
|
||||
`parseFormula(src, paramNames?)` akzeptiert einen optionalen zweiten
|
||||
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
|
||||
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
|
||||
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
|
||||
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
|
||||
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
|
||||
|
||||
## Benutzerdefinierte Funktionen
|
||||
|
||||
Kein Laufzeit-Closure, keine Änderung an `evaluator.ts`/`graph.ts`: ein Aufruf
|
||||
einer benutzerdefinierten Funktion wird **beim Parsen der aufrufenden Zelle
|
||||
in deren AST eingesetzt** (Inlining). `userFunctions.ts#expandUserFunctions`
|
||||
läuft direkt nach `parseFormula()` in `workbook.ts#parseCell` und ersetzt
|
||||
jeden `FuncNode`, dessen Name in der Funktions-Bibliothek steht (eingebaute
|
||||
Namen haben Vorrang und werden nie ersetzt), durch eine Kopie des – selbst
|
||||
wieder rekursiv expandierten – Funktionskörpers, mit jedem `ParamNode` durch
|
||||
den zugehörigen Argument-Teilbaum des Aufrufers ersetzt. Danach sieht die
|
||||
gespeicherte AST der Zelle so aus, als wäre die Formel von Hand ausgeschrieben
|
||||
worden – `collectRefs`/`precedentsOf`/`evaluate` laufen unverändert, auch für
|
||||
Zellen, die *nur* im Funktionskörper referenziert werden, nicht im Aufruf.
|
||||
|
||||
Ein Arity-Mismatch oder eine Expansionstiefe über 32 (Sicherheitsnetz gegen
|
||||
einen Definitionszyklus, der eigentliche Schutz ist die Zyklus-Prüfung beim
|
||||
**Speichern** einer Funktion im Store) wirft in `expandUserFunctions` einen
|
||||
`Error`, den `parseCell` genau wie einen `ParseError` zu `#ERR` macht. Ein
|
||||
unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
@@ -104,8 +134,8 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|------------|-----------|
|
||||
| `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`) |
|
||||
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,13 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
}
|
||||
return fn(values);
|
||||
}
|
||||
|
||||
case 'param':
|
||||
// userFunctions.ts's expandUserFunctions() substitutes every ParamNode
|
||||
// away before a cell's AST is ever stored -- this should be unreachable
|
||||
// in practice. Fails as a cell error rather than however an unhandled
|
||||
// case would otherwise behave, matching the engine's "no crash" rule.
|
||||
return new FormulaError('#ERR', `unresolved parameter ${node.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export {
|
||||
literalValue,
|
||||
type TableModel,
|
||||
} from './workbook';
|
||||
export {
|
||||
buildFunctionIndex,
|
||||
expandUserFunctions,
|
||||
collectCalledFunctionNames,
|
||||
type FunctionDef,
|
||||
} from './userFunctions';
|
||||
export {
|
||||
FormulaError,
|
||||
isError,
|
||||
|
||||
+38
-4
@@ -30,6 +30,14 @@
|
||||
*
|
||||
* Precedence, lowest to highest: + - < * / < unary +/- < ^
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
||||
* when parseFormula() is called with a `paramNames` list (used to parse a
|
||||
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare identifier
|
||||
* that doesn't look like a cell address is still a ParseError, exactly as
|
||||
* before.
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
||||
@@ -42,7 +50,8 @@ export type Node =
|
||||
| UnaryNode
|
||||
| FuncNode
|
||||
| CellNode
|
||||
| RangeNode;
|
||||
| RangeNode
|
||||
| ParamNode;
|
||||
|
||||
export interface NumberNode {
|
||||
kind: 'number';
|
||||
@@ -83,12 +92,25 @@ export interface RangeNode {
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
}
|
||||
/** A reference to a user-defined function's own parameter -- only produced
|
||||
* when parseFormula() is given a `paramNames` list (parsing a function
|
||||
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
||||
* userFunctions.ts's expandUserFunctions() before evaluation ever sees it. */
|
||||
export interface ParamNode {
|
||||
kind: 'param';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly toks: Token[]) {}
|
||||
/** Case-insensitively matched (compared upper-cased), like function names
|
||||
* elsewhere in the engine (functions.ts's getFunction()). */
|
||||
private readonly paramNames: ReadonlySet<string>;
|
||||
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
|
||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.toks[this.i];
|
||||
@@ -210,6 +232,13 @@ class Parser {
|
||||
if (!firstTok && a.type !== 'IDENT') {
|
||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||
}
|
||||
// an unqualified name matching a declared parameter -- only reachable
|
||||
// when parseFormula() was given a paramNames list; a param is always a
|
||||
// scalar, so it's never part of a qualifier or a ":"-range, only ever
|
||||
// this single bare token.
|
||||
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
||||
return { kind: 'param', name: a.value };
|
||||
}
|
||||
if (!looksLikeCell(a.value)) {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
@@ -249,8 +278,13 @@ class Parser {
|
||||
* Parse a raw formula. The leading "=" is optional (callers usually strip it).
|
||||
* Throws {@link ParseError} / {@link LexError} on malformed input; the store
|
||||
* turns those into the `#ERR` cell value.
|
||||
*
|
||||
* `paramNames`, when given, is a user-defined function's parameter list --
|
||||
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
||||
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
||||
* cell formula; behaviour is then identical to before this existed.
|
||||
*/
|
||||
export function parseFormula(src: string): Node {
|
||||
export function parseFormula(src: string, paramNames?: readonly string[]): Node {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
@@ -259,7 +293,7 @@ export function parseFormula(src: string): Node {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks).parse();
|
||||
return new Parser(toks, paramNames).parse();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range reference node it contains. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
import { DependencyGraph } from './graph';
|
||||
import { collectRefs, parseFormula, type Node } from './parser';
|
||||
import { expandRange } from './refs';
|
||||
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
|
||||
import {
|
||||
FormulaError,
|
||||
isError,
|
||||
@@ -66,9 +67,12 @@ export class Workbook implements EngineHost {
|
||||
private readonly asts = new Map<string, Node | FormulaError>();
|
||||
/** cellKey -> computed value */
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||
private readonly functionIndex: Map<string, FunctionDef>;
|
||||
|
||||
constructor(tables: TableModel[]) {
|
||||
constructor(tables: TableModel[], functionDefs: FunctionDef[] = []) {
|
||||
this.tables = tables;
|
||||
this.functionIndex = buildFunctionIndex(functionDefs);
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
@@ -150,6 +154,7 @@ export class Workbook implements EngineHost {
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = parseFormula(raw);
|
||||
ast = expandUserFunctions(ast, this.functionIndex);
|
||||
} catch (e) {
|
||||
// a bad formula is a cell-level error, never a crash
|
||||
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type {
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
Sheet,
|
||||
@@ -32,6 +33,30 @@ function localeOrUndef(x: unknown): NumberLocale | undefined {
|
||||
return isNumberLocale(x) ? x : undefined;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed (and in v1 documents entirely) -- and drops any malformed entry
|
||||
* rather than rejecting the whole import over one bad function. */
|
||||
function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: CustomFunction[] = [];
|
||||
for (const f of x) {
|
||||
if (
|
||||
!f ||
|
||||
typeof f !== 'object' ||
|
||||
typeof (f as { id?: unknown }).id !== 'string' ||
|
||||
typeof (f as { name?: unknown }).name !== 'string' ||
|
||||
typeof (f as { body?: unknown }).body !== 'string' ||
|
||||
!Array.isArray((f as { params?: unknown }).params) ||
|
||||
!(f as { params: unknown[] }).params.every((p) => typeof p === 'string')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = f as CustomFunction;
|
||||
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
function colorOrUndef(x: unknown): string | undefined {
|
||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||
@@ -303,6 +328,7 @@ export function validateDocument(x: unknown): DocumentState {
|
||||
: null,
|
||||
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -365,6 +391,7 @@ export function migrateToV2(x: unknown): DocumentState {
|
||||
: null,
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user