Add FOR-loop "code" bodies to user-defined functions (kind: 'block')
A custom function body could previously only be a single expression -- no way to iterate over a range's elements to build something like PRODUCT (multiply every cell together), since none of the built-ins (SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing functions, not define a new reduction. This adds a second body kind, 'block': a sequence of `name = expression` assignments and `FOR var IN source DO name = expression` loops, ending in `RETURN expression`. Flat scope -- every introduced name stays visible for the rest of the body, including after its own loop, holding its last iteration's value. No runtime loop in the evaluator: a loop's source size is already known once the caller's real argument is substituted in, so userFunctions.ts's new compileBlock() unrolls every FOR loop into a single nested expression (a left fold) at the same parse-time inlining point Phase 1 already used -- the result is exactly the same shape of static AST as any other inlined function call, so evaluator.ts and graph.ts need no changes, dependency edges on every unrolled cell are picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps it crash/hang-free. parser.ts's parseFunctionBlock() parses the new grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are plain case-matched IDENTs, not new reserved tokens) into a BlockNode kept outside the existing Node union, so no existing switch needs a new case. Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'` (defaulted for every function saved before this existed, including via JSON import). validateCustomFunction validates the new grammar's local/ loop-variable names the same way as parameters (not cell-shaped, no reserved words, no shadowing a parameter -- but reusing the same local across statements, the accumulator pattern, is correctly not a collision) and that every FOR source is structurally a parameter, cell, or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel" and "Mit Schleife", with its own placeholder/hint (a PRODUCT example) and a small badge on block-kind rows in the list. 237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live in the running app: defined PRODUCT via the loop UI, called =PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation on a change to a cell inside the range, and an inline rejection for an invalid FOR source at save time. 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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
npm run dev # Vite-Devserver
|
||||||
npm run test # Vitest-Suite (Engine + Store + IO), 200 Tests
|
npm run test # Vitest-Suite (Engine + Store + IO), 237 Tests
|
||||||
npm run build # Typecheck + Produktionsbuild
|
npm run build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -193,6 +193,48 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
|
|||||||
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
|
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
|
||||||
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
|
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
|
||||||
|
|
||||||
|
#### Mit Schleife: FOR-Code statt einer einzelnen Formel
|
||||||
|
|
||||||
|
Der „Art"-Umschalter im Formular bietet neben **„Einfache Formel"** (s. o.)
|
||||||
|
auch **„Mit Schleife"** — echter Code statt eines einzelnen Ausdrucks, für
|
||||||
|
Fälle, die eine einzelne Formel nicht ausdrücken kann: über die Elemente
|
||||||
|
eines Bereichs selbst iterieren (z. B. ein Produkt aller Zellen, nicht nur
|
||||||
|
Summe/Mittel/Min/Max). Das Code-Feld akzeptiert eine Folge aus:
|
||||||
|
|
||||||
|
- **Zuweisungen** — `name = ausdruck`.
|
||||||
|
- **FOR-Schleifen** — `FOR var IN bereich DO name = ausdruck`; `bereich` ist
|
||||||
|
ein Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck).
|
||||||
|
|
||||||
|
— beliebig oft, beliebig gemischt, zuletzt **`RETURN ausdruck`**. Jeder
|
||||||
|
eingeführte Name (Zuweisungsziel oder Schleifenvariable) bleibt für den
|
||||||
|
**restlichen** Code sichtbar, auch nach dem Ende „seiner" Schleife (mit dem
|
||||||
|
Wert der letzten Iteration) — flacher Namensraum, kein Block-Scoping.
|
||||||
|
`FOR`/`IN`/`DO`/`RETURN` sind reservierte Wörter, nicht als Parameter-/
|
||||||
|
Variablenname erlaubt.
|
||||||
|
|
||||||
|
Beispiel — Produkt aller Elemente eines Bereichs (`PRODUCT` gibt es nicht als
|
||||||
|
eingebaute Funktion, nur `SUM`/`AVG`/`MIN`/`MAX`/`COUNT`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ergebnis = 1
|
||||||
|
FOR x IN bereich DO ergebnis = ergebnis * x
|
||||||
|
RETURN ergebnis
|
||||||
|
```
|
||||||
|
|
||||||
|
aufgerufen als `=PRODUCT(Umsatz!B2:E6)`.
|
||||||
|
|
||||||
|
**Keine echte Laufzeit-Schleife im Evaluator:** Da die Größe eines Bereichs
|
||||||
|
beim Aufruf bereits feststeht, wird eine `FOR`-Schleife beim Einsetzen des
|
||||||
|
Funktionsaufrufs vollständig **abgerollt** — jede Iteration wird zu einem
|
||||||
|
eigenen, ineinander verschachtelten Ausdrucks-Teilbaum (ein Links-Fold). Am
|
||||||
|
Ende steht wieder eine ganz normale, statische Formel-AST — Evaluator und
|
||||||
|
Abhängigkeitsgraph sehen keinen Unterschied zu einer von Hand ausgeschriebenen
|
||||||
|
Formel, Neuberechnung bei Änderung *jeder* im Bereich enthaltenen Zelle
|
||||||
|
funktioniert automatisch korrekt. Kein Endlos-Risiko: mehr als insgesamt
|
||||||
|
10.000 abgerollte Zellen (über alle Schleifen einer Funktion) ergeben `#ERR`
|
||||||
|
statt zu hängen. Details: [`src/engine/README.md`](src/engine/README.md)s
|
||||||
|
„Schleifen"-Abschnitt.
|
||||||
|
|
||||||
### Zell-Formatierung
|
### Zell-Formatierung
|
||||||
|
|
||||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
||||||
@@ -445,7 +487,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 200 Tests:
|
`npm run test` — Vitest, 237 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
@@ -521,6 +563,30 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
|
Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
|
||||||
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
|
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
|
||||||
v1→v2-Migration.
|
v1→v2-Migration.
|
||||||
|
- **„Mit Schleife" (FOR-Code)** — Parser-Ebene (`parseFunctionBlock`):
|
||||||
|
eine Schleife (Akkumulator-Muster), mehrere unabhängige Schleifen
|
||||||
|
nacheinander, mehrere Zuweisungen vor/zwischen Schleifen, ein Name aus
|
||||||
|
einer früheren Anweisung in einer späteren Schleife nutzbar; Fehler bei
|
||||||
|
fehlendem `RETURN`, `FOR` ohne `DO`/`IN`, fehlendem `=`, Selbstbezug im
|
||||||
|
eigenen Initialisierer. Engine-Ebene (`compileBlock`): korrektes Abrollen
|
||||||
|
(ein Bereich → verschachtelte Ausdrücke in Zellreihenfolge), ein einzelner
|
||||||
|
Zellbezug als Quelle → genau eine Iteration, die Schleifenvariable/das
|
||||||
|
letzte Zuweisungsziel bleiben danach mit ihrem letzten Wert sichtbar,
|
||||||
|
verschachtelter Aufruf einer anderen Funktion im Schleifenkörper wird mit-
|
||||||
|
expandiert, Arity-Prüfung gilt auch für „Mit Schleife"-Funktionen, eine
|
||||||
|
FOR-Quelle, die nach Parametersubstitution kein Zell-/Bereichsbezug ist,
|
||||||
|
wirft, mehr als 10.000 abgerollte Zellen wirft. Workbook-Integration:
|
||||||
|
korrekter Wert und Neuberechnung bei Änderung einer Zelle *innerhalb* des
|
||||||
|
übergebenen Bereichs, Überschreitung des Abroll-Limits und eine ungültige
|
||||||
|
FOR-Quelle ergeben `#ERR` statt eines Absturzes. Store-Ebene: reservierte
|
||||||
|
Wörter (`FOR`/`IN`/`DO`/`RETURN`) als Parameter- oder lokaler Name
|
||||||
|
abgelehnt, zellartiger lokaler Name abgelehnt, Wiederverwendung desselben
|
||||||
|
lokalen Namens über mehrere Anweisungen (das Akkumulator-Muster) ist
|
||||||
|
**keine** Kollision, ein lokaler Name, der einen Parameter überschattet,
|
||||||
|
wird abgelehnt, eine berechnete FOR-Quelle wird abgelehnt, Funktions-Zyklus
|
||||||
|
durch einen Schleifenkörper wird erkannt, Undo/Redo, `kind` im JSON-
|
||||||
|
Round-Trip, ein älteres gespeichertes Dokument ohne `kind`-Feld defaultet
|
||||||
|
auf `'expression'`.
|
||||||
- **Blatt-Qualifier „Blatt!Tabelle::Zelle"** — Parser-Ebene: die explizite
|
- **Blatt-Qualifier „Blatt!Tabelle::Zelle"** — Parser-Ebene: die explizite
|
||||||
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
|
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
|
||||||
Rückwärtskompatibilität der 2-teiligen Form (`X!Y`, `X::Y`, auch ein
|
Rückwärtskompatibilität der 2-teiligen Form (`X!Y`, `X::Y`, auch ein
|
||||||
@@ -564,6 +630,10 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
|
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
|
||||||
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
|
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
|
||||||
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
|
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
|
||||||
|
- **„Mit Schleife"-Code** bewusst ohne: verschachtelte Schleifen (`FOR`
|
||||||
|
innerhalb einer `DO`-Klausel), bedingte Anweisungen (`IF`), echtes
|
||||||
|
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar),
|
||||||
|
frühzeitiges `RETURN` mitten in der Anweisungsfolge (nur am Ende erlaubt).
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ const emit = defineEmits<{ (e: 'close'): void }>();
|
|||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
|
|
||||||
type Mode = 'list' | 'edit';
|
type Mode = 'list' | 'edit';
|
||||||
|
type Kind = 'expression' | 'block';
|
||||||
const mode = ref<Mode>('list');
|
const mode = ref<Mode>('list');
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const form = reactive({ name: '', params: [''] as string[], body: '' });
|
const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
|
||||||
const formError = ref<string | null>(null);
|
const formError = ref<string | null>(null);
|
||||||
|
|
||||||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||||||
@@ -38,6 +39,7 @@ function openNew() {
|
|||||||
form.name = '';
|
form.name = '';
|
||||||
form.params = [''];
|
form.params = [''];
|
||||||
form.body = '';
|
form.body = '';
|
||||||
|
form.kind = 'expression';
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
mode.value = 'edit';
|
mode.value = 'edit';
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@ function openEdit(id: string) {
|
|||||||
form.name = f.name;
|
form.name = f.name;
|
||||||
form.params = f.params.length ? [...f.params] : [''];
|
form.params = f.params.length ? [...f.params] : [''];
|
||||||
form.body = f.body;
|
form.body = f.body;
|
||||||
|
form.kind = f.kind;
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
mode.value = 'edit';
|
mode.value = 'edit';
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,8 @@ function removeParamField(i: number) {
|
|||||||
function save() {
|
function save() {
|
||||||
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
|
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
|
||||||
const result = editingId.value
|
const result = editingId.value
|
||||||
? store.updateCustomFunction(editingId.value, form.name, params, form.body)
|
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
|
||||||
: store.addCustomFunction(form.name, params, form.body);
|
: store.addCustomFunction(form.name, params, form.body, form.kind);
|
||||||
if (result && typeof result === 'object' && 'error' in result) {
|
if (result && typeof result === 'object' && 'error' in result) {
|
||||||
formError.value = result.error;
|
formError.value = result.error;
|
||||||
return;
|
return;
|
||||||
@@ -103,7 +106,10 @@ function close() {
|
|||||||
</p>
|
</p>
|
||||||
<ul v-else class="fn-list">
|
<ul v-else class="fn-list">
|
||||||
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
|
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
|
||||||
<code class="fn-sig">{{ f.name }}({{ f.params.join('; ') }})</code>
|
<code class="fn-sig">
|
||||||
|
<span v-if="f.kind === 'block'" class="fn-badge" title="Mit Schleife (Code)">↻</span>
|
||||||
|
{{ f.name }}({{ f.params.join('; ') }})
|
||||||
|
</code>
|
||||||
<span class="fn-actions">
|
<span class="fn-actions">
|
||||||
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
|
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
|
||||||
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
|
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
|
||||||
@@ -119,6 +125,22 @@ function close() {
|
|||||||
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="params-label">Art</span>
|
||||||
|
<div class="kind-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ on: form.kind === 'expression' }"
|
||||||
|
@click="form.kind = 'expression'"
|
||||||
|
>
|
||||||
|
Einfache Formel
|
||||||
|
</button>
|
||||||
|
<button type="button" :class="{ on: form.kind === 'block' }" @click="form.kind = 'block'">
|
||||||
|
Mit Schleife
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row params-row">
|
<div class="row params-row">
|
||||||
<span class="params-label">Parameter</span>
|
<span class="params-label">Parameter</span>
|
||||||
<div class="params-list">
|
<div class="params-list">
|
||||||
@@ -138,15 +160,28 @@ function close() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="row body-row">
|
<label class="row body-row">
|
||||||
Formel
|
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.body"
|
v-model="form.body"
|
||||||
class="formula-input"
|
class="formula-input"
|
||||||
rows="4"
|
:rows="form.kind === 'block' ? 5 : 4"
|
||||||
placeholder="z. B. (revenue - cost) / revenue"
|
:placeholder="
|
||||||
|
form.kind === 'block'
|
||||||
|
? 'ergebnis = 1\nFOR x IN bereich DO ergebnis = ergebnis * x\nRETURN ergebnis'
|
||||||
|
: 'z. B. (revenue - cost) / revenue'
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="hint">
|
<p v-if="form.kind === 'block'" class="hint">
|
||||||
|
Eine Folge aus <strong>Zuweisungen</strong> (<code>name = ausdruck</code>)
|
||||||
|
und <strong>FOR-Schleifen</strong> (<code>FOR var IN bereich DO name = ausdruck</code>),
|
||||||
|
zuletzt <code>RETURN ausdruck</code>. Jeder eingeführte Name bleibt für
|
||||||
|
den Rest des Codes sichtbar. Beispiel — Produkt aller Elemente eines
|
||||||
|
Bereichs:
|
||||||
|
<code>ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x / RETURN ergebnis</code>,
|
||||||
|
aufgerufen als <code>=PRODUCT(Umsatz!B2:E6)</code>.
|
||||||
|
</p>
|
||||||
|
<p v-else class="hint">
|
||||||
Ein Parameter kann auch ein <strong>Bereich</strong> sein, solange er
|
Ein Parameter kann auch ein <strong>Bereich</strong> sein, solange er
|
||||||
direkt an eine Funktion wie <code>SUM</code>/<code>AVG</code>/
|
direkt an eine Funktion wie <code>SUM</code>/<code>AVG</code>/
|
||||||
<code>MIN</code>/<code>MAX</code> übergeben wird — z. B.
|
<code>MIN</code>/<code>MAX</code> übergeben wird — z. B.
|
||||||
@@ -203,6 +238,33 @@ function close() {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.fn-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 3px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.kind-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.kind-toggle button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kind-toggle button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.kind-toggle button.on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.fn-actions {
|
.fn-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
+80
-2
@@ -20,7 +20,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|
|||||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
| `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`). |
|
| `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) }`. Eingebaute Namen sind nie überschreibbar. |
|
| `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. |
|
| `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; `compileBlock()` rollt zusätzlich `kind: 'block'`-Funktionskörper (FOR-Schleifen) zu einem einzigen Ausdruck ab, siehe „Schleifen" unten. |
|
||||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. |
|
| `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()`. |
|
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||||
|
|
||||||
@@ -147,6 +147,84 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
|||||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||||
|
|
||||||
|
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||||
|
|
||||||
|
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||||
|
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
|
||||||
|
unverändert bleibt):
|
||||||
|
|
||||||
|
```ebnf
|
||||||
|
functionBlock = { statement } "RETURN" expression ;
|
||||||
|
statement = assignment | forLoop ;
|
||||||
|
assignment = IDENT "=" expression ;
|
||||||
|
forLoop = "FOR" IDENT "IN" expression "DO" assignment ;
|
||||||
|
```
|
||||||
|
|
||||||
|
`FOR`/`IN`/`DO`/`RETURN` sind – wie Funktionsnamen – gewöhnliche `IDENT`-
|
||||||
|
Token, case-insensitiv per Wortvergleich erkannt (`Parser.isKeyword`), kein
|
||||||
|
eigener Lexer-Token dafür nötig; `=` als Zuweisung ist ein neuer, rein
|
||||||
|
additiver `EQUALS`-Token (vorher ein `LexError`, kann also keine vorher
|
||||||
|
gültige Formel beeinflussen). `parseFunctionBlock(src, paramNames)`
|
||||||
|
(`parser.ts`) parst sequenziell: jeder eingeführte Name (Zuweisungsziel oder
|
||||||
|
Schleifenvariable) wird sofort zu `Parser.paramNames` hinzugefügt – **flacher
|
||||||
|
Namensraum**, kein Scope-Stack, ein Name bleibt für den *Rest* des Blocks
|
||||||
|
sichtbar (auch nach seiner eigenen Schleife). Ergebnis ist ein `BlockNode`
|
||||||
|
(`statements: (AssignNode | ForNode)[]`, `ret: Node`) – bewusst **nicht**
|
||||||
|
Teil des `Node`-Unions, dadurch braucht keine bestehende
|
||||||
|
`switch (node.kind)`-Stelle (Evaluator, `collectRefs`, `substitute`) einen
|
||||||
|
neuen Fall.
|
||||||
|
|
||||||
|
**Keine Laufzeit-Schleife.** `userFunctions.ts#compileBlock(block, params,
|
||||||
|
args)` kompiliert einen Block – mit den *echten* Argumenten des Aufrufers,
|
||||||
|
nicht mehr symbolisch – zu einem einzigen Ausdrucks-`Node`, bevor er in
|
||||||
|
`expand()`s `func`-Fall landet:
|
||||||
|
|
||||||
|
1. `bound`-Umgebung startet mit den Parametern (Name → Argument-`Node`).
|
||||||
|
2. Jede `assignment` ersetzt ihren Wert mittels der **bestehenden**
|
||||||
|
`substitute()` gegen `bound` und aktualisiert `bound`.
|
||||||
|
3. Jede `forLoop` löst ihre Quelle (nach Substitution) über `rangeOrCellTo-
|
||||||
|
CellNodes()` in eine konkrete Zellliste auf (`expandRange`, wie
|
||||||
|
`evaluator.ts#evalArg` es für ein Bereichsargument tut) – ein
|
||||||
|
Einzelzellbezug ergibt genau eine Iteration. Für jede Zelle, in
|
||||||
|
Zellreihenfolge: die Schleifenvariable zeigt auf diese Zelle, der
|
||||||
|
Zuweisungskörper wird gegen die **aktuelle** `bound`-Umgebung substituiert
|
||||||
|
(sieht also den Wert der *vorherigen* Iteration) – ein Links-Fold, ein
|
||||||
|
verschachtelter Ausdrucks-Teilbaum pro Zelle. Schleifenvariable und
|
||||||
|
Zuweisungsziel bleiben danach in `bound` (flacher Namensraum: sichtbar mit
|
||||||
|
ihrem letzten Wert für den Rest des Blocks).
|
||||||
|
4. `RETURN`s Ausdruck wird zuletzt gegen die finale `bound`-Umgebung
|
||||||
|
substituiert – das Ergebnis ist der komplette, statische Rückgabewert von
|
||||||
|
`compileBlock`.
|
||||||
|
|
||||||
|
Das Ergebnis kann noch unaufgelöste `FuncNode`s enthalten (verschachtelte
|
||||||
|
Aufrufe im Schleifenkörper) – deshalb läuft es direkt danach noch einmal
|
||||||
|
durch `expand()` (eine Ebene tiefer), genau wie jeder andere Funktionskörper.
|
||||||
|
`MAX_EXPANSION_DEPTH` gilt dadurch unverändert auch für verschachtelte
|
||||||
|
`kind: 'block'`-Aufrufe.
|
||||||
|
|
||||||
|
**Kein Endlos-Risiko:** `MAX_UNROLL_STEPS` (10.000, Summe aller Schleifen
|
||||||
|
eines `compileBlock`-Aufrufs) wirft einen `Error` bei Überschreitung –
|
||||||
|
`#ERR`, wie jeder andere `expandUserFunctions`-Fehler. Ein `forLoop`, dessen
|
||||||
|
(substituierte) Quelle weder eine Zelle noch ein Bereich ist, wirft ebenso
|
||||||
|
(`rangeOrCellToCellNodes`); der Store prüft das bereits strukturell beim
|
||||||
|
**Speichern** (`findInvalidForSource`, exportiert), bevor es je zu einem
|
||||||
|
echten Aufruf kommt.
|
||||||
|
|
||||||
|
**Namens-Validierung ist Store-Sache, nicht Engine-Sache** (wie beim
|
||||||
|
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
|
||||||
|
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür
|
||||||
|
`declaredBlockNames(block)` (jeder eingeführte Name, inkl. Wiederholungen
|
||||||
|
bei Wiederverwendung – z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
|
||||||
|
bereich DO ergebnis = ergebnis * x`, wo „ergebnis" zweimal auftaucht, aber
|
||||||
|
**keine** Kollision ist) und `findInvalidForSource(block)`. `document.ts`s
|
||||||
|
`validateCustomFunction` prüft damit: jeder Name gültig/nicht zellartig/kein
|
||||||
|
reserviertes Wort (`FOR`/`IN`/`DO`/`RETURN`), kein *unterschiedlicher*
|
||||||
|
lokaler Name überschattet einen Parameter (Wiederverwendung *desselben*
|
||||||
|
Namens über mehrere Anweisungen ist dagegen erlaubt), und die
|
||||||
|
Zyklus-Prüfung nutzt `collectCalledFunctionNamesInBlock(block)` (läuft das
|
||||||
|
bestehende `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des
|
||||||
|
Blocks – kein zweiter AST-Walker nötig).
|
||||||
|
|
||||||
## Auswertungsmodell
|
## Auswertungsmodell
|
||||||
|
|
||||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||||
@@ -179,7 +257,7 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
|||||||
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
|
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
|
||||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||||
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
||||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
|
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle bzw. mehr als `MAX_UNROLL_STEPS` abgerollte Zellen |
|
||||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseFormula, collectRefs, ParseError, type Node } from '../parser';
|
import {
|
||||||
|
parseFormula,
|
||||||
|
parseFunctionBlock,
|
||||||
|
collectRefs,
|
||||||
|
ParseError,
|
||||||
|
type Node,
|
||||||
|
type Statement,
|
||||||
|
type BlockNode,
|
||||||
|
} from '../parser';
|
||||||
|
|
||||||
/** Compact s-expression rendering of an AST for precedence assertions. */
|
/** Compact s-expression rendering of an AST for precedence assertions. */
|
||||||
function sexpr(n: Node): string {
|
function sexpr(n: Node): string {
|
||||||
@@ -25,6 +33,17 @@ function sexpr(n: Node): string {
|
|||||||
const parse = (s: string) => sexpr(parseFormula(s));
|
const parse = (s: string) => sexpr(parseFormula(s));
|
||||||
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
||||||
|
|
||||||
|
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
|
||||||
|
function sexprStmt(s: Statement): string {
|
||||||
|
if (s.kind === 'assign') return `${s.name}=${sexpr(s.value)}`;
|
||||||
|
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprStmt(s.body)})`;
|
||||||
|
}
|
||||||
|
function sexprBlock(b: BlockNode): string {
|
||||||
|
return `{${b.statements.map(sexprStmt).join('; ')}} RETURN ${sexpr(b.ret)}`;
|
||||||
|
}
|
||||||
|
const parseBlock = (s: string, paramNames: readonly string[]) =>
|
||||||
|
sexprBlock(parseFunctionBlock(s, paramNames));
|
||||||
|
|
||||||
describe('parser precedence & associativity', () => {
|
describe('parser precedence & associativity', () => {
|
||||||
it('* / bind tighter than + -', () => {
|
it('* / bind tighter than + -', () => {
|
||||||
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
||||||
@@ -160,3 +179,63 @@ describe('parser errors', () => {
|
|||||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
|
||||||
|
it('a single FOR loop, accumulator pattern', () => {
|
||||||
|
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
|
||||||
|
expect(parseBlock(src, ['bereich'])).toBe(
|
||||||
|
'{ergebnis=1; FOR(x IN param(bereich) DO ergebnis=(* param(ergebnis) param(x)))} RETURN param(ergebnis)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
|
||||||
|
const src =
|
||||||
|
'total = 0 FOR a IN r1 DO total = total + a FOR b IN r2 DO total = total + b RETURN total';
|
||||||
|
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||||
|
'{total=0; FOR(a IN param(r1) DO total=(+ param(total) param(a))); ' +
|
||||||
|
'FOR(b IN param(r2) DO total=(+ param(total) param(b)))} RETURN param(total)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a block with no statements -- just RETURN <expression>', () => {
|
||||||
|
expect(parseBlock('RETURN 42', [])).toBe('{} RETURN 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RETURN may be any expression, not just a bare variable', () => {
|
||||||
|
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5} RETURN (* param(x) 2)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain assignment can reference an already-declared variable', () => {
|
||||||
|
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe(
|
||||||
|
'{a=1; b=(+ param(a) 1)} RETURN param(b)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
|
||||||
|
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x RETURN total', [])).toBe(
|
||||||
|
'{total=0; FOR(x IN range(0,0:0,4) DO total=(+ param(total) param(x)))} RETURN param(total)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when RETURN is missing entirely', () => {
|
||||||
|
expect(() => parseFunctionBlock('x = 1', [])).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on FOR without a matching DO', () => {
|
||||||
|
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on FOR without IN', () => {
|
||||||
|
expect(() => parseFunctionBlock('FOR x bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
||||||
|
ParseError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when an assignment is missing "="', () => {
|
||||||
|
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
|
||||||
|
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const val = (wb: Workbook, sheetId: string, a1: string) => {
|
|||||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
|
const def = (
|
||||||
partial;
|
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
|
||||||
|
): FunctionDef => ({ kind: 'expression', ...partial });
|
||||||
|
|
||||||
describe('expandUserFunctions (AST-level)', () => {
|
describe('expandUserFunctions (AST-level)', () => {
|
||||||
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
||||||
@@ -243,3 +244,194 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
|
|||||||
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const productBlock = {
|
||||||
|
params: ['bereich'],
|
||||||
|
kind: 'block' as const,
|
||||||
|
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
|
||||||
|
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
|
||||||
|
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
|
||||||
|
// ((1 * A1) * A2) * A3
|
||||||
|
expect(expanded).toEqual({
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: { kind: 'number', value: 1 },
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||||
|
},
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||||
|
},
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 0, row: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single-cell FOR source (no range) unrolls to exactly one iteration', () => {
|
||||||
|
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1)'), index);
|
||||||
|
expect(expanded).toEqual({
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: { kind: 'number', value: 1 },
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a loop variable / local stays visible (holding its last value) after its own loop', () => {
|
||||||
|
const index = buildFunctionIndex([
|
||||||
|
def({
|
||||||
|
id: 'f1',
|
||||||
|
name: 'LASTPLUSONE',
|
||||||
|
params: ['bereich'],
|
||||||
|
kind: 'block',
|
||||||
|
body: 'acc = 0 FOR x IN bereich DO acc = acc + x RETURN x + 1', // returns the loop VAR, not acc
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const expanded = expandUserFunctions(parseFormula('=LASTPLUSONE(A1:A3)'), index);
|
||||||
|
expect(expanded).toEqual({
|
||||||
|
kind: 'binary',
|
||||||
|
op: '+',
|
||||||
|
left: { kind: 'cell', qualifier: null, col: 0, row: 2 }, // A3 -- the last iterated cell
|
||||||
|
right: { kind: 'number', value: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a call inside a FOR body to another (expression-kind) function is expanded too', () => {
|
||||||
|
const index = buildFunctionIndex([
|
||||||
|
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||||
|
def({
|
||||||
|
id: 's1',
|
||||||
|
name: 'SUMDOUBLED',
|
||||||
|
params: ['bereich'],
|
||||||
|
kind: 'block',
|
||||||
|
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
|
||||||
|
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left
|
||||||
|
expect(expanded).toEqual({
|
||||||
|
kind: 'binary',
|
||||||
|
op: '+',
|
||||||
|
left: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '+',
|
||||||
|
left: { kind: 'number', value: 0 },
|
||||||
|
right: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||||
|
right: { kind: 'number', value: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||||
|
right: { kind: 'number', value: 2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple independent FOR loops each accumulate correctly', () => {
|
||||||
|
const index = buildFunctionIndex([
|
||||||
|
def({
|
||||||
|
id: 'c1',
|
||||||
|
name: 'COMBINED',
|
||||||
|
params: ['sumRange', 'prodRange'],
|
||||||
|
kind: 'block',
|
||||||
|
body:
|
||||||
|
'total = 0 FOR a IN sumRange DO total = total + a ' +
|
||||||
|
'prod = 1 FOR b IN prodRange DO prod = prod * b ' +
|
||||||
|
'RETURN total + prod',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const expanded = expandUserFunctions(parseFormula('=COMBINED(A1:A2; B1:B2)'), index);
|
||||||
|
expect(expanded).toEqual({
|
||||||
|
kind: 'binary',
|
||||||
|
op: '+',
|
||||||
|
left: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '+',
|
||||||
|
left: { kind: 'binary', op: '+', left: { kind: 'number', value: 0 }, right: { kind: 'cell', qualifier: null, col: 0, row: 0 } },
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
kind: 'binary',
|
||||||
|
op: '*',
|
||||||
|
left: { kind: 'binary', op: '*', left: { kind: 'number', value: 1 }, right: { kind: 'cell', qualifier: null, col: 1, row: 0 } },
|
||||||
|
right: { kind: 'cell', qualifier: null, col: 1, row: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arity mismatch is still checked for block-kind functions', () => {
|
||||||
|
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a FOR source that is not a cell/range after substitution throws', () => {
|
||||||
|
const index = buildFunctionIndex([
|
||||||
|
def({
|
||||||
|
id: 'f1',
|
||||||
|
name: 'BAD',
|
||||||
|
params: ['x'],
|
||||||
|
kind: 'block',
|
||||||
|
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// x substituted with a NumberNode -> the FOR source becomes "5 + 1", not a cell/range
|
||||||
|
expect(() => expandUserFunctions(parseFormula('=BAD(5)'), index)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a FOR loop over more cells than the unroll cap throws', () => {
|
||||||
|
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
// A1:A20000 -> 20000 cells, well over the 10,000-cell cap
|
||||||
|
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1:A20000)'), index)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Workbook integration: block-kind (FOR loop) user-defined functions', () => {
|
||||||
|
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||||
|
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates when a cell inside the passed range changes', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||||
|
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
|
||||||
|
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exceeding the unroll cap degrades to #ERR at the cell, not a crash', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
|
||||||
|
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||||
|
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
|
||||||
|
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
|
||||||
|
const wb = new Workbook(
|
||||||
|
[umsatz],
|
||||||
|
[
|
||||||
|
def({
|
||||||
|
id: 'f1',
|
||||||
|
name: 'BAD',
|
||||||
|
params: ['x'],
|
||||||
|
kind: 'block',
|
||||||
|
body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
||||||
export {
|
export {
|
||||||
parseFormula,
|
parseFormula,
|
||||||
|
parseFunctionBlock,
|
||||||
collectRefs,
|
collectRefs,
|
||||||
ParseError,
|
ParseError,
|
||||||
type Node,
|
type Node,
|
||||||
type CellNode,
|
type CellNode,
|
||||||
type RangeNode,
|
type RangeNode,
|
||||||
|
type BlockNode,
|
||||||
} from './parser';
|
} from './parser';
|
||||||
export { evaluate } from './evaluator';
|
export { evaluate } from './evaluator';
|
||||||
export { DependencyGraph } from './graph';
|
export { DependencyGraph } from './graph';
|
||||||
@@ -35,6 +37,9 @@ export {
|
|||||||
buildFunctionIndex,
|
buildFunctionIndex,
|
||||||
expandUserFunctions,
|
expandUserFunctions,
|
||||||
collectCalledFunctionNames,
|
collectCalledFunctionNames,
|
||||||
|
collectCalledFunctionNamesInBlock,
|
||||||
|
declaredBlockNames,
|
||||||
|
findInvalidForSource,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
} from './userFunctions';
|
} from './userFunctions';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type TokenType =
|
|||||||
| 'COLON'
|
| 'COLON'
|
||||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||||
| 'BANG'
|
| 'BANG'
|
||||||
|
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
|
||||||
| 'EOF';
|
| 'EOF';
|
||||||
|
|
||||||
export interface Token {
|
export interface Token {
|
||||||
@@ -47,6 +48,7 @@ const SINGLE: Record<string, TokenType> = {
|
|||||||
';': 'SEMICOLON',
|
';': 'SEMICOLON',
|
||||||
':': 'COLON',
|
':': 'COLON',
|
||||||
'!': 'BANG',
|
'!': 'BANG',
|
||||||
|
'=': 'EQUALS',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDigit(ch: string): boolean {
|
function isDigit(ch: string): boolean {
|
||||||
|
|||||||
+128
-2
@@ -120,13 +120,56 @@ export interface ParamNode {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// "Block" function bodies (`kind: 'block'` custom functions) -- a small,
|
||||||
|
// separate statement grammar for a FOR-loop-capable function body. NOT part
|
||||||
|
// of the `Node` union above: a block is parsed by parseFunctionBlock() (a
|
||||||
|
// distinct entry point from parseFormula()) and compiled down to a plain
|
||||||
|
// `Node` by userFunctions.ts#compileBlock() *before* it ever reaches
|
||||||
|
// expandUserFunctions()'s normal splicing/evaluation path -- so no existing
|
||||||
|
// `switch (node.kind)` (evaluator, collectRefs, userFunctions#substitute)
|
||||||
|
// needs a case for these. See src/engine/README.md's "Schleifen" section.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** `name = value` -- a plain local-variable assignment, or (nested inside a
|
||||||
|
* ForNode) one loop step. */
|
||||||
|
export interface AssignNode {
|
||||||
|
kind: 'assign';
|
||||||
|
name: string;
|
||||||
|
value: Node;
|
||||||
|
}
|
||||||
|
/** `FOR varName IN source DO body` -- source must resolve, once parameters
|
||||||
|
* are substituted with the caller's real arguments, to a single cell or a
|
||||||
|
* range (checked by userFunctions.ts); compileBlock() unrolls it into one
|
||||||
|
* nested expression per cell, in order. */
|
||||||
|
export interface ForNode {
|
||||||
|
kind: 'for';
|
||||||
|
varName: string;
|
||||||
|
source: Node;
|
||||||
|
body: AssignNode;
|
||||||
|
}
|
||||||
|
export type Statement = AssignNode | ForNode;
|
||||||
|
/** A whole "block" function body: a sequence of statements, then a `RETURN`
|
||||||
|
* expression. Every name introduced by a statement (assignment target or
|
||||||
|
* loop variable) stays visible for the rest of the block -- flat scope, no
|
||||||
|
* nested block scoping. */
|
||||||
|
export interface BlockNode {
|
||||||
|
kind: 'block';
|
||||||
|
statements: Statement[];
|
||||||
|
ret: Node;
|
||||||
|
}
|
||||||
|
|
||||||
export class ParseError extends Error {}
|
export class ParseError extends Error {}
|
||||||
|
|
||||||
class Parser {
|
class Parser {
|
||||||
private i = 0;
|
private i = 0;
|
||||||
/** Case-insensitively matched (compared upper-cased), like function names
|
/** Case-insensitively matched (compared upper-cased), like function names
|
||||||
* elsewhere in the engine (functions.ts's getFunction()). */
|
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
|
||||||
private readonly paramNames: ReadonlySet<string>;
|
* parseBlock() below: as each statement introduces a new local/loop-var
|
||||||
|
* name, it's added here so *later* statements/the RETURN can reference it
|
||||||
|
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
||||||
|
* this, so its behaviour is completely unaffected. */
|
||||||
|
private readonly paramNames: Set<string>;
|
||||||
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
|
constructor(private readonly toks: Token[], paramNames: readonly string[] = []) {
|
||||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||||
}
|
}
|
||||||
@@ -155,6 +198,70 @@ class Parser {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entry point for a "block" function body: `{ statement } "RETURN" expression`.
|
||||||
|
* See parseFunctionBlock() below. */
|
||||||
|
parseBlock(): BlockNode {
|
||||||
|
const statements: Statement[] = [];
|
||||||
|
while (!this.isKeyword('RETURN')) {
|
||||||
|
statements.push(this.parseStatement());
|
||||||
|
}
|
||||||
|
this.next(); // consume RETURN
|
||||||
|
const ret = this.expression();
|
||||||
|
if (!this.at('EOF')) {
|
||||||
|
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
return { kind: 'block', statements, ret };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStatement(): Statement {
|
||||||
|
if (this.isKeyword('FOR')) return this.parseForLoop();
|
||||||
|
if (this.at('EOF')) {
|
||||||
|
throw new ParseError(`Expected a statement or RETURN but reached the end at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
return this.parseAssignment();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAssignment(): AssignNode {
|
||||||
|
if (!this.at('IDENT')) {
|
||||||
|
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
const nameTok = this.next();
|
||||||
|
if (!this.at('EQUALS')) {
|
||||||
|
throw new ParseError(`Expected '=' after '${nameTok.value}' at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
this.next();
|
||||||
|
const value = this.expression();
|
||||||
|
this.paramNames.add(nameTok.value.toUpperCase()); // visible from here on (flat scope)
|
||||||
|
return { kind: 'assign', name: nameTok.value, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseForLoop(): ForNode {
|
||||||
|
this.next(); // consume FOR
|
||||||
|
if (!this.at('IDENT')) {
|
||||||
|
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
const varTok = this.next();
|
||||||
|
if (!this.isKeyword('IN')) {
|
||||||
|
throw new ParseError(`Expected IN after 'FOR ${varTok.value}' at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
this.next(); // consume IN
|
||||||
|
const source = this.expression();
|
||||||
|
if (!this.isKeyword('DO')) {
|
||||||
|
throw new ParseError(`Expected DO after the FOR source at ${this.peek().pos}`);
|
||||||
|
}
|
||||||
|
this.next(); // consume DO
|
||||||
|
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the DO assignment (and after)
|
||||||
|
const body = this.parseAssignment();
|
||||||
|
return { kind: 'for', varName: varTok.value, source, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the current token is an IDENT spelling exactly `word`
|
||||||
|
* (case-insensitive) -- how FOR/IN/DO/RETURN are recognised, the same way
|
||||||
|
* function names are matched elsewhere (no reserved-word lexer tokens). */
|
||||||
|
private isKeyword(word: string): boolean {
|
||||||
|
return this.at('IDENT') && this.peek().value.toUpperCase() === word;
|
||||||
|
}
|
||||||
|
|
||||||
private expression(): Node {
|
private expression(): Node {
|
||||||
let left = this.term();
|
let left = this.term();
|
||||||
while (this.at('PLUS') || this.at('MINUS')) {
|
while (this.at('PLUS') || this.at('MINUS')) {
|
||||||
@@ -342,6 +449,25 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
|
|||||||
return new Parser(toks, paramNames).parse();
|
return new Parser(toks, paramNames).parse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a "block" function body -- the `FOR`/`DO`/`RETURN` statement
|
||||||
|
* grammar used by a `kind: 'block'` custom function (see
|
||||||
|
* src/engine/userFunctions.ts and CustomFunctionsModal.vue). The leading
|
||||||
|
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
|
||||||
|
* {@link LexError} on malformed input, same conventions as parseFormula().
|
||||||
|
*/
|
||||||
|
export function parseFunctionBlock(src: string, paramNames: readonly string[]): BlockNode {
|
||||||
|
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||||
|
let toks: Token[];
|
||||||
|
try {
|
||||||
|
toks = tokenize(body);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LexError) throw new ParseError(e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return new Parser(toks, paramNames).parseBlock();
|
||||||
|
}
|
||||||
|
|
||||||
/** Walk an AST and collect every cell/range reference node it contains. */
|
/** Walk an AST and collect every cell/range reference node it contains. */
|
||||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
|
|||||||
+183
-8
@@ -15,9 +15,26 @@
|
|||||||
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
|
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
|
||||||
* and correctly pick up cell references that live *only* inside the
|
* and correctly pick up cell references that live *only* inside the
|
||||||
* function body, not just the ones passed as arguments.
|
* function body, not just the ones passed as arguments.
|
||||||
|
*
|
||||||
|
* A `kind: 'block'` function (a FOR/DO/RETURN body, see parser.ts's
|
||||||
|
* `parseFunctionBlock`) works the same way in the end: `compileBlock()`
|
||||||
|
* below *unrolls* every FOR loop -- whose range size is already known once
|
||||||
|
* the caller's real argument is substituted in -- into a single nested
|
||||||
|
* expression tree, one iteration deep per cell. There is no runtime loop in
|
||||||
|
* the evaluator; by the time `expand()` is done, a block-kind call has
|
||||||
|
* become exactly the same shape of plain expression tree as any other
|
||||||
|
* inlined function call. See src/engine/README.md's "Schleifen" section.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseFormula, type Node } from './parser';
|
import {
|
||||||
|
parseFormula,
|
||||||
|
parseFunctionBlock,
|
||||||
|
type BlockNode,
|
||||||
|
type CellNode,
|
||||||
|
type ForNode,
|
||||||
|
type Node,
|
||||||
|
} from './parser';
|
||||||
|
import { expandRange } from './refs';
|
||||||
|
|
||||||
export interface FunctionDef {
|
export interface FunctionDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,10 +42,24 @@ export interface FunctionDef {
|
|||||||
name: string;
|
name: string;
|
||||||
/** parameter names, in call order. */
|
/** parameter names, in call order. */
|
||||||
params: string[];
|
params: string[];
|
||||||
/** raw formula text, no leading "=". */
|
/** raw formula text, no leading "=" -- a single expression for
|
||||||
|
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||||
|
* `kind: 'block'`. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
|
||||||
|
* capable). Every function predating this field is 'expression'. */
|
||||||
|
kind: 'expression' | 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total cells one `compileBlock()` call (i.e. one block-kind function call
|
||||||
|
* site) may unroll, summed across every FOR loop in its body -- a safety
|
||||||
|
* net against a pathologically large range (e.g. `FOR x IN A1:Z10000 DO
|
||||||
|
* ...`), matching the "no crash, no hang" guarantee. A chain of nested
|
||||||
|
* block-kind calls is still separately bounded by MAX_EXPANSION_DEPTH, so
|
||||||
|
* total work across a whole formula stays finite either way. Generous for
|
||||||
|
* anything this app's tables realistically hold. */
|
||||||
|
const MAX_UNROLL_STEPS = 10_000;
|
||||||
|
|
||||||
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
|
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
|
||||||
* rejected at definition time (see the store's cycle check), this is only
|
* 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
|
* a last-resort safety net so a bug there degrades to a cell error rather
|
||||||
@@ -53,13 +84,14 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, Fu
|
|||||||
*/
|
*/
|
||||||
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
|
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
|
||||||
if (index.size === 0) return ast; // fast path: no user functions defined at all
|
if (index.size === 0) return ast; // fast path: no user functions defined at all
|
||||||
return expand(ast, index, new Map(), 0);
|
return expand(ast, index, new Map(), new Map(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expand(
|
function expand(
|
||||||
node: Node,
|
node: Node,
|
||||||
index: ReadonlyMap<string, FunctionDef>,
|
index: ReadonlyMap<string, FunctionDef>,
|
||||||
bodyCache: Map<string, Node>,
|
bodyCache: Map<string, Node>,
|
||||||
|
blockCache: Map<string, BlockNode>,
|
||||||
depth: number,
|
depth: number,
|
||||||
): Node {
|
): Node {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
@@ -70,18 +102,18 @@ function expand(
|
|||||||
case 'param':
|
case 'param':
|
||||||
return node; // leaves -- nothing to expand
|
return node; // leaves -- nothing to expand
|
||||||
case 'unary':
|
case 'unary':
|
||||||
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
|
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
|
||||||
case 'binary':
|
case 'binary':
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
left: expand(node.left, index, bodyCache, depth),
|
left: expand(node.left, index, bodyCache, blockCache, depth),
|
||||||
right: expand(node.right, index, bodyCache, depth),
|
right: expand(node.right, index, bodyCache, blockCache, depth),
|
||||||
};
|
};
|
||||||
case 'func': {
|
case 'func': {
|
||||||
// expand the arguments regardless of whether this call is itself a
|
// expand the arguments regardless of whether this call is itself a
|
||||||
// user function -- a built-in like SUM(...) may still take a
|
// user function -- a built-in like SUM(...) may still take a
|
||||||
// user-function call as one of its arguments.
|
// user-function call as one of its arguments.
|
||||||
const args = node.args.map((a) => expand(a, index, bodyCache, depth));
|
const args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
|
||||||
const def = index.get(node.name.toUpperCase());
|
const def = index.get(node.name.toUpperCase());
|
||||||
if (!def) return { ...node, args };
|
if (!def) return { ...node, args };
|
||||||
|
|
||||||
@@ -94,6 +126,23 @@ function expand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (def.kind === 'block') {
|
||||||
|
let block = blockCache.get(def.id);
|
||||||
|
if (!block) {
|
||||||
|
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||||
|
blockCache.set(def.id, block);
|
||||||
|
}
|
||||||
|
// compileBlock() already substitutes this function's own parameters
|
||||||
|
// (it needs their *real* values to know how many cells a FOR loop
|
||||||
|
// covers), so -- unlike the expression-kind path below -- there is
|
||||||
|
// no separate substitute() step here. Its result may still contain
|
||||||
|
// nested calls (to built-ins or other user functions), so it goes
|
||||||
|
// through expand() once more, one level deeper, exactly like any
|
||||||
|
// other function body.
|
||||||
|
const compiled = compileBlock(block, def.params, args);
|
||||||
|
return expand(compiled, index, bodyCache, blockCache, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
let body = bodyCache.get(def.id);
|
let body = bodyCache.get(def.id);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
|
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||||
@@ -104,7 +153,7 @@ function expand(
|
|||||||
// resolving inside-out like this keeps each function's own parameters
|
// resolving inside-out like this keeps each function's own parameters
|
||||||
// scoped correctly no matter what the caller's happen to be named),
|
// scoped correctly no matter what the caller's happen to be named),
|
||||||
// then substitute this function's own parameters last.
|
// then substitute this function's own parameters last.
|
||||||
const expandedBody = expand(body, index, bodyCache, depth + 1);
|
const expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
|
||||||
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
|
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
|
||||||
return substitute(expandedBody, bound);
|
return substitute(expandedBody, bound);
|
||||||
}
|
}
|
||||||
@@ -133,6 +182,77 @@ function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a `kind: 'block'` function body down to a single plain expression
|
||||||
|
* `Node`, given the caller's *real* argument nodes for this call. Statements
|
||||||
|
* run in order against a growing `bound` environment (name -> already-
|
||||||
|
* substituted expression tree): a plain assignment substitutes its value
|
||||||
|
* against the current environment and stores it; a FOR loop resolves its
|
||||||
|
* source to a concrete list of cells (throwing if it isn't one -- can only
|
||||||
|
* happen if a param turned out, at this call site, not to be a cell/range;
|
||||||
|
* the store's save-time check catches the far more common case of a
|
||||||
|
* body that could *never* work), then unrolls one nested substitution per
|
||||||
|
* cell, each seeing the *previous* iteration's value for whichever name the
|
||||||
|
* loop body assigns -- exactly a left fold. The loop variable and the
|
||||||
|
* loop body's assignment target both stay in `bound` afterwards (flat
|
||||||
|
* scope: visible, holding their last iteration's value, for the rest of the
|
||||||
|
* block) -- parseFunctionBlock() already parsed the source text with that
|
||||||
|
* same flat-scope assumption, so this matches.
|
||||||
|
*
|
||||||
|
* Throws a plain `Error` on a bad FOR source or exceeding
|
||||||
|
* {@link MAX_UNROLL_STEPS}; the caller (`expand()`'s `func` case, in turn
|
||||||
|
* `Workbook.parseCell`) turns it into `#ERR`, same as any other
|
||||||
|
* expandUserFunctions() failure.
|
||||||
|
*/
|
||||||
|
function compileBlock(block: BlockNode, paramNames: readonly string[], args: readonly Node[]): Node {
|
||||||
|
const bound = new Map<string, Node>(paramNames.map((p, i) => [p.toUpperCase(), args[i]]));
|
||||||
|
let steps = 0;
|
||||||
|
for (const stmt of block.statements) {
|
||||||
|
if (stmt.kind === 'assign') {
|
||||||
|
bound.set(stmt.name.toUpperCase(), substitute(stmt.value, bound));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = substitute(stmt.source, bound);
|
||||||
|
const cells = rangeOrCellToCellNodes(source, stmt.varName);
|
||||||
|
steps += cells.length;
|
||||||
|
if (steps > MAX_UNROLL_STEPS) {
|
||||||
|
throw new Error(
|
||||||
|
`FOR-Schleife(n) über zu viele Zellen (> ${MAX_UNROLL_STEPS} insgesamt) -- bitte einen kleineren Bereich verwenden`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const cell of cells) {
|
||||||
|
const iterBound = new Map(bound);
|
||||||
|
iterBound.set(stmt.varName.toUpperCase(), cell);
|
||||||
|
const value = substitute(stmt.body.value, iterBound);
|
||||||
|
bound.set(stmt.body.name.toUpperCase(), value);
|
||||||
|
bound.set(stmt.varName.toUpperCase(), cell); // stays visible after the loop (flat scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return substitute(block.ret, bound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A FOR loop's source, once parameters are substituted in, must be a
|
||||||
|
* single cell or a range -- never a computed value (mirrors "a range may
|
||||||
|
* only appear as a function argument" for ordinary formulas). Expands a
|
||||||
|
* range to its individual cells via `expandRange` (same helper
|
||||||
|
* `evaluator.ts#evalArg` uses for a range function argument), carrying the
|
||||||
|
* same table/sheet qualifier through to each. */
|
||||||
|
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||||
|
if (node.kind === 'cell') return [node];
|
||||||
|
if (node.kind === 'range') {
|
||||||
|
return expandRange(node.start, node.end).map(
|
||||||
|
(c): CellNode => ({
|
||||||
|
kind: 'cell',
|
||||||
|
qualifier: node.qualifier,
|
||||||
|
...(node.sheetQualifier === undefined ? {} : { sheetQualifier: node.sheetQualifier }),
|
||||||
|
col: c.col,
|
||||||
|
row: c.row,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`FOR ${loopVarName} IN ...: die Quelle muss ein Zell- oder Bereichsbezug sein`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every distinct function name called anywhere in `ast` (built-in or not --
|
* 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
|
* callers filter against their own registry). Used by the store to build a
|
||||||
@@ -157,3 +277,58 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every name a block declares -- an assignment target or a loop variable --
|
||||||
|
* in the order first declared. Used by the store to validate these local
|
||||||
|
* names the same way it validates parameters (not cell-shaped, no
|
||||||
|
* collisions with each other or the parameters, not a reserved word); the
|
||||||
|
* engine itself has no opinion on naming policy or German error text, so
|
||||||
|
* it only exposes the raw names here. */
|
||||||
|
export function declaredBlockNames(block: BlockNode): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const stmt of block.statements) {
|
||||||
|
if (stmt.kind === 'assign') {
|
||||||
|
out.push(stmt.name);
|
||||||
|
} else {
|
||||||
|
out.push(stmt.varName, stmt.body.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first FOR loop, if any, whose source isn't (syntactically) a
|
||||||
|
* parameter, a single cell, or a range -- i.e. can never resolve to a
|
||||||
|
* concrete list of cells no matter what the caller passes. Checked at
|
||||||
|
* save time so this is caught immediately, not just on the first real
|
||||||
|
* call; `compileBlock` re-checks the same thing on the *substituted*
|
||||||
|
* source at call time (needed regardless, since a parameter's shape isn't
|
||||||
|
* known until then) as the actual enforcement. */
|
||||||
|
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||||
|
for (const stmt of block.statements) {
|
||||||
|
if (stmt.kind === 'for' && stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
|
||||||
|
* over every leaf expression a `BlockNode` contains (each statement's
|
||||||
|
* value/source, a FOR loop's body assignment, the RETURN expression) and
|
||||||
|
* merges the results -- `collectCalledFunctionNames` itself needs no
|
||||||
|
* changes, since a block is never part of the `Node` union it switches on. */
|
||||||
|
export function collectCalledFunctionNamesInBlock(
|
||||||
|
block: BlockNode,
|
||||||
|
out: Set<string> = new Set(),
|
||||||
|
): Set<string> {
|
||||||
|
for (const stmt of block.statements) {
|
||||||
|
if (stmt.kind === 'assign') {
|
||||||
|
collectCalledFunctionNames(stmt.value, out);
|
||||||
|
} else {
|
||||||
|
collectCalledFunctionNames(stmt.source, out);
|
||||||
|
collectCalledFunctionNames(stmt.body.value, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectCalledFunctionNames(block.ret, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -52,7 +52,8 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rec = f as CustomFunction;
|
const rec = f as CustomFunction;
|
||||||
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body });
|
const kind = rec.kind === 'block' ? 'block' : 'expression'; // default for anything missing/invalid
|
||||||
|
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body, kind });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -815,6 +815,164 @@ describe('custom functions (document-level library)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('custom functions: "block" kind (FOR loops)', () => {
|
||||||
|
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
|
||||||
|
|
||||||
|
it('adds a block-kind function and a call evaluates through the unrolled loop', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '2');
|
||||||
|
setCell(s, name, 'A2', '3');
|
||||||
|
setCell(s, name, 'A3', '4');
|
||||||
|
|
||||||
|
const result = s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
||||||
|
|
||||||
|
setCell(s, name, 'B1', '=PRODUCT2(A1:A3)');
|
||||||
|
expect(val(s, name, 'B1')).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates when a cell inside the passed range changes', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '2');
|
||||||
|
setCell(s, name, 'A2', '3');
|
||||||
|
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
||||||
|
setCell(s, name, 'B1', '=PRODUCT2(A1:A2)');
|
||||||
|
expect(val(s, name, 'B1')).toBe(6);
|
||||||
|
|
||||||
|
setCell(s, name, 'A1', '10');
|
||||||
|
expect(val(s, name, 'B1')).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to "expression" when kind is omitted (backward-compatible call signature)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
|
||||||
|
expect(s.doc.customFunctions[0].kind).toBe('expression');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reserved word (FOR/IN/DO/RETURN) as a parameter name', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const result = s.addCustomFunction('F', ['for'], 'for + 1', 'block') as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(s.doc.customFunctions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reserved word as a local/loop variable name', () => {
|
||||||
|
const s = fresh();
|
||||||
|
// "do" parses fine as an ordinary name here (DO the keyword only ever
|
||||||
|
// appears in the one position the FOR grammar expects it) -- this
|
||||||
|
// specifically exercises the dedicated reserved-word name check, not a
|
||||||
|
// parser-level keyword clash.
|
||||||
|
const result = s.addCustomFunction(
|
||||||
|
'F',
|
||||||
|
['bereich'],
|
||||||
|
'do = 1 FOR x IN bereich DO do = do * x RETURN do',
|
||||||
|
'block',
|
||||||
|
) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a local/loop variable name that looks like a cell address', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const result = s.addCustomFunction(
|
||||||
|
'F',
|
||||||
|
['bereich'],
|
||||||
|
'A1 = 1 FOR x IN bereich DO A1 = A1 * x RETURN A1',
|
||||||
|
'block',
|
||||||
|
) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a local name that shadows a parameter', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const result = s.addCustomFunction(
|
||||||
|
'SHADOWTEST',
|
||||||
|
['bereich'],
|
||||||
|
'bereich = 1 FOR x IN bereich DO bereich = bereich * x RETURN bereich',
|
||||||
|
'block',
|
||||||
|
) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-assigning the same local across statements (the accumulator pattern) is not a collision', () => {
|
||||||
|
const s = fresh();
|
||||||
|
// "ergebnis" is declared once and re-assigned inside the loop -- exactly
|
||||||
|
// productBody's shape -- this must save cleanly, not be flagged as a
|
||||||
|
// duplicate name.
|
||||||
|
const result = s.addCustomFunction('REASSIGNTEST', ['bereich'], productBody, 'block');
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a FOR source that is structurally not a parameter, cell, or range', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const result = s.addCustomFunction(
|
||||||
|
'F',
|
||||||
|
['x'],
|
||||||
|
'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
|
||||||
|
'block',
|
||||||
|
) as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed code (missing RETURN)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a block-kind function can call another custom function inside its loop', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '2');
|
||||||
|
setCell(s, name, 'A2', '3');
|
||||||
|
|
||||||
|
s.addCustomFunction('DOUBLE', ['n'], 'n * 2');
|
||||||
|
s.addCustomFunction(
|
||||||
|
'SUMDOUBLED',
|
||||||
|
['bereich'],
|
||||||
|
'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
|
||||||
|
'block',
|
||||||
|
);
|
||||||
|
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
|
||||||
|
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a cycle that runs through a block-kind function\'s loop body', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const a = s.addCustomFunction('A', ['bereich'], 'total = 0 FOR x IN bereich DO total = total + B(x) RETURN total', 'block') as string;
|
||||||
|
expect(typeof a).toBe('string');
|
||||||
|
// B calling A closes the cycle A -> B -> A
|
||||||
|
const result = s.addCustomFunction('B', ['x'], 'A(x) + 1') as { error: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(s.doc.customFunctions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo/redo covers a block-kind function change', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const s = fresh();
|
||||||
|
s.addCustomFunction('PRODUCT2', ['bereich'], productBody, 'block');
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(s.doc.customFunctions).toHaveLength(1);
|
||||||
|
|
||||||
|
s.undo();
|
||||||
|
expect(s.doc.customFunctions).toHaveLength(0);
|
||||||
|
s.redo();
|
||||||
|
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
||||||
|
} finally {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('undo / redo', () => {
|
describe('undo / redo', () => {
|
||||||
// rapid-fire changes (a resize drag, or several store calls in one
|
// rapid-fire changes (a resize drag, or several store calls in one
|
||||||
// synchronous action) coalesce into a single undo step -- tests that want
|
// synchronous action) coalesce into a single undo step -- tests that want
|
||||||
@@ -1243,8 +1401,14 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
|
|||||||
s.addToSelection(umsatzId, 2, 2);
|
s.addToSelection(umsatzId, 2, 2);
|
||||||
s.addToSelection(umsatzId, 4, 5);
|
s.addToSelection(umsatzId, 4, 5);
|
||||||
|
|
||||||
// a custom function
|
// a custom function -- one of each kind
|
||||||
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
||||||
|
s.addCustomFunction(
|
||||||
|
'PRODUCT2',
|
||||||
|
['bereich'],
|
||||||
|
'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
|
||||||
|
'block',
|
||||||
|
);
|
||||||
|
|
||||||
return { analyse, umsatzId, chartId, txtId };
|
return { analyse, umsatzId, chartId, txtId };
|
||||||
}
|
}
|
||||||
@@ -1310,13 +1474,15 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
|
|||||||
// multi-range selection
|
// multi-range selection
|
||||||
expect(doc.selection.ranges).toHaveLength(2);
|
expect(doc.selection.ranges).toHaveLength(2);
|
||||||
|
|
||||||
// custom function
|
// custom functions -- one of each kind
|
||||||
expect(doc.customFunctions).toHaveLength(1);
|
expect(doc.customFunctions).toHaveLength(2);
|
||||||
expect(doc.customFunctions[0]).toMatchObject({
|
expect(doc.customFunctions[0]).toMatchObject({
|
||||||
name: 'MARGE',
|
name: 'MARGE',
|
||||||
params: ['revenue', 'cost'],
|
params: ['revenue', 'cost'],
|
||||||
body: '(revenue - cost) / revenue',
|
body: '(revenue - cost) / revenue',
|
||||||
|
kind: 'expression',
|
||||||
});
|
});
|
||||||
|
expect(doc.customFunctions[1]).toMatchObject({ name: 'PRODUCT2', kind: 'block' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('export -> import is lossless for the fully-loaded document', () => {
|
it('export -> import is lossless for the fully-loaded document', () => {
|
||||||
@@ -1362,4 +1528,17 @@ describe('v1 -> v2 migration', () => {
|
|||||||
const rep = tableId(store, 'Report');
|
const rep = tableId(store, 'Report');
|
||||||
expect(store.cellValue(rep, 0, 0)).toBe(7);
|
expect(store.cellValue(rep, 0, 0)).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('an older v2 document\'s customFunctions without a "kind" field default to "expression"', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const before = s.snapshot();
|
||||||
|
const legacy = {
|
||||||
|
...before,
|
||||||
|
customFunctions: [{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' }], // no "kind"
|
||||||
|
};
|
||||||
|
const parsed = parseDocument(JSON.stringify(legacy));
|
||||||
|
expect(parsed.customFunctions).toEqual([
|
||||||
|
{ id: 'fn-1', name: 'MARGE', params: ['x', 'y'], body: 'x - y', kind: 'expression' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-13
@@ -26,8 +26,12 @@ import {
|
|||||||
looksLikeCell,
|
looksLikeCell,
|
||||||
parseA1,
|
parseA1,
|
||||||
parseFormula,
|
parseFormula,
|
||||||
|
parseFunctionBlock,
|
||||||
hasFunction,
|
hasFunction,
|
||||||
collectCalledFunctionNames,
|
collectCalledFunctionNames,
|
||||||
|
collectCalledFunctionNamesInBlock,
|
||||||
|
declaredBlockNames,
|
||||||
|
findInvalidForSource,
|
||||||
type CellValue,
|
type CellValue,
|
||||||
type TableModel,
|
type TableModel,
|
||||||
type Token,
|
type Token,
|
||||||
@@ -87,7 +91,13 @@ function toTableModels(doc: DocumentState): TableModel[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||||||
return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body }));
|
return doc.customFunctions.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
params: f.params,
|
||||||
|
body: f.body,
|
||||||
|
kind: f.kind,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||||||
@@ -661,9 +671,11 @@ function createStore() {
|
|||||||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
* 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
|
* 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). */
|
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||||||
function bodyCallSet(params: string[], body: string): Set<string> {
|
function bodyCallSet(f: Pick<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
|
||||||
try {
|
try {
|
||||||
return collectCalledFunctionNames(parseFormula(body, params));
|
return f.kind === 'block'
|
||||||
|
? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params))
|
||||||
|
: collectCalledFunctionNames(parseFormula(f.body, f.params));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
@@ -690,18 +702,29 @@ function createStore() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FOR/IN/DO/RETURN double as block-body keywords (see parser.ts's
|
||||||
|
* parseFunctionBlock) -- never usable as a parameter/local name, in
|
||||||
|
* either kind of function (so a name stays valid if the function is
|
||||||
|
* later switched to "Mit Schleife"). */
|
||||||
|
const RESERVED_NAMES = new Set(['FOR', 'IN', 'DO', 'RETURN']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full validation for add/update: name & parameter shape, no collision with
|
* Full validation for add/update: name & parameter shape, no collision with
|
||||||
* a built-in or another custom function, the body parses, and -- simulating
|
* a built-in or another custom function, the body parses (as an
|
||||||
* the *whole* call graph with this function's (possibly new) definition in
|
* expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement
|
||||||
* place -- no cycle. Returns a user-facing (German) error string, or null
|
* sequence, with every local/loop-variable name validated the same way as
|
||||||
* when the definition is safe to save. `ignoreId` excludes the function
|
* a parameter and every FOR source structurally a parameter/cell/range),
|
||||||
* being edited from the collision/graph checks against its own old state.
|
* 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(
|
function validateCustomFunction(
|
||||||
name: string,
|
name: string,
|
||||||
params: string[],
|
params: string[],
|
||||||
body: string,
|
body: string,
|
||||||
|
kind: CustomFunction['kind'],
|
||||||
ignoreId: string | null,
|
ignoreId: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
const cleanName = name.trim();
|
const cleanName = name.trim();
|
||||||
@@ -723,32 +746,77 @@ function createStore() {
|
|||||||
for (const p of cleanParams) {
|
for (const p of cleanParams) {
|
||||||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||||||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||||||
|
if (RESERVED_NAMES.has(p.toUpperCase())) {
|
||||||
|
return `"${p}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Parametername verwendet werden.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||||||
return 'Parameternamen müssen eindeutig sein.';
|
return 'Parameternamen müssen eindeutig sein.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ownCalls: Set<string>;
|
||||||
|
if (kind === 'block') {
|
||||||
|
let block;
|
||||||
|
try {
|
||||||
|
block = parseFunctionBlock(body, cleanParams);
|
||||||
|
} catch (e) {
|
||||||
|
return `Code ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
// declaredBlockNames() lists every assignment target/loop variable, but
|
||||||
|
// the SAME name legitimately reappears across statements (that's the
|
||||||
|
// whole point of an accumulator: `ergebnis = 1`, then later `FOR x IN
|
||||||
|
// bereich DO ergebnis = ergebnis * x` re-assigns "ergebnis", it doesn't
|
||||||
|
// declare a second one) -- so shape/reserved-word checks run on every
|
||||||
|
// occurrence (harmless to repeat), but the "no collision" check only
|
||||||
|
// looks at each *distinct* local name against the parameters, never
|
||||||
|
// against other locals (re-using a local's own name is exactly the
|
||||||
|
// point; two locals can even share a name across two separate loops).
|
||||||
|
const localNames = declaredBlockNames(block);
|
||||||
|
for (const n of localNames) {
|
||||||
|
if (!IDENT_RE.test(n)) return `"${n}" ist kein gültiger Name.`;
|
||||||
|
if (looksLikeCell(n)) return `"${n}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`;
|
||||||
|
if (RESERVED_NAMES.has(n.toUpperCase())) {
|
||||||
|
return `"${n}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const paramSet = new Set(cleanParams.map((p) => p.toLowerCase()));
|
||||||
|
for (const n of new Set(localNames.map((x) => x.toLowerCase()))) {
|
||||||
|
if (paramSet.has(n)) return `"${n}" ist bereits ein Parametername.`;
|
||||||
|
}
|
||||||
|
const badFor = findInvalidForSource(block);
|
||||||
|
if (badFor) {
|
||||||
|
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
|
||||||
|
}
|
||||||
|
ownCalls = collectCalledFunctionNamesInBlock(block);
|
||||||
|
} else {
|
||||||
let ast;
|
let ast;
|
||||||
try {
|
try {
|
||||||
ast = parseFormula(body, cleanParams);
|
ast = parseFormula(body, cleanParams);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
}
|
}
|
||||||
|
ownCalls = collectCalledFunctionNames(ast);
|
||||||
|
}
|
||||||
|
|
||||||
const graph = new Map<string, Set<string>>();
|
const graph = new Map<string, Set<string>>();
|
||||||
for (const f of doc.customFunctions) {
|
for (const f of doc.customFunctions) {
|
||||||
if (f.id === ignoreId) continue;
|
if (f.id === ignoreId) continue;
|
||||||
graph.set(f.name.toUpperCase(), bodyCallSet(f.params, f.body));
|
graph.set(f.name.toUpperCase(), bodyCallSet(f));
|
||||||
}
|
}
|
||||||
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
|
graph.set(cleanName.toUpperCase(), ownCalls);
|
||||||
if (callGraphHasCycle(graph)) {
|
if (callGraphHasCycle(graph)) {
|
||||||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCustomFunction(name: string, params: string[], body: string): string | { error: string } {
|
function addCustomFunction(
|
||||||
const error = validateCustomFunction(name, params, body, null);
|
name: string,
|
||||||
|
params: string[],
|
||||||
|
body: string,
|
||||||
|
kind: CustomFunction['kind'] = 'expression',
|
||||||
|
): string | { error: string } {
|
||||||
|
const error = validateCustomFunction(name, params, body, kind, null);
|
||||||
if (error) return { error };
|
if (error) return { error };
|
||||||
const id = uid('fn');
|
const id = uid('fn');
|
||||||
const custom: CustomFunction = {
|
const custom: CustomFunction = {
|
||||||
@@ -756,6 +824,7 @@ function createStore() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
params: params.map((p) => p.trim()),
|
params: params.map((p) => p.trim()),
|
||||||
body: body.startsWith('=') ? body.slice(1) : body,
|
body: body.startsWith('=') ? body.slice(1) : body,
|
||||||
|
kind,
|
||||||
};
|
};
|
||||||
doc.customFunctions.push(custom);
|
doc.customFunctions.push(custom);
|
||||||
syncWorkbook();
|
syncWorkbook();
|
||||||
@@ -767,14 +836,16 @@ function createStore() {
|
|||||||
name: string,
|
name: string,
|
||||||
params: string[],
|
params: string[],
|
||||||
body: string,
|
body: string,
|
||||||
|
kind: CustomFunction['kind'] = 'expression',
|
||||||
): void | { error: string } {
|
): void | { error: string } {
|
||||||
const f = doc.customFunctions.find((x) => x.id === id);
|
const f = doc.customFunctions.find((x) => x.id === id);
|
||||||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||||||
const error = validateCustomFunction(name, params, body, id);
|
const error = validateCustomFunction(name, params, body, kind, id);
|
||||||
if (error) return { error };
|
if (error) return { error };
|
||||||
f.name = name.trim();
|
f.name = name.trim();
|
||||||
f.params = params.map((p) => p.trim());
|
f.params = params.map((p) => p.trim());
|
||||||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||||||
|
f.kind = kind;
|
||||||
syncWorkbook();
|
syncWorkbook();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -159,8 +159,14 @@ export interface CustomFunction {
|
|||||||
name: string;
|
name: string;
|
||||||
/** parameter names, in call order; may be empty (a plain named formula). */
|
/** parameter names, in call order; may be empty (a plain named formula). */
|
||||||
params: string[];
|
params: string[];
|
||||||
/** raw formula text, no leading "=". */
|
/** raw formula text, no leading "=" -- a single expression for
|
||||||
|
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||||
|
* `kind: 'block'`. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** 'expression' (default, single-formula body) or 'block' (FOR-loop
|
||||||
|
* capable, "Mit Schleife" in the UI). Missing on any function saved
|
||||||
|
* before this existed -- normalised to 'expression' on load. */
|
||||||
|
kind: 'expression' | 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentState {
|
export interface DocumentState {
|
||||||
|
|||||||
Reference in New Issue
Block a user