Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere

Replaces the previous FOR-only, single-statement-body, static-unrolling
design with genuine control flow: FOR var IN source DO <block> END loop
bodies with any number of statements (including nested loops/IFs),
IF cond THEN <block> [ELSE <block>] END branching (nestable in any
block), and RETURN as an ordinary statement usable anywhere -- it
aborts execution immediately with its value, including breaking out of
a loop early.

This required a real, if small, architecture change: an IF branch or an
early RETURN depends on actual cell values, which the previous "unroll
everything into one static formula at parse time" approach can't
resolve (loop *counts* are knowable up front from a range's size;
branch *outcomes* aren't). A kind: 'block' call is now inlined only as
far as resolving nested function calls (expandBlock, parse-time, same
inside-out principle as before) and wrapped in a new BlockCallNode;
its own control flow is interpreted at evaluation time by a small
tree-walking interpreter added to evaluator.ts (execBlock/execStatement,
a thrown ReturnSignal for early exit), reusing userFunctions.ts's
substitute() (now exported) to resolve parameter/local-variable names
against the current environment. Dependency tracking stays fully
correct and conservative: collectRefs() walks into a BlockCallNode's
block (new collectRefsInBlock), so a cell referenced only in a branch
that doesn't happen to run is still a tracked precedent -- same
principle as an ordinary IF() in any spreadsheet.

Also added: comparison operators (= <> > < >= <=, result 1/0) as a new,
loosest-binding precedence level in the *general* expression grammar
(not just inside a block's IF) -- needed for IF conditions to be useful
at all. A definition-time static check (blockAlwaysReturns) rejects a
function at save time when it's not obviously guaranteed to reach
RETURN on every path (an IF without an ELSE, or one where only one
branch returns); the interpreter's own runtime "ended without RETURN"
-> #ERR remains the actual enforcement for cases the static check can't
see (e.g. a loop over an empty range).

Nested loops (FOR inside FOR) and nested IFs work as an emergent
property of the recursive statement grammar -- verified with dedicated
tests, not just assumed.

257 tests passing (30 net new/rewritten for this change), vue-tsc/
eslint/build clean. Verified live in the running app: a FIRSTOVER(range;
threshold) function using a FOR loop with a nested IF and an early
RETURN, confirmed it returns the first match, confirmed changing a
cell *past* the match point doesn't affect the result while changing
the match point itself does, and confirmed a function without a
guaranteed RETURN is rejected inline at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 22:41:55 +02:00
co-authored by Claude Sonnet 5
parent 6daea9f956
commit 01ea18106c
13 changed files with 1143 additions and 484 deletions
+110 -59
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 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