diff --git a/README.md b/README.md
index d71762e..d7b86e3 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), 237 Tests
+npm run test # Vitest-Suite (Engine + Store + IO), 257 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -193,47 +193,69 @@ 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
+#### Mit Schleife: echter 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:
+auch **„Mit Schleife"** — echter Code mit Kontrollfluss, für Fälle, die eine
+einzelne Formel nicht ausdrücken kann: über die Elemente eines Bereichs
+selbst iterieren, verzweigen, vorzeitig abbrechen. 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).
+- **FOR-Schleifen** — `FOR var IN bereich DO … END`, mit beliebig vielen
+ Anweisungen dazwischen (auch weiteren Schleifen/`IF`s); `bereich` ist ein
+ Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck).
+- **IF/ELSE** — `IF bedingung THEN … END` bzw. mit `ELSE …` davor dem `END`,
+ verschachtelbar in **jedem** Block (auch innerhalb eines `FOR`-Körpers).
+- **RETURN** — `RETURN ausdruck`, an **beliebiger Stelle**, auch mitten in
+ einer Schleife oder einem `IF`-Zweig. Bricht die Ausführung sofort mit
+ diesem Wert ab; nichts danach läuft noch, auch keine weiteren
+ Schleifen-Durchläufe.
-— 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.
+— beliebig gemischt und verschachtelt. Jeder eingeführte Name (Zuweisungsziel
+oder Schleifenvariable) bleibt für den **restlichen** Code sichtbar, auch
+nach dem Ende „seines" `FOR`/`IF` (mit seinem zuletzt zugewiesenen Wert) —
+flacher Namensraum, kein Block-Scoping. `FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/
+`ELSE`/`RETURN` sind reservierte Wörter, nicht als Parameter-/Variablenname
+erlaubt. Für `IF`-Bedingungen gibt es Vergleichsoperatoren
+(`= <> > < >= <=`, Ergebnis `1`/`0`) — in **jeder** Formel nutzbar, nicht
+nur in Blöcken.
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
+FOR x IN bereich DO
+ ergebnis = ergebnis * x
+END
RETURN ergebnis
```
-aufgerufen als `=PRODUCT(Umsatz!B2:E6)`.
+aufgerufen als `=PRODUCT(Umsatz!B2:E6)`. Ein zweites Beispiel mit `IF` und
+frühem `RETURN` — die erste Zahl in einem Bereich über einer Schwelle finden,
+ohne den Rest des Bereichs überhaupt anzufassen:
-**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.
+```
+FOR x IN bereich DO
+ IF x > schwelle THEN
+ RETURN x
+ END
+END
+RETURN 0
+```
+
+**Kein Endlos-Risiko trotz echtem Kontrollfluss.** Anders als die einfache
+Formel-Variante (die vollständig zu einer statischen Formel „inlined" wird)
+läuft eine `FOR`/`IF`/`RETURN`-Funktion bei jeder Neuberechnung durch einen
+kleinen, echten Interpreter (`evaluator.ts`) — nötig, weil ein `IF`-Zweig
+bzw. ein frühes `RETURN` vom tatsächlichen Zellwert abhängt, nicht vorab
+berechenbar ist. Eine `FOR`-Schleife über insgesamt mehr als 10.000 Zellen
+(über alle Schleifen einer Funktion) ergibt `#ERR` statt zu hängen; eine
+Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat,
+ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
+nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
+[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
### Zell-Formatierung
@@ -301,7 +323,16 @@ fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen.
```ebnf
formula = "=" expression ;
-expression = term { ("+" | "-") term } ; (* links-assoziativ *)
+expression = comparison ;
+comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
+ ein Vergleich, nicht
+ verkettbar wie
+ "a < b < c"; vor allem
+ für IF-Bedingungen in
+ „Mit Schleife"-Code
+ (s. o.), aber in jeder
+ Formel nutzbar *)
+additive = term { ("+" | "-") term } ; (* links-assoziativ *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary
| power ;
@@ -328,7 +359,8 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
```
-Präzedenz schwach → stark: `+ -` < `* /` < unäres `+ -` < `^`.
+Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` <
+unäres `+ -` < `^`.
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**:
@@ -380,7 +412,9 @@ unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch
oben), `#DIV/0!`,
`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten
-Funktion), `#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!`
+Funktion, oder bei „Mit Schleife"-Code eine ungültige FOR-Quelle, mehr als
+10.000 Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN`),
+`#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!`
(Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere
Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen
benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er
@@ -404,7 +438,7 @@ Tabellen aller Blätter.
| `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. |
| `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. |
| `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. |
-| `userFunctions.ts` | Benutzerdefinierte Funktionen: `expandUserFunctions()` setzt einen Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure, Evaluator/Graph sehen danach eine ganz normale AST. |
+| `userFunctions.ts` | Benutzerdefinierte Funktionen: `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure. Ein `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) wird stattdessen zu einem `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen; sein eigener Kontrollfluss läuft erst zur Auswertungszeit, im kleinen Interpreter in `evaluator.ts` (echte Zellwerte nötig für `IF`/frühes `RETURN`). |
| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`; verdrahtet `userFunctions.ts` direkt nach `parseFormula()`. |
**Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` →
@@ -487,7 +521,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
-`npm run test` — Vitest, 237 Tests:
+`npm run test` — Vitest, 257 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -563,30 +597,44 @@ 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
+- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
+ unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler).
+- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
+ `FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
+ nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
+ verschachtelt in `FOR` mit einem `RETURN` mittendrin, `FOR` verschachtelt
+ in `FOR`, `RETURN` an beliebiger Stelle mit beliebigem Ausdruck, ein Name
+ aus einer früheren Anweisung in einer späteren Schleife nutzbar, ein Block
+ ganz ohne `RETURN` parst trotzdem (das ist eine Save-/Laufzeit-Prüfung,
+ kein Parse-Fehler); Fehler bei `FOR`/`IF` ohne passendes `END`/`DO`/`IN`/
+ `THEN`, fehlendem `=`, Selbstbezug im eigenen Initialisierer. Der
+ Interpreter (`evaluator.ts`, Workbook-Integration in
+ `userFunctions.spec.ts`): korrekter Wert und Neuberechnung bei Änderung
+ einer Zelle *innerhalb* des übergebenen Bereichs, ein `FOR`-Körper mit
+ mehreren Anweisungen läuft jede Iteration vollständig, `IF`/`ELSE`
+ verzweigt nach dem echten, aktuellen Zellwert, ein frühes `RETURN`
+ innerhalb eines `IF` innerhalb eines `FOR` bricht die Schleife wirklich ab
+ (eine erst *nach* dem Treffer geänderte Zelle bleibt folgenlos), eine Zelle,
+ die nur in einem **nicht** genommenen `IF`-Zweig steht, ist trotzdem eine
+ erfasste Abhängigkeit, verschachtelte `FOR`-Schleifen rechnen korrekt (nicht
+ nur parsen), ein verschachtelter Aufruf einer anderen Funktion im
+ Schleifenkörper wird mit-expandiert, Arity-Prüfung gilt auch hier, mehr als
+ 10.000 Schleifenschritte (über alle Schleifen einer Auswertung) sowie 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'`.
+ sowie eine Ausführung ohne je erreichtes `RETURN` ergeben alle `#ERR` statt
+ eines Absturzes. Store-Ebene: reservierte Wörter (`FOR`/`IN`/`DO`/`END`/
+ `IF`/`THEN`/`ELSE`/`RETURN`) als Parameter- oder lokaler Name abgelehnt (auch
+ wenn der lokale Name in einem verschachtelten `IF`-Zweig deklariert wird),
+ 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 (auch verschachtelt in einem `IF`)
+ wird abgelehnt, ein Codepfad ohne garantiertes `RETURN` (fehlt ganz, oder
+ nur ein `IF`-Zweig ohne `ELSE` returnt) wird beim Speichern abgelehnt — mit
+ `ELSE`, bei dem **beide** Zweige returnen, wird akzeptiert — 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
@@ -630,10 +678,13 @@ 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).
+- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
+ nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
+ bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
+ `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
+ Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
+ Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
+ dagegen unterstützt (s. o.).
## Projektstruktur
diff --git a/src/components/CustomFunctionsModal.vue b/src/components/CustomFunctionsModal.vue
index 331e823..c0ace01 100644
--- a/src/components/CustomFunctionsModal.vue
+++ b/src/components/CustomFunctionsModal.vue
@@ -164,21 +164,26 @@ 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,
+ Eine Folge aus Zuweisungen (name = ausdruck),
+ FOR-Schleifen (FOR var IN bereich DO … END,
+ beliebig viele Anweisungen dazwischen) und IF/ELSE
+ (IF bedingung THEN … ELSE … END, verschachtelbar in
+ jedem Block). RETURN ausdruck darf an beliebiger Stelle
+ stehen und bricht die Ausführung sofort ab — z. B. um eine Schleife
+ früh zu verlassen. Vergleiche mit = <> > < >= <=.
+ Jeder eingeführte Name bleibt für den Rest des Codes sichtbar, auch
+ nach seiner eigenen Schleife/seinem eigenen IF. Beispiel — Produkt
+ aller Elemente eines Bereichs:
+ ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x END / RETURN ergebnis,
aufgerufen als =PRODUCT(Umsatz!B2:E6).
diff --git a/src/engine/README.md b/src/engine/README.md
index d31000c..16e0468 100644
--- a/src/engine/README.md
+++ b/src/engine/README.md
@@ -16,11 +16,11 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|----------------|---------|
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
-| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
+| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" 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; `compileBlock()` rollt zusätzlich `kind: 'block'`-Funktionskörper (FOR-Schleifen) zu einem einzigen Ausdruck ab, siehe „Schleifen" unten. |
+| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, 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()`. |
@@ -33,7 +33,16 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
```ebnf
formula = "=" expression ;
-expression = term { ("+" | "-") term } ; (* links-assoziativ *)
+expression = comparison ;
+comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
+ ein Vergleich, nicht
+ verkettbar; Ergebnis
+ 1/0 (siehe Auswertungs-
+ modell unten) *)
+additive = term { ("+" | "-") term } ; (* links-assoziativ; die
+ alte "expression"-Regel,
+ bevor Vergleiche
+ existierten *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary
| power ;
@@ -68,7 +77,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
```
-Präzedenz, schwach → stark: `+ -` < `* /` < unäres `+ -` < `^`.
+Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < unäres `+ -` < `^`.
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
@@ -151,84 +160,139 @@ derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
-unverändert bleibt):
+unverändert bleibt) – mit echtem Kontrollfluss:
```ebnf
-functionBlock = { statement } "RETURN" expression ;
-statement = assignment | forLoop ;
+functionBlock = block ; (* ein Block = die ganze Funktion *)
+block = { statement } ;
+statement = assignment | forLoop | ifStmt | returnStmt ;
assignment = IDENT "=" expression ;
-forLoop = "FOR" IDENT "IN" expression "DO" assignment ;
+forLoop = "FOR" IDENT "IN" expression "DO" block "END" ;
+ifStmt = "IF" expression "THEN" block [ "ELSE" block ] "END" ;
+returnStmt = "RETURN" expression ;
```
-`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.
+`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/`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
+nutzt den `EQUALS`-Token (rein additiv – vorher ein `LexError`, kann also
+keine vorher gültige Formel beeinflussen; derselbe Token dient in
+`expression()` als Gleichheits-Vergleich, die beiden Grammatiken kollidieren
+nicht, weil eine Zuweisung nur an Anweisungs-Anfang steht). `RETURN` ist eine
+gewöhnliche **Anweisung**, keine feste Blockendung – sie darf überall
+stehen, auch verschachtelt in `FOR`/`IF`.
-**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:
+`parseFunctionBlock(src, paramNames)` (`parser.ts`) parst rekursiv (`parse-
+StatementBlock` ruft sich über `FOR`/`IF`-Körper selbst wieder auf): 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* der Funktion sichtbar, auch nach seinem
+eigenen `FOR`/`IF`. `AssignNode`/`ForNode`/`IfNode`/`ReturnNode`/`BlockNode`
+sind bewusst **nicht** Teil des `Node`-Unions – nur der eine Knoten, der
+tatsächlich in einer normalen Zell-AST landet, ist es:
-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`.
+```ts
+interface BlockCallNode {
+ kind: 'blockcall';
+ block: BlockNode; // bereits: verschachtelte Aufrufe anderer Funktionen aufgelöst
+ args: Node[]; // die echten Argument-Ausdrücke des Aufrufers
+ paramNames: string[];
+ label: string; // für Fehlermeldungen
+}
+```
-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.
+**Warum kein Abrollen mehr (wie noch bei einer früheren Version dieses
+Features):** ein `IF`-Zweig oder ein bedingtes `RETURN` hängt vom
+tatsächlichen Zellwert ab – das lässt sich nicht mehr rein aus der
+Bereichsgröße vorausberechnen. `userFunctions.ts#expand()`s `func`-Fall löst
+für `kind: 'block'` deshalb nur noch **verschachtelte Funktionsaufrufe**
+innerhalb des Blocks auf (`expandBlock()`, läuft wie `expand()` durch jede
+Anweisung/jeden verschachtelten `FOR`/`IF`-Körper, mit denselben "von innen
+nach außen"-Regeln wie bisher) und verpackt das Ergebnis unverändert in einen
+`BlockCallNode` – die *eigenen* Parameter dieser Funktion bleiben als
+`ParamNode` im Block stehen, erst zur Auswertungszeit gebunden.
-**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.
+**Der Interpreter (`evaluator.ts`)** läuft, wenn `evalScalar` auf einen
+`blockcall`-Knoten trifft:
+
+- `evaluateBlockCall(node, host)` baut die Startumgebung `env: Map` aus `paramNames`/`args` und ruft `execBlock`/`execStatement` auf.
+- Eine `assignment`/`return`/`ifstmt`-Bedingung wird über `substitute(expr,
+ env)` (aus `userFunctions.ts`, dafür **exportiert**) gegen `env` aufgelöst
+ und dann ganz normal `evalScalar`-ausgewertet; das Ergebnis wird (als
+ Literal-`Node`) zurück in `env` geschrieben.
+- Eine `forLoop` löst ihre (substituierte) Quelle über `rangeOrCellToCell-
+ Nodes()` in eine konkrete Zellliste auf (`expandRange`, wie
+ `evalArg` es für ein Bereichsargument tut) – ein Einzelzellbezug ergibt
+ genau eine Iteration – und führt `body` (ein vollständiger, ggf.
+ verschachtelter `BlockNode`) einmal je Zelle aus, in Zellreihenfolge.
+ Schleifenvariable und alle im Körper zugewiesenen Namen bleiben danach in
+ `env` (flacher Namensraum: sichtbar mit ihrem letzten Wert für den Rest der
+ Ausführung).
+- `return` wirft ein `ReturnSignal` (Wert), das nur `evaluateBlockCall`
+ fängt – ein Standard-"Unwind zu einer bekannten Grenze"-Muster für frühen
+ Ausstieg. Das bricht **sofort** jede laufende Schleife/jedes `IF` ab, egal
+ wie tief verschachtelt.
+- Läuft die Ausführung durch, ohne dass je `ReturnSignal` geworfen wurde,
+ wirft `evaluateBlockCall` selbst einen `Error` ("… endet ohne RETURN").
+
+Jeder `Error` hier (Schritt-Limit, ungültige FOR-Quelle, kein `RETURN`
+erreicht) propagiert unverändert zu `evaluateCell`s bestehendem catch-all in
+`workbook.ts` → `#ERR` – dieselbe Absicherung, die schon jeden anderen
+Auswertungsfehler abfängt, keine neue Fehlerbehandlung nötig.
+
+**Kein Endlos-Risiko trotz echter Schleife:** `MAX_LOOP_STEPS` (10.000,
+Summe aller Iterationen einer `evaluateBlockCall`-Ausführung, auch über
+verschachtelte Schleifen hinweg) wirft bei Überschreitung. Eine Kette
+verschachtelter `kind: 'block'`-Aufrufe bleibt zusätzlich durch
+`MAX_EXPANSION_DEPTH` (32, unverändert, greift beim Auflösen verschachtelter
+Aufrufe in `expandBlock`) begrenzt, sodass die Gesamtarbeit pro Formel immer
+endlich bleibt.
+
+**Abhängigkeiten:** `collectRefs()` (`parser.ts`) bekommt einen `blockcall`-
+Fall, der sowohl `.args` als auch – über die neue Hilfsfunktion
+`collectRefsInBlock` – jeden im Block erreichbaren Zellbezug einsammelt,
+rekursiv durch `FOR`/`IF`. Das ist bewusst **konservativ**: ein Bezug in
+einem zur Laufzeit nicht genommenen `IF`-Zweig zählt trotzdem als
+Abhängigkeit (genau wie bei einem gewöhnlichen `IF` in Excel) – lieber
+gelegentlich unnötig neu rechnen als je einen veralteten Wert zeigen.
**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).
+Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür,
+jeweils rekursiv durch verschachtelte `FOR`/`IF`-Körper:
+
+- `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 END`, wo „ergebnis" zweimal auftaucht,
+ aber **keine** Kollision ist).
+- `findInvalidForSource(block)` – die erste `FOR`-Quelle, die syntaktisch
+ weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
+- `collectCalledFunctionNamesInBlock(block)` – läuft das bestehende
+ `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des Blocks; von
+ der Store-Zyklusprüfung genutzt.
+- `blockAlwaysReturns(block)` – konservative statische Prüfung "erreicht
+ jeder Pfad garantiert ein `RETURN`": wahr, wenn der Block eine
+ `return`-Anweisung enthält, oder ein `IF` mit `ELSE`, bei dem **beide**
+ Zweige immer returnen. Eine `FOR`-Schleife allein reicht nie (könnte 0
+ Iterationen haben) – das ist eine Frühwarnung beim Speichern, die
+ Laufzeit-Prüfung oben ("… endet ohne RETURN" → `#ERR`) ist die tatsächliche
+ Absicherung.
+
+`document.ts`s `validateCustomFunction` prüft damit: jeder Name gültig/nicht
+zellartig/kein reserviertes Wort (`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/
+`RETURN`), kein *unterschiedlicher* lokaler Name überschattet einen
+Parameter (Wiederverwendung *desselben* Namens über mehrere Anweisungen ist
+dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
## Auswertungsmodell
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
+- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei
+ `+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in
+ einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in
+ jeder Formel gültig.
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
gefundene Fehler gewinnt).
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
@@ -257,7 +321,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/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle bzw. mehr als `MAX_UNROLL_STEPS` abgerollte Zellen |
+| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle, mehr als `MAX_LOOP_STEPS` Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN` |
| `#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__/lexer.spec.ts b/src/engine/__tests__/lexer.spec.ts
index 44a9ddd..795f74f 100644
--- a/src/engine/__tests__/lexer.spec.ts
+++ b/src/engine/__tests__/lexer.spec.ts
@@ -68,4 +68,24 @@ describe('lexer', () => {
it('throws on an unexpected character', () => {
expect(() => tokenize('A1 & B2')).toThrow();
});
+
+ it('tokenizes assignment/equality and comparison operators', () => {
+ expect(types('= < > <= >= <>')).toEqual([
+ 'EQUALS',
+ 'LT',
+ 'GT',
+ 'LTE',
+ 'GTE',
+ 'NEQ',
+ 'EOF',
+ ]);
+ });
+
+ it('prefers the two-character comparison operators over their single-character prefix', () => {
+ expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
+ expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
+ expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
+ expect(types('A1B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
+ });
});
diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts
index 307ce83..9818d7d 100644
--- a/src/engine/__tests__/parser.spec.ts
+++ b/src/engine/__tests__/parser.spec.ts
@@ -28,6 +28,10 @@ function sexpr(n: Node): string {
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
+ case 'blockcall':
+ // never appears in freshly-parsed input (see parser.ts's comment on
+ // BlockCallNode) -- exhaustiveness only, no test exercises this.
+ return `blockcall(${n.label})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
@@ -35,11 +39,19 @@ const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(
/** 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)})`;
+ switch (s.kind) {
+ case 'assign':
+ return `${s.name}=${sexpr(s.value)}`;
+ case 'return':
+ return `RETURN ${sexpr(s.value)}`;
+ case 'for':
+ return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
+ case 'ifstmt':
+ return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
+ }
}
function sexprBlock(b: BlockNode): string {
- return `{${b.statements.map(sexprStmt).join('; ')}} RETURN ${sexpr(b.ret)}`;
+ return `{${b.statements.map(sexprStmt).join('; ')}}`;
}
const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames));
@@ -180,45 +192,101 @@ describe('parser errors', () => {
});
});
-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';
+describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
+ it('parses all six comparison operators', () => {
+ expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
+ expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
+ expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
+ expect(parse('=A1=B1')).toBe('(>= cell(0,0) cell(1,0))');
+ expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
+ });
+
+ it('binds looser than + -', () => {
+ expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
+ });
+
+ it('is not chainable -- "a < b < c" is a parse error', () => {
+ expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
+ });
+});
+
+describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
+ it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
+ const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
expect(parseBlock(src, ['bereich'])).toBe(
- '{ergebnis=1; FOR(x IN param(bereich) DO ergebnis=(* param(ergebnis) param(x)))} RETURN param(ergebnis)',
+ '{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
+ );
+ });
+
+ it('a FOR body with multiple statements', () => {
+ const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
+ expect(parseBlock(src, ['bereich'])).toBe(
+ '{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
);
});
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';
+ 'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END 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)',
+ '{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('IF ... THEN ... END, no ELSE', () => {
+ const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
+ expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
});
- 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('IF ... THEN ... ELSE ... END', () => {
+ const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
+ expect(parseBlock(src, ['x'])).toBe(
+ '{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
+ );
+ });
+
+ it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
+ const src =
+ 'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
+ expect(parseBlock(src, ['bereich'])).toBe(
+ '{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
+ 'total=(+ param(total) param(x))}); RETURN param(total)}',
+ );
+ });
+
+ it('a FOR loop nested inside another FOR loop\'s body', () => {
+ const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
+ expect(parseBlock(src, ['r1', 'r2'])).toBe(
+ '{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
+ '{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
+ );
+ });
+
+ it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
+ expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
+ 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)',
- );
+ 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)',
+ expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END 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('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
+ expect(parseBlock('x = 1', [])).toBe('{x=1}');
+ });
+
+ it('throws on FOR without a matching END', () => {
+ expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
+ ParseError,
+ );
});
it('throws on FOR without a matching DO', () => {
@@ -226,11 +294,19 @@ describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
});
it('throws on FOR without IN', () => {
- expect(() => parseFunctionBlock('FOR x bereich DO y = x RETURN y', ['bereich'])).toThrow(
+ expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
+ it('throws on IF without THEN', () => {
+ expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
+ });
+
+ it('throws on IF without a matching END', () => {
+ expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
+ });
+
it('throws when an assignment is missing "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
});
diff --git a/src/engine/__tests__/userFunctions.spec.ts b/src/engine/__tests__/userFunctions.spec.ts
index 19b4473..8ff91be 100644
--- a/src/engine/__tests__/userFunctions.spec.ts
+++ b/src/engine/__tests__/userFunctions.spec.ts
@@ -248,63 +248,28 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
const productBlock = {
params: ['bereich'],
kind: 'block' as const,
- body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
+ body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
};
-describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
- it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
+describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
+ it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
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 },
+ expect(expanded.kind).toBe('blockcall');
+ if (expanded.kind !== 'blockcall') throw new Error('unreachable');
+ expect(expanded.label).toBe('PRODUCT2');
+ expect(expanded.paramNames).toEqual(['bereich']);
+ expect(expanded.args).toEqual([
+ {
+ kind: 'range',
+ qualifier: null,
+ start: { col: 0, row: 0, colAbs: false, rowAbs: false },
+ end: { col: 0, row: 2, colAbs: false, rowAbs: false },
},
- 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', () => {
+ it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
const index = buildFunctionIndex([
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
def({
@@ -312,93 +277,37 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
name: 'SUMDOUBLED',
params: ['bereich'],
kind: 'block',
- body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
+ body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END 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({
+ if (expanded.kind !== 'blockcall') throw new Error('unreachable');
+ const forStmt = expanded.block.statements[1];
+ if (forStmt.kind !== 'for') throw new Error('expected a for statement');
+ const assignInBody = forStmt.body.statements[0];
+ if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
+ // DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
+ // "DOUBLE" remains anywhere in the block
+ expect(assignInBody.value).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 },
- },
- },
+ left: { kind: 'param', name: 'total' },
right: {
kind: 'binary',
op: '*',
- left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
+ left: { kind: 'param', name: 'x' },
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', () => {
+describe('Workbook integration: block-kind (FOR/IF/RETURN "code") 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 })]);
@@ -412,7 +321,107 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
});
- it('exceeding the unroll cap degrades to #ERR at the cell, not a crash', () => {
+ it('a FOR body with multiple statements runs all of them, every iteration', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
+ const doubleSum = def({
+ id: 'f1',
+ name: 'DOUBLESUM',
+ params: ['bereich'],
+ kind: 'block',
+ body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
+ });
+ const wb = new Workbook([umsatz], [doubleSum]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
+ });
+
+ it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', {
+ A1: '2',
+ A2: '3',
+ B1: '10',
+ B2: '20',
+ B9: '=OUTERPROD(A1:A2; B1:B2)',
+ });
+ const outerProd = def({
+ id: 'f1',
+ name: 'OUTERPROD',
+ params: ['r1', 'r2'],
+ kind: 'block',
+ body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
+ });
+ const wb = new Workbook([umsatz], [outerProd]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
+ });
+
+ it('IF/ELSE branches on the real, current cell value', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
+ const sign2 = def({
+ id: 'f1',
+ name: 'SIGN2',
+ params: ['x'],
+ kind: 'block',
+ body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
+ });
+ const wb = new Workbook([umsatz], [sign2]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(1);
+ wb.setCell('s-umsatz', 0, 0, '-5');
+ expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
+ });
+
+ it('comparison operators work inside an IF condition', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
+ const eq2 = def({
+ id: 'f1',
+ name: 'EQ2',
+ params: ['a', 'b'],
+ kind: 'block',
+ body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
+ });
+ const wb = new Workbook([umsatz], [eq2]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(1);
+ wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
+ expect(val(wb, 's-umsatz', 'B9')).toBe(0);
+ });
+
+ it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
+ // A3 is the first cell > 100; A4 must never affect the result
+ const umsatz = sheet('s-umsatz', 'Umsatz', {
+ A1: '1',
+ A2: '2',
+ A3: '999',
+ A4: '4',
+ B9: '=FIRSTOVER(A1:A4; 100)',
+ });
+ const firstOver = def({
+ id: 'f1',
+ name: 'FIRSTOVER',
+ params: ['bereich', 'schwelle'],
+ kind: 'block',
+ body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
+ });
+ const wb = new Workbook([umsatz], [firstOver]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(999);
+ // changing the never-reached A4 doesn't change the (already-found) result
+ wb.setCell('s-umsatz', 0, 3, '99999');
+ expect(val(wb, 's-umsatz', 'B9')).toBe(999);
+ });
+
+ it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
+ const branchy = def({
+ id: 'f1',
+ name: 'BRANCHY',
+ params: ['x'],
+ kind: 'block',
+ body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
+ });
+ const wb = new Workbook([umsatz], [branchy]);
+ expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
+ wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
+ expect(val(wb, 's-umsatz', 'B9')).toBe(2);
+ });
+
+ it('exceeding the loop-step 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');
@@ -428,10 +437,17 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
name: 'BAD',
params: ['x'],
kind: 'block',
- body: 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
+ body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
}),
],
);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
+
+ it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
+ const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
+ const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
+ const wb = new Workbook([umsatz], [noReturn]);
+ expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
+ });
});
diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts
index 9012f9c..44a0a2a 100644
--- a/src/engine/evaluator.ts
+++ b/src/engine/evaluator.ts
@@ -11,16 +11,26 @@
* ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`.
+ * - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
* - Any error among operands / arguments propagates unchanged (first wins).
* - A range may only appear as a function argument. Used in scalar position it
* yields `#VALUE!`.
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - Division by zero -> `#DIV/0!`.
+ *
+ * `kind: 'block'` custom functions (FOR/IF/RETURN "code" bodies -- see
+ * userFunctions.ts and src/engine/README.md's "Schleifen" section) are the
+ * one thing here that isn't a pure, static AST walk: a `BlockCallNode`
+ * embedded in a cell's AST is *interpreted* right here, at evaluation time
+ * (`evaluateBlockCall`, below `evalScalar`) -- real cell values are needed
+ * to decide an `IF` branch or when an early `RETURN` fires, so unlike an
+ * ordinary function call this can't be resolved any earlier.
*/
-import type { Node } from './parser';
+import type { BlockCallNode, BlockNode, CellNode, Node, Statement } from './parser';
import { getFunction } from './functions';
import { expandRange } from './refs';
+import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue {
@@ -61,6 +71,18 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return l * r;
case '/':
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
+ case '=':
+ return l === r ? 1 : 0;
+ case '<>':
+ return l !== r ? 1 : 0;
+ case '>':
+ return l > r ? 1 : 0;
+ case '<':
+ return l < r ? 1 : 0;
+ case '>=':
+ return l >= r ? 1 : 0;
+ case '<=':
+ return l <= r ? 1 : 0;
}
return new FormulaError('#ERR', `unknown operator ${node.op}`);
}
@@ -99,9 +121,141 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// in practice. Fails as a cell error rather than however an unhandled
// case would otherwise behave, matching the engine's "no crash" rule.
return new FormulaError('#ERR', `unresolved parameter ${node.name}`);
+
+ case 'blockcall':
+ return evaluateBlockCall(node, host);
}
}
+// ---------------------------------------------------------------------------
+// `kind: 'block'` custom functions: a small tree-walking interpreter for a
+// BlockCallNode's `block` (see parser.ts's grammar comment and
+// src/engine/README.md's "Schleifen" section). Everything else in this file
+// is a pure, static AST walk; this is the one place real control flow
+// (branching, early exit) happens, so it needs real cell values as it goes
+// -- unlike a `kind: 'expression'` call, which is fully resolved to a plain
+// expression at parse time and never reaches any special-case code here.
+// ---------------------------------------------------------------------------
+
+/** Thrown by a `return` statement to unwind out of `execBlock`/`execStatement`
+ * immediately, carrying the function's result. Caught only by
+ * `evaluateBlockCall`, right where it's thrown from -- a plain, standard
+ * "unwind to a known boundary" pattern for early-exit control flow. */
+class ReturnSignal {
+ constructor(public readonly value: CellValue) {}
+}
+
+/** Total loop iterations one `evaluateBlockCall` may run, summed across
+ * every FOR loop (including nested ones) it executes -- 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 separately bounded by expandUserFunctions()'s own
+ * MAX_EXPANSION_DEPTH, so total work across one formula stays finite
+ * either way. Generous for anything this app's tables realistically hold. */
+const MAX_LOOP_STEPS = 10_000;
+
+function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
+ const env = new Map(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]]));
+ const budget = { steps: 0 };
+ try {
+ execBlock(node.block, env, host, budget);
+ } catch (e) {
+ if (e instanceof ReturnSignal) return e.value;
+ throw e; // a real problem (bad FOR source, step budget, ...) -- propagates
+ // to evaluateCell()'s existing catch-all in workbook.ts, becoming #ERR,
+ // exactly like a plain evaluation exception already does.
+ }
+ throw new Error(`'${node.label}' ended without RETURN`);
+}
+
+function execBlock(block: BlockNode, env: Map, host: EngineHost, budget: { steps: number }): void {
+ for (const stmt of block.statements) {
+ execStatement(stmt, env, host, budget);
+ }
+}
+
+/** Resolve `expr` against the current environment (substitute() replaces
+ * any name reference with its bound Node -- the caller's argument, or an
+ * earlier statement's/iteration's literal result) and evaluate it. */
+function evalInEnv(expr: Node, env: Map, host: EngineHost): CellValue {
+ return evalScalar(substitute(expr, env), host);
+}
+
+/** Wrap an already-computed scalar back into a literal `Node` so it can be
+ * stored in `env` and later be resolved/evaluated exactly like any other
+ * bound name (a parameter, or the caller's argument expression). `null`
+ * (an empty cell's value) becomes the number `0`, matching how every other
+ * arithmetic context in this engine already treats an empty cell. Callers
+ * check `isError` before ever calling this -- an error value is never
+ * passed in (it aborts the whole function via `ReturnSignal` instead). */
+function literalNodeOf(v: number | string | null): Node {
+ if (v === null) return { kind: 'number', value: 0 };
+ return typeof v === 'number' ? { kind: 'number', value: v } : { kind: 'string', value: v };
+}
+
+function execStatement(
+ stmt: Statement,
+ env: Map,
+ host: EngineHost,
+ budget: { steps: number },
+): void {
+ switch (stmt.kind) {
+ case 'assign': {
+ const v = evalInEnv(stmt.value, env, host);
+ if (isError(v)) throw new ReturnSignal(v); // an error in a plain assignment aborts the function with that error
+ env.set(stmt.name.toUpperCase(), literalNodeOf(v));
+ return;
+ }
+ case 'return': {
+ const v = evalInEnv(stmt.value, env, host);
+ throw new ReturnSignal(v);
+ }
+ case 'ifstmt': {
+ const c = evalInEnv(stmt.cond, env, host);
+ if (isError(c)) throw new ReturnSignal(c);
+ const truthy = typeof c === 'number' ? c !== 0 : c !== null && c !== '';
+ if (truthy) execBlock(stmt.then, env, host, budget);
+ else if (stmt.else) execBlock(stmt.else, env, host, budget);
+ return;
+ }
+ case 'for': {
+ const source = substitute(stmt.source, env);
+ const cells = rangeOrCellToCellNodes(source, stmt.varName);
+ for (const cell of cells) {
+ budget.steps++;
+ if (budget.steps > MAX_LOOP_STEPS) {
+ throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`);
+ }
+ env.set(stmt.varName.toUpperCase(), cell);
+ execBlock(stmt.body, env, host, budget);
+ }
+ return;
+ }
+ }
+}
+
+/** A FOR loop's source, once names are resolved against the current
+ * environment, 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`
+ * (the same helper `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`);
+}
+
/** Evaluate one function argument into a flat list of values (ranges expand). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
if (node.kind === 'range') {
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 1cd9098..ed0be78 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -40,6 +40,7 @@ export {
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
+ blockAlwaysReturns,
type FunctionDef,
} from './userFunctions';
export {
diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts
index 089f4f1..e16ed8b 100644
--- a/src/engine/lexer.ts
+++ b/src/engine/lexer.ts
@@ -24,7 +24,12 @@ export type TokenType =
| 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG'
- | 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
+ | 'EQUALS' // "=" -- assignment (block body) or equality comparison
+ | 'NEQ' // "<>"
+ | 'LT' // "<"
+ | 'GT' // ">"
+ | 'LTE' // "<="
+ | 'GTE' // ">="
| 'EOF';
export interface Token {
@@ -49,6 +54,8 @@ const SINGLE: Record = {
':': 'COLON',
'!': 'BANG',
'=': 'EQUALS',
+ '<': 'LT',
+ '>': 'GT',
};
function isDigit(ch: string): boolean {
@@ -155,6 +162,24 @@ export function tokenize(src: string): Token[] {
continue;
}
+ // two-character comparison operators -- must be checked before the
+ // single-character "<"/">" fallback below
+ if (ch === '<' && src[i + 1] === '=') {
+ tokens.push({ type: 'LTE', value: '<=', pos: i });
+ i += 2;
+ continue;
+ }
+ if (ch === '>' && src[i + 1] === '=') {
+ tokens.push({ type: 'GTE', value: '>=', pos: i });
+ i += 2;
+ continue;
+ }
+ if (ch === '<' && src[i + 1] === '>') {
+ tokens.push({ type: 'NEQ', value: '<>', pos: i });
+ i += 2;
+ continue;
+ }
+
// single-character punctuation / operators
const t = SINGLE[ch];
if (t) {
diff --git a/src/engine/parser.ts b/src/engine/parser.ts
index 0aac4aa..471c3bb 100644
--- a/src/engine/parser.ts
+++ b/src/engine/parser.ts
@@ -6,7 +6,16 @@
* ---------------------------------------------------------------------------
* formula = "=" expression ;
*
- * expression = term { ("+" | "-") term } ; (* left assoc *)
+ * expression = comparison ;
+ * comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ;
+ * (* at most one comparison,
+ * not chainable like
+ * "a < b < c"; mainly
+ * useful in a "block"
+ * function body's IF
+ * condition, but valid
+ * in any formula *)
+ * additive = term { ("+" | "-") term } ; (* left assoc *)
* term = unary { ("*" | "/") unary } ; (* left assoc *)
* unary = ("+" | "-") unary
* | power ;
@@ -40,7 +49,7 @@
* see reference() below). *)
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
*
- * Precedence, lowest to highest: + - < * / < unary +/- < ^
+ * Precedence, lowest to highest: comparison < + - < * / < unary +/- < ^
* ---------------------------------------------------------------------------
*
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
@@ -63,7 +72,8 @@ export type Node =
| FuncNode
| CellNode
| RangeNode
- | ParamNode;
+ | ParamNode
+ | BlockCallNode;
export interface NumberNode {
kind: 'number';
@@ -75,7 +85,7 @@ export interface StringNode {
}
export interface BinaryNode {
kind: 'binary';
- op: '+' | '-' | '*' | '/' | '^';
+ op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
left: Node;
right: Node;
}
@@ -121,42 +131,78 @@ export interface ParamNode {
}
// ---------------------------------------------------------------------------
-// "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.
+// "Block" function bodies (`kind: 'block'` custom functions) -- a small
+// statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
+// ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
+// a block is parsed by parseFunctionBlock() (a distinct entry point from
+// parseFormula()), and -- unlike Phase 1's single-expression bodies, which
+// are fully inlined at parse time -- a block is only *partly* resolved at
+// parse time (nested calls to other functions) and then *interpreted* at
+// evaluation time by evaluator.ts, since a conditional/early RETURN depends
+// on real cell values that aren't known until then. `BlockCallNode` (further
+// below) is the one piece of this that *does* end up embedded in a normal
+// cell AST and so *is* part of `Node`. See src/engine/README.md's
+// "Schleifen" section for the full design.
// ---------------------------------------------------------------------------
-/** `name = value` -- a plain local-variable assignment, or (nested inside a
- * ForNode) one loop step. */
+/** `name = value` -- a local-variable assignment. */
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. */
+/** `RETURN value` -- may appear anywhere in a block (top level or nested in
+ * a FOR/IF), not just at the end; ends the function's execution immediately
+ * with this value once reached. */
+export interface ReturnNode {
+ kind: 'return';
+ value: Node;
+}
+/** `FOR varName IN source DO body END` -- source must resolve, once
+ * parameters are substituted with the caller's real arguments, to a single
+ * cell or a range (checked by userFunctions.ts at save time, enforced by
+ * the interpreter at call time); `body` runs once per cell, in order. */
export interface ForNode {
kind: 'for';
varName: string;
source: Node;
- body: AssignNode;
+ body: BlockNode;
}
-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. */
+/** `IF cond THEN then [ELSE else] END`. May appear in any block, including
+ * nested inside a FOR's body or another IF's branch. */
+export interface IfNode {
+ kind: 'ifstmt';
+ cond: Node;
+ then: BlockNode;
+ else: BlockNode | null;
+}
+export type Statement = AssignNode | ForNode | IfNode | ReturnNode;
+/** A sequence of statements -- a whole function body, or the body of a
+ * FOR/IF. Every name a statement introduces (assignment target or loop
+ * variable) stays visible for the *rest* of the execution, including after
+ * its own FOR/IF ends (with its last value) -- flat scope, no nested block
+ * scoping. */
export interface BlockNode {
kind: 'block';
statements: Statement[];
- ret: Node;
+}
+/** The one block-related node that *is* part of `Node`: embedded into a
+ * normal cell AST wherever a `kind: 'block'` custom function is called
+ * (userFunctions.ts#expand(), replacing what a `kind: 'expression'` call
+ * would have inlined as a plain expression). `block` has already had every
+ * *other* function call it contains resolved (built-ins untouched, other
+ * custom functions inlined/turned into their own BlockCallNode) -- only
+ * this function's *own* parameters, referenced via ParamNode inside
+ * `block`, remain to be bound, which evaluator.ts's interpreter does at
+ * evaluation time using `args`/`paramNames`. */
+export interface BlockCallNode {
+ kind: 'blockcall';
+ block: BlockNode;
+ args: Node[];
+ paramNames: string[];
+ /** the function's own name, purely for a clearer "ended without RETURN"
+ * error message. */
+ label: string;
}
export class ParseError extends Error {}
@@ -198,26 +244,32 @@ class Parser {
return node;
}
- /** Entry point for a "block" function body: `{ statement } "RETURN" expression`.
- * See parseFunctionBlock() below. */
+ /** Entry point for a "block" function body: a plain statement sequence
+ * (`RETURN` is just one of the statement kinds now, usable anywhere --
+ * see parseFunctionBlock() below). */
parseBlock(): BlockNode {
- const statements: Statement[] = [];
- while (!this.isKeyword('RETURN')) {
- statements.push(this.parseStatement());
- }
- this.next(); // consume RETURN
- const ret = this.expression();
+ const block = this.parseStatementBlock([]);
if (!this.at('EOF')) {
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
}
- return { kind: 'block', statements, ret };
+ return block;
+ }
+
+ /** Parses statements until the current token is EOF or one of `stopWords`
+ * (a keyword that ends the enclosing construct -- "END"/"ELSE" -- left
+ * *unconsumed* for the caller to check/consume itself). */
+ private parseStatementBlock(stopWords: readonly string[]): BlockNode {
+ const statements: Statement[] = [];
+ while (!this.at('EOF') && !stopWords.some((w) => this.isKeyword(w))) {
+ statements.push(this.parseStatement());
+ }
+ return { kind: 'block', statements };
}
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}`);
- }
+ if (this.isKeyword('IF')) return this.parseIfStmt();
+ if (this.isKeyword('RETURN')) return this.parseReturnStmt();
return this.parseAssignment();
}
@@ -235,34 +287,91 @@ class Parser {
return { kind: 'assign', name: nameTok.value, value };
}
+ private parseReturnStmt(): ReturnNode {
+ this.next(); // consume RETURN
+ const value = this.expression();
+ return { kind: 'return', 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
+ this.expectKeyword('IN', `FOR ${varTok.value}`);
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();
+ this.expectKeyword('DO', 'the FOR source');
+ this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
+ const body = this.parseStatementBlock(['END']);
+ this.expectKeyword('END', 'the FOR body');
return { kind: 'for', varName: varTok.value, source, body };
}
+ private parseIfStmt(): IfNode {
+ this.next(); // consume IF
+ const cond = this.expression();
+ this.expectKeyword('THEN', 'the IF condition');
+ const then = this.parseStatementBlock(['ELSE', 'END']);
+ let elseBlock: BlockNode | null = null;
+ if (this.isKeyword('ELSE')) {
+ this.next();
+ elseBlock = this.parseStatementBlock(['END']);
+ }
+ this.expectKeyword('END', 'the IF body');
+ return { kind: 'ifstmt', cond, then, else: elseBlock };
+ }
+
/** 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). */
+ * (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/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;
}
+ /** Consumes `word` as a keyword, or throws a ParseError naming what came
+ * before it (for a clearer message than a generic "expected IDENT"). */
+ private expectKeyword(word: string, afterWhat: string): void {
+ if (!this.isKeyword(word)) {
+ throw new ParseError(`Expected ${word} after ${afterWhat} at ${this.peek().pos}`);
+ }
+ this.next();
+ }
+
+ /** Public entry point for every expression context (function args,
+ * parens, assignment RHS, ...) -- wraps `additive` with an optional
+ * single comparison, the loosest-binding operator. */
private expression(): Node {
+ const left = this.additive();
+ const opType = this.comparisonOpType();
+ if (!opType) return left;
+ this.next();
+ return { kind: 'binary', op: opType, left, right: this.additive() };
+ }
+
+ private comparisonOpType(): BinaryNode['op'] | null {
+ switch (this.peek().type) {
+ case 'EQUALS':
+ return '=';
+ case 'NEQ':
+ return '<>';
+ case 'GT':
+ return '>';
+ case 'LT':
+ return '<';
+ case 'GTE':
+ return '>=';
+ case 'LTE':
+ return '<=';
+ default:
+ return null;
+ }
+ }
+
+ /** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
+ * `expression` itself gained the comparison wrapper above it. */
+ private additive(): Node {
let left = this.term();
while (this.at('PLUS') || this.at('MINUS')) {
const op = this.next().type === 'PLUS' ? '+' : '-';
@@ -450,9 +559,10 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
}
/**
- * 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
+ * Parse a "block" function body -- the statement grammar (assignments,
+ * `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
+ * `kind: 'block'` custom function (see src/engine/userFunctions.ts,
+ * evaluator.ts's interpreter, and CustomFunctionsModal.vue). The leading
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
* {@link LexError} on malformed input, same conventions as parseFormula().
*/
@@ -468,7 +578,11 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]):
return new Parser(toks, paramNames).parseBlock();
}
-/** Walk an AST and collect every cell/range reference node it contains. */
+/** Walk an AST and collect every cell/range reference node it contains.
+ * For a `blockcall`, walks both its (already-resolved) `args` and every
+ * expression reachable inside its `block` -- conservatively: a reference
+ * inside a branch/loop that might not execute at runtime still counts as a
+ * dependency, same as an ordinary `IF`-style formula in any spreadsheet. */
export function collectRefs(node: Node, out: Array = []): Array {
switch (node.kind) {
case 'cell':
@@ -485,6 +599,32 @@ export function collectRefs(node: Node, out: Array = []):
case 'func':
node.args.forEach((a) => collectRefs(a, out));
break;
+ case 'blockcall':
+ node.args.forEach((a) => collectRefs(a, out));
+ collectRefsInBlock(node.block, out);
+ break;
}
return out;
}
+
+function collectRefsInBlock(block: BlockNode, out: Array): void {
+ for (const stmt of block.statements) {
+ switch (stmt.kind) {
+ case 'assign':
+ collectRefs(stmt.value, out);
+ break;
+ case 'return':
+ collectRefs(stmt.value, out);
+ break;
+ case 'for':
+ collectRefs(stmt.source, out);
+ collectRefsInBlock(stmt.body, out);
+ break;
+ case 'ifstmt':
+ collectRefs(stmt.cond, out);
+ collectRefsInBlock(stmt.then, out);
+ if (stmt.else) collectRefsInBlock(stmt.else, out);
+ break;
+ }
+ }
+}
diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts
index cf8d722..4ad0fff 100644
--- a/src/engine/userFunctions.ts
+++ b/src/engine/userFunctions.ts
@@ -6,35 +6,35 @@
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
* which has no room for a "body formula with its own cell references and a
- * parameter environment". Rather than rework the evaluator, a call to a
- * user-defined function is *inlined* into the caller's AST at parse time,
- * before the graph/evaluator ever see it: the `FuncNode` is replaced by a
- * (recursively expanded) copy of the function body, with every `ParamNode`
- * swapped for the caller's own argument subtree. After that, the stored AST
- * looks exactly as if the user had written the expanded formula by hand --
- * `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
- * and correctly pick up cell references that live *only* inside the
- * function body, not just the ones passed as arguments.
+ * parameter environment". Rather than rework the evaluator, a `kind:
+ * 'expression'` call (a single-formula body) is *inlined* into the caller's
+ * AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
+ * is replaced by a (recursively expanded) copy of the function body, with
+ * every `ParamNode` swapped for the caller's own argument subtree. After
+ * that, the stored AST looks exactly as if the user had written the
+ * expanded formula by hand -- `collectRefs`/`precedentsOf`/`evaluate` all
+ * work completely unmodified, and correctly pick up cell references that
+ * live *only* inside the function body, not just the ones passed as
+ * arguments.
*
- * 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.
+ * A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
+ * `parseFunctionBlock`) is different: this module only resolves *nested*
+ * function calls inside it (`expandBlock`, below) -- still at parse time,
+ * still the same "inside-out" inlining -- and wraps the result in a
+ * `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
+ * branching, an early RETURN) is only resolved at evaluation time, by a
+ * small interpreter in `evaluator.ts`, since it genuinely depends on real
+ * cell values. See src/engine/README.md's "Schleifen" section for the full
+ * design and why that split is necessary.
*/
import {
parseFormula,
parseFunctionBlock,
type BlockNode,
- type CellNode,
type ForNode,
type Node,
} from './parser';
-import { expandRange } from './refs';
export interface FunctionDef {
id: string;
@@ -43,23 +43,14 @@ export interface FunctionDef {
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=" -- a single expression for
- * `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
- * `kind: 'block'`. */
+ * `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
+ * IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
body: string;
- /** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
- * capable). Every function predating this field is 'expression'. */
+ /** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
+ * "code"). 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
@@ -71,10 +62,13 @@ export function buildFunctionIndex(defs: readonly FunctionDef[]): Map): Node {
+/**
+ * Replace every `ParamNode` in `node` matching a name in `bound` with its
+ * bound subtree. Exported for evaluator.ts's block interpreter, which reuses
+ * this exact mechanism to resolve names (parameters *and* local variables --
+ * it doesn't distinguish the two, both are just entries in its environment
+ * map) against the current execution state before evaluating an expression.
+ */
+export function substitute(node: Node, bound: ReadonlyMap): Node {
switch (node.kind) {
case 'param':
// always found in practice (arity is checked before this runs, and
@@ -179,78 +186,52 @@ function substitute(node: Node, bound: ReadonlyMap): Node {
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
case 'func':
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
+ case 'blockcall':
+ // only `.args` can still reference an *outer* scope's parameter (e.g.
+ // this call sits inside another function's own body, referencing
+ // *that* function's parameter) -- `.block` itself only refers to this
+ // call's *own* parameters, a separate scope bound later, at
+ // evaluation time, so it's left untouched here.
+ return { ...node, args: node.args.map((a) => substitute(a, bound)) };
}
}
-/**
- * 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`);
+/** {@link expand}, run over every leaf expression a `BlockNode` contains
+ * (each statement's value/condition/source, recursing into nested FOR/IF
+ * bodies) instead of over a single `Node` -- resolves nested function
+ * calls throughout a block-kind function's body before it's wrapped in a
+ * `BlockCallNode`. */
+function expandBlock(
+ block: BlockNode,
+ index: ReadonlyMap,
+ bodyCache: Map,
+ blockCache: Map,
+ depth: number,
+): BlockNode {
+ return {
+ kind: 'block',
+ statements: block.statements.map((stmt) => {
+ switch (stmt.kind) {
+ case 'assign':
+ return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
+ case 'return':
+ return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
+ case 'for':
+ return {
+ ...stmt,
+ source: expand(stmt.source, index, bodyCache, blockCache, depth),
+ body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
+ };
+ case 'ifstmt':
+ return {
+ ...stmt,
+ cond: expand(stmt.cond, index, bodyCache, blockCache, depth),
+ then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
+ else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
+ };
+ }
+ }),
+ };
}
/**
@@ -273,40 +254,60 @@ export function collectCalledFunctionNames(ast: Node, out: Set = new Set
collectCalledFunctionNames(ast.operand, out);
break;
default:
- break; // number/string/cell/range/param -- no nested calls
+ break; // number/string/cell/range/param/blockcall -- no nested calls
}
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[] = [];
+ * in the order first declared, recursing into nested FOR/IF bodies. Used
+ * by the store to validate these local names the same way it validates
+ * parameters (not cell-shaped, no collision with a parameter, 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. The *same*
+ * name legitimately reappears (an accumulator re-assigned inside a loop,
+ * or across two separate loops) -- that's not the store's concern either,
+ * it's intentional under the flat-scope design (see parser.ts). */
+export function declaredBlockNames(block: BlockNode, out: string[] = []): string[] {
for (const stmt of block.statements) {
- if (stmt.kind === 'assign') {
- out.push(stmt.name);
- } else {
- out.push(stmt.varName, stmt.body.name);
+ switch (stmt.kind) {
+ case 'assign':
+ out.push(stmt.name);
+ break;
+ case 'for':
+ out.push(stmt.varName);
+ declaredBlockNames(stmt.body, out);
+ break;
+ case 'ifstmt':
+ declaredBlockNames(stmt.then, out);
+ if (stmt.else) declaredBlockNames(stmt.else, out);
+ break;
+ case 'return':
+ break;
}
}
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
+/** The first FOR loop, anywhere in the block (including nested inside
+ * another FOR/IF), 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; the evaluator's
+ * interpreter re-checks the same thing on the *substituted* source at call
+ * time regardless (needed either way, 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;
+ if (stmt.kind === 'for') {
+ if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
+ return stmt;
+ }
+ const nested = findInvalidForSource(stmt.body);
+ if (nested) return nested;
+ } else if (stmt.kind === 'ifstmt') {
+ const nested = findInvalidForSource(stmt.then) ?? (stmt.else && findInvalidForSource(stmt.else));
+ if (nested) return nested;
}
}
return null;
@@ -314,21 +315,49 @@ export function findInvalidForSource(block: BlockNode): ForNode | 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. */
+ * value/condition/source, recursing into nested FOR/IF bodies) 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);
+ switch (stmt.kind) {
+ case 'assign':
+ case 'return':
+ collectCalledFunctionNames(stmt.value, out);
+ break;
+ case 'for':
+ collectCalledFunctionNames(stmt.source, out);
+ collectCalledFunctionNamesInBlock(stmt.body, out);
+ break;
+ case 'ifstmt':
+ collectCalledFunctionNames(stmt.cond, out);
+ collectCalledFunctionNamesInBlock(stmt.then, out);
+ if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
+ break;
}
}
- collectCalledFunctionNames(block.ret, out);
return out;
}
+
+/**
+ * Conservative static check: does *every* execution path through `block`
+ * reach a `return`? True if the block contains a `return` statement
+ * (statements after it are unreachable but harmless), or an `IF` with an
+ * `ELSE` where *both* branches always return. A `FOR` loop is never enough
+ * on its own -- even if its body always returns when it runs, the loop
+ * might cover zero cells at call time, which this static check can't see
+ * (the interpreter's own "ended without RETURN" -> `#ERR` is the actual,
+ * always-correct enforcement; this is only an early, save-time warning).
+ */
+export function blockAlwaysReturns(block: BlockNode): boolean {
+ return block.statements.some((stmt) => {
+ if (stmt.kind === 'return') return true;
+ if (stmt.kind === 'ifstmt' && stmt.else) {
+ return blockAlwaysReturns(stmt.then) && blockAlwaysReturns(stmt.else);
+ }
+ return false;
+ });
+}
diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts
index 420dcd5..6066413 100644
--- a/src/store/__tests__/document.spec.ts
+++ b/src/store/__tests__/document.spec.ts
@@ -815,10 +815,10 @@ 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';
+describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
+ const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
- it('adds a block-kind function and a call evaluates through the unrolled loop', () => {
+ it('adds a block-kind function and a call evaluates through the loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
@@ -870,7 +870,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['bereich'],
- 'do = 1 FOR x IN bereich DO do = do * x RETURN do',
+ 'do = 1 FOR x IN bereich DO do = do * x END RETURN do',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -881,7 +881,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['bereich'],
- 'A1 = 1 FOR x IN bereich DO A1 = A1 * x RETURN A1',
+ 'A1 = 1 FOR x IN bereich DO A1 = A1 * x END RETURN A1',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -892,7 +892,18 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'SHADOWTEST',
['bereich'],
- 'bereich = 1 FOR x IN bereich DO bereich = bereich * x RETURN bereich',
+ 'bereich = 1 FOR x IN bereich DO bereich = bereich * x END RETURN bereich',
+ 'block',
+ ) as { error: string };
+ expect(result.error).toBeTruthy();
+ });
+
+ it('rejects a local name (declared inside an IF branch) that looks like a cell address', () => {
+ const s = fresh();
+ const result = s.addCustomFunction(
+ 'F',
+ ['x'],
+ 'IF x > 0 THEN B2 = 1 RETURN B2 ELSE RETURN 0 END',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
@@ -912,13 +923,24 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction(
'F',
['x'],
- 'total = 0 FOR y IN x + 1 DO total = total + y RETURN total',
+ 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
'block',
) as { error: string };
expect(result.error).toBeTruthy();
});
- it('rejects malformed code (missing RETURN)', () => {
+ it('rejects a FOR source nested inside an IF that is structurally invalid', () => {
+ const s = fresh();
+ const result = s.addCustomFunction(
+ 'F',
+ ['x'],
+ 'total = 0 IF x > 0 THEN FOR y IN x + 1 DO total = total + y END END RETURN total',
+ 'block',
+ ) as { error: string };
+ expect(result.error).toBeTruthy();
+ });
+
+ it('rejects malformed code (missing RETURN entirely)', () => {
const s = fresh();
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
error: string;
@@ -926,6 +948,31 @@ describe('custom functions: "block" kind (FOR loops)', () => {
expect(result.error).toBeTruthy();
});
+ it('rejects code where RETURN is missing on one branch (IF without ELSE is never guaranteed to return)', () => {
+ const s = fresh();
+ const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 END', 'block') as {
+ error: string;
+ };
+ expect(result.error).toBeTruthy();
+ });
+
+ it('rejects code where only one IF/ELSE branch returns', () => {
+ const s = fresh();
+ const result = s.addCustomFunction(
+ 'F',
+ ['x'],
+ 'IF x > 0 THEN RETURN 1 ELSE y = 1 END',
+ 'block',
+ ) as { error: string };
+ expect(result.error).toBeTruthy();
+ });
+
+ it('accepts code where BOTH IF/ELSE branches return, with nothing after', () => {
+ const s = fresh();
+ const result = s.addCustomFunction('F', ['x'], 'IF x > 0 THEN RETURN 1 ELSE RETURN 0 END', 'block');
+ expect(typeof result).toBe('string');
+ });
+
it('a block-kind function can call another custom function inside its loop', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
@@ -937,16 +984,39 @@ describe('custom functions: "block" kind (FOR loops)', () => {
s.addCustomFunction(
'SUMDOUBLED',
['bereich'],
- 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) RETURN total',
+ 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
'block',
);
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2);
});
+ it('an early RETURN inside an IF inside a FOR finds the first match', () => {
+ const s = fresh();
+ const id = s.addTable(s.doc.activeSheetId);
+ const name = s.tableById(id)!.name;
+ setCell(s, name, 'A1', '1');
+ setCell(s, name, 'A2', '999');
+ setCell(s, name, 'A3', '4');
+
+ s.addCustomFunction(
+ 'FIRSTOVER',
+ ['bereich', 'schwelle'],
+ 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
+ 'block',
+ );
+ setCell(s, name, 'B1', '=FIRSTOVER(A1:A3; 100)');
+ expect(val(s, name, 'B1')).toBe(999);
+ });
+
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;
+ const a = s.addCustomFunction(
+ 'A',
+ ['bereich'],
+ 'total = 0 FOR x IN bereich DO total = total + B(x) END 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 };
@@ -1406,7 +1476,7 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
s.addCustomFunction(
'PRODUCT2',
['bereich'],
- 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis',
+ 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
'block',
);
diff --git a/src/store/document.ts b/src/store/document.ts
index 11666f6..c04bda5 100644
--- a/src/store/document.ts
+++ b/src/store/document.ts
@@ -32,6 +32,7 @@ import {
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
+ blockAlwaysReturns,
type CellValue,
type TableModel,
type Token,
@@ -787,6 +788,13 @@ function createStore() {
if (badFor) {
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`;
}
+ // conservative static check (see blockAlwaysReturns's own doc comment)
+ // -- a real "no RETURN reached" (e.g. an empty FOR range) still
+ // degrades to #ERR at the affected cell regardless, this just catches
+ // the common case immediately, before ever saving.
+ if (!blockAlwaysReturns(block)) {
+ return 'Nicht jeder Codepfad endet mit RETURN.';
+ }
ownCalls = collectCalledFunctionNamesInBlock(block);
} else {
let ast;