diff --git a/README.md b/README.md
index 4ac3dc2..d71762e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/components/CustomFunctionsModal.vue b/src/components/CustomFunctionsModal.vue
index 35b96e6..331e823 100644
--- a/src/components/CustomFunctionsModal.vue
+++ b/src/components/CustomFunctionsModal.vue
@@ -16,9 +16,10 @@ const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
+type Kind = 'expression' | 'block';
const mode = ref('list');
const editingId = ref(null);
-const form = reactive({ name: '', params: [''] as string[], body: '' });
+const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
const formError = ref(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() {
+ Eine Folge aus Zuweisungen (name = ausdruck)
+ und FOR-Schleifen (FOR var IN bereich DO name = ausdruck),
+ zuletzt RETURN ausdruck. Jeder eingeführte Name bleibt für
+ den Rest des Codes sichtbar. Beispiel — Produkt aller Elemente eines
+ Bereichs:
+ ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x / RETURN ergebnis,
+ aufgerufen als =PRODUCT(Umsatz!B2:E6).
+
+
Ein Parameter kann auch ein Bereich sein, solange er
direkt an eine Funktion wie SUM/AVG/
MIN/MAX ü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;
diff --git a/src/engine/README.md b/src/engine/README.md
index 6aa5356..d31000c 100644
--- a/src/engine/README.md
+++ b/src/engine/README.md
@@ -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.* |
diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts
index 651ba12..307ce83 100644
--- a/src/engine/__tests__/parser.spec.ts
+++ b/src/engine/__tests__/parser.spec.ts
@@ -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 ', () => {
+ 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);
+ });
+});
diff --git a/src/engine/__tests__/userFunctions.spec.ts b/src/engine/__tests__/userFunctions.spec.ts
index a660c04..19b4473 100644
--- a/src/engine/__tests__/userFunctions.spec.ts
+++ b/src/engine/__tests__/userFunctions.spec.ts
@@ -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 & Pick): FunctionDef =>
- partial;
+const def = (
+ partial: Partial & Pick,
+): 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');
+ });
+});
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 2d1e45e..1cd9098 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -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 {
diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts
index 189595f..089f4f1 100644
--- a/src/engine/lexer.ts
+++ b/src/engine/lexer.ts
@@ -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 = {
';': 'SEMICOLON',
':': 'COLON',
'!': 'BANG',
+ '=': 'EQUALS',
};
function isDigit(ch: string): boolean {
diff --git a/src/engine/parser.ts b/src/engine/parser.ts
index c887b5b..0aac4aa 100644
--- a/src/engine/parser.ts
+++ b/src/engine/parser.ts
@@ -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;
+ * 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;
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 = []): Array {
switch (node.kind) {
diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts
index bec92a5..cf8d722 100644
--- a/src/engine/userFunctions.ts
+++ b/src/engine/userFunctions.ts
@@ -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): 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,
bodyCache: Map,
+ blockCache: Map,
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): 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(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 = 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 = new Set(),
+): Set {
+ 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;
+}
diff --git a/src/io/json.ts b/src/io/json.ts
index 2b19472..da59159 100644
--- a/src/io/json.ts
+++ b/src/io/json.ts
@@ -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;
}
diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts
index 234af9e..420dcd5 100644
--- a/src/store/__tests__/document.spec.ts
+++ b/src/store/__tests__/document.spec.ts
@@ -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' },
+ ]);
+ });
});
diff --git a/src/store/document.ts b/src/store/document.ts
index e323182..11666f6 100644
--- a/src/store/document.ts
+++ b/src/store/document.ts
@@ -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 {
+ function bodyCallSet(f: Pick): Set {
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;
+ 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>();
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();
}
diff --git a/src/store/types.ts b/src/store/types.ts
index 1ab03d4..2e4783d 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -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 {