From 01ea18106c773b1e382364d67b0f977aa09d327d Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 22:41:55 +0200 Subject: [PATCH] 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 END loop bodies with any number of statements (including nested loops/IFs), IF cond THEN [ELSE ] 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 --- README.md | 169 +++++++---- src/components/CustomFunctionsModal.vue | 21 +- src/engine/README.md | 196 ++++++++----- src/engine/__tests__/lexer.spec.ts | 20 ++ src/engine/__tests__/parser.spec.ts | 120 ++++++-- src/engine/__tests__/userFunctions.spec.ts | 254 +++++++++-------- src/engine/evaluator.ts | 156 +++++++++- src/engine/index.ts | 1 + src/engine/lexer.ts | 27 +- src/engine/parser.ts | 246 ++++++++++++---- src/engine/userFunctions.ts | 317 +++++++++++---------- src/store/__tests__/document.spec.ts | 92 +++++- src/store/document.ts | 8 + 13 files changed, 1143 insertions(+), 484 deletions(-) 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() {