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:
wkh
2026-09-02 21:58:59 +02:00
co-authored by Claude Sonnet 5
parent 7340307a70
commit 6daea9f956
13 changed files with 1094 additions and 48 deletions
+72 -2
View File
@@ -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), 200 Tests
npm run test # Vitest-Suite (Engine + Store + IO), 237 Tests
npm run build # Typecheck + Produktionsbuild
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.
- 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
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
`npm run test` — Vitest, 200 Tests:
`npm run test` — Vitest, 237 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
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
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
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
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
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
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
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
+70 -8
View File
@@ -16,9 +16,10 @@ const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
type Kind = 'expression' | 'block';
const mode = ref<Mode>('list');
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);
// 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.params = [''];
form.body = '';
form.kind = 'expression';
formError.value = null;
mode.value = 'edit';
}
@@ -49,6 +51,7 @@ function openEdit(id: string) {
form.name = f.name;
form.params = f.params.length ? [...f.params] : [''];
form.body = f.body;
form.kind = f.kind;
formError.value = null;
mode.value = 'edit';
}
@@ -63,8 +66,8 @@ function removeParamField(i: number) {
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);
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
: store.addCustomFunction(form.name, params, form.body, form.kind);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
@@ -103,7 +106,10 @@ function close() {
</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>
<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">
<button class="mini" @click="openEdit(f.id)">Bearb.</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" />
</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">
<span class="params-label">Parameter</span>
<div class="params-list">
@@ -138,15 +160,28 @@ function close() {
</div>
<label class="row body-row">
Formel
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
<textarea
v-model="form.body"
class="formula-input"
rows="4"
placeholder="z. B. (revenue - cost) / revenue"
:rows="form.kind === 'block' ? 5 : 4"
:placeholder="
form.kind === 'block'
? 'ergebnis = 1\nFOR x IN bereich DO ergebnis = ergebnis * x\nRETURN ergebnis'
: 'z. B. (revenue - cost) / revenue'
"
/>
</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
direkt an eine Funktion wie <code>SUM</code>/<code>AVG</code>/
<code>MIN</code>/<code>MAX</code> übergeben wird — z. B.
@@ -203,6 +238,33 @@ function close() {
font-family: inherit;
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 {
display: flex;
gap: 6px;
+80 -2
View File
@@ -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). |
| `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. |
| `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()`. |
| `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,
`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
- **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 |
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
| `#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 |
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
+80 -1
View File
@@ -1,5 +1,13 @@
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. */
function sexpr(n: Node): string {
@@ -25,6 +33,17 @@ function sexpr(n: Node): string {
const parse = (s: string) => sexpr(parseFormula(s));
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', () => {
it('* / bind tighter than + -', () => {
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
@@ -160,3 +179,63 @@ describe('parser errors', () => {
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);
});
});
+194 -2
View File
@@ -31,8 +31,9 @@ const val = (wb: Workbook, sheetId: string, a1: string) => {
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
};
const def = (partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>): FunctionDef =>
partial;
const def = (
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
): FunctionDef => ({ kind: 'expression', ...partial });
describe('expandUserFunctions (AST-level)', () => {
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
});
});
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');
});
});
+5
View File
@@ -8,11 +8,13 @@
export { tokenize, LexError, type Token, type TokenType } from './lexer';
export {
parseFormula,
parseFunctionBlock,
collectRefs,
ParseError,
type Node,
type CellNode,
type RangeNode,
type BlockNode,
} from './parser';
export { evaluate } from './evaluator';
export { DependencyGraph } from './graph';
@@ -35,6 +37,9 @@ export {
buildFunctionIndex,
expandUserFunctions,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
type FunctionDef,
} from './userFunctions';
export {
+2
View File
@@ -24,6 +24,7 @@ export type TokenType =
| 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG'
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
| 'EOF';
export interface Token {
@@ -47,6 +48,7 @@ const SINGLE: Record<string, TokenType> = {
';': 'SEMICOLON',
':': 'COLON',
'!': 'BANG',
'=': 'EQUALS',
};
function isDigit(ch: string): boolean {
+128 -2
View File
@@ -120,13 +120,56 @@ export interface ParamNode {
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 {}
class Parser {
private i = 0;
/** Case-insensitively matched (compared upper-cased), like function names
* elsewhere in the engine (functions.ts's getFunction()). */
private readonly paramNames: ReadonlySet<string>;
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
* 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[] = []) {
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
}
@@ -155,6 +198,70 @@ class Parser {
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 {
let left = this.term();
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();
}
/**
* 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. */
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
switch (node.kind) {
+183 -8
View File
@@ -15,9 +15,26 @@
* `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.
*
* 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 {
id: string;
@@ -25,10 +42,24 @@ export interface FunctionDef {
name: string;
/** parameter names, in call order. */
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;
/** '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
* 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
@@ -53,13 +84,14 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, Fu
*/
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);
return expand(ast, index, new Map(), new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
blockCache: Map<string, BlockNode>,
depth: number,
): Node {
switch (node.kind) {
@@ -70,18 +102,18 @@ function expand(
case 'param':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, depth) };
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
case 'binary':
return {
...node,
left: expand(node.left, index, bodyCache, depth),
right: expand(node.right, index, bodyCache, depth),
left: expand(node.left, index, bodyCache, blockCache, depth),
right: expand(node.right, index, bodyCache, blockCache, 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 args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
const def = index.get(node.name.toUpperCase());
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);
if (!body) {
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
// 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 expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
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 --
* 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;
}
/** 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
View File
@@ -52,7 +52,8 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] {
continue;
}
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;
}
+182 -3
View File
@@ -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', () => {
// rapid-fire changes (a resize drag, or several store calls in one
// 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, 4, 5);
// a custom function
// a custom function -- one of each kind
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 };
}
@@ -1310,13 +1474,15 @@ 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);
// custom functions -- one of each kind
expect(doc.customFunctions).toHaveLength(2);
expect(doc.customFunctions[0]).toMatchObject({
name: 'MARGE',
params: ['revenue', 'cost'],
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', () => {
@@ -1362,4 +1528,17 @@ describe('v1 -> v2 migration', () => {
const rep = tableId(store, 'Report');
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' },
]);
});
});
+89 -18
View File
@@ -26,8 +26,12 @@ import {
looksLikeCell,
parseA1,
parseFormula,
parseFunctionBlock,
hasFunction,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
type CellValue,
type TableModel,
type Token,
@@ -87,7 +91,13 @@ 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 }));
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. */
@@ -661,9 +671,11 @@ function createStore() {
* 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> {
function bodyCallSet(f: Pick<CustomFunction, 'params' | 'body' | 'kind'>): Set<string> {
try {
return collectCalledFunctionNames(parseFormula(body, params));
return f.kind === 'block'
? collectCalledFunctionNamesInBlock(parseFunctionBlock(f.body, f.params))
: collectCalledFunctionNames(parseFormula(f.body, f.params));
} catch {
return new Set();
}
@@ -690,18 +702,29 @@ function createStore() {
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
* 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.
* a built-in or another custom function, the body parses (as an
* expression, or -- for `kind: 'block'` -- as a FOR/DO/RETURN statement
* sequence, with every local/loop-variable name validated the same way as
* a parameter and every FOR source structurally a parameter/cell/range),
* 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,
kind: CustomFunction['kind'],
ignoreId: string | null,
): string | null {
const cleanName = name.trim();
@@ -723,32 +746,77 @@ function createStore() {
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 (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) {
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)}`;
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;
try {
ast = parseFormula(body, cleanParams);
} catch (e) {
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
}
ownCalls = collectCalledFunctionNames(ast);
}
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(f.name.toUpperCase(), bodyCallSet(f));
}
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
graph.set(cleanName.toUpperCase(), ownCalls);
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);
function addCustomFunction(
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 };
const id = uid('fn');
const custom: CustomFunction = {
@@ -756,6 +824,7 @@ function createStore() {
name: name.trim(),
params: params.map((p) => p.trim()),
body: body.startsWith('=') ? body.slice(1) : body,
kind,
};
doc.customFunctions.push(custom);
syncWorkbook();
@@ -767,14 +836,16 @@ function createStore() {
name: string,
params: string[],
body: string,
kind: CustomFunction['kind'] = 'expression',
): 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);
const error = validateCustomFunction(name, params, body, kind, id);
if (error) return { error };
f.name = name.trim();
f.params = params.map((p) => p.trim());
f.body = body.startsWith('=') ? body.slice(1) : body;
f.kind = kind;
syncWorkbook();
}
+7 -1
View File
@@ -159,8 +159,14 @@ export interface CustomFunction {
name: string;
/** parameter names, in call order; may be empty (a plain named formula). */
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;
/** '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 {