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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user