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 ```bash
npm install npm install
npm run dev # Vite-Devserver 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 build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) 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. Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports. - 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.) Der „Art"-Umschalter im Formular bietet neben **„Einfache Formel"** (s. o.)
auch **„Mit Schleife"** — echter Code statt eines einzelnen Ausdrucks, für auch **„Mit Schleife"** — echter Code mit Kontrollfluss, für Fälle, die eine
Fälle, die eine einzelne Formel nicht ausdrücken kann: über die Elemente einzelne Formel nicht ausdrücken kann: über die Elemente eines Bereichs
eines Bereichs selbst iterieren (z. B. ein Produkt aller Zellen, nicht nur selbst iterieren, verzweigen, vorzeitig abbrechen. Das Code-Feld akzeptiert
Summe/Mittel/Min/Max). Das Code-Feld akzeptiert eine Folge aus: eine Folge aus:
- **Zuweisungen** — `name = ausdruck`. - **Zuweisungen** — `name = ausdruck`.
- **FOR-Schleifen** — `FOR var IN bereich DO name = ausdruck`; `bereich` ist - **FOR-Schleifen** — `FOR var IN bereich DO … END`, mit beliebig vielen
ein Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck). 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 — beliebig gemischt und verschachtelt. Jeder eingeführte Name (Zuweisungsziel
eingeführte Name (Zuweisungsziel oder Schleifenvariable) bleibt für den oder Schleifenvariable) bleibt für den **restlichen** Code sichtbar, auch
**restlichen** Code sichtbar, auch nach dem Ende „seiner" Schleife (mit dem nach dem Ende „seines" `FOR`/`IF` (mit seinem zuletzt zugewiesenen Wert) —
Wert der letzten Iteration) — flacher Namensraum, kein Block-Scoping. flacher Namensraum, kein Block-Scoping. `FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/
`FOR`/`IN`/`DO`/`RETURN` sind reservierte Wörter, nicht als Parameter-/ `ELSE`/`RETURN` sind reservierte Wörter, nicht als Parameter-/Variablenname
Variablenname erlaubt. 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 Beispiel — Produkt aller Elemente eines Bereichs (`PRODUCT` gibt es nicht als
eingebaute Funktion, nur `SUM`/`AVG`/`MIN`/`MAX`/`COUNT`): eingebaute Funktion, nur `SUM`/`AVG`/`MIN`/`MAX`/`COUNT`):
``` ```
ergebnis = 1 ergebnis = 1
FOR x IN bereich DO ergebnis = ergebnis * x FOR x IN bereich DO
ergebnis = ergebnis * x
END
RETURN ergebnis 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 FOR x IN bereich DO
Funktionsaufrufs vollständig **abgerollt** — jede Iteration wird zu einem IF x > schwelle THEN
eigenen, ineinander verschachtelten Ausdrucks-Teilbaum (ein Links-Fold). Am RETURN x
Ende steht wieder eine ganz normale, statische Formel-AST — Evaluator und END
Abhängigkeitsgraph sehen keinen Unterschied zu einer von Hand ausgeschriebenen END
Formel, Neuberechnung bei Änderung *jeder* im Bereich enthaltenen Zelle RETURN 0
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 **Kein Endlos-Risiko trotz echtem Kontrollfluss.** Anders als die einfache
„Schleifen"-Abschnitt. 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 ### Zell-Formatierung
@@ -301,7 +323,16 @@ fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen.
```ebnf ```ebnf
formula = "=" expression ; 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 *) term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary unary = ("+" | "-") unary
| power ; | power ;
@@ -328,7 +359,8 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; 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 `::` Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**: sind dabei austauschbar und **case-/whitespace-tolerant**:
@@ -380,7 +412,9 @@ unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch
oben), `#DIV/0!`, oben), `#DIV/0!`,
`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR` `#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten (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 (Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere
Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen
benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er
@@ -404,7 +438,7 @@ Tabellen aller Blätter.
| `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. | | `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. |
| `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. | | `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. |
| `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. | | `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()`. | | `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)` → **Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` →
@@ -487,7 +521,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 237 Tests: `npm run test` — Vitest, 257 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, 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 Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
v1→v2-Migration. v1→v2-Migration.
- **„Mit Schleife" (FOR-Code)** — Parser-Ebene (`parseFunctionBlock`): - **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
eine Schleife (Akkumulator-Muster), mehrere unabhängige Schleifen unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler).
nacheinander, mehrere Zuweisungen vor/zwischen Schleifen, ein Name aus - **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
einer früheren Anweisung in einer späteren Schleife nutzbar; Fehler bei `FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
fehlendem `RETURN`, `FOR` ohne `DO`/`IN`, fehlendem `=`, Selbstbezug im nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
eigenen Initialisierer. Engine-Ebene (`compileBlock`): korrektes Abrollen verschachtelt in `FOR` mit einem `RETURN` mittendrin, `FOR` verschachtelt
(ein Bereich → verschachtelte Ausdrücke in Zellreihenfolge), ein einzelner in `FOR`, `RETURN` an beliebiger Stelle mit beliebigem Ausdruck, ein Name
Zellbezug als Quelle → genau eine Iteration, die Schleifenvariable/das aus einer früheren Anweisung in einer späteren Schleife nutzbar, ein Block
letzte Zuweisungsziel bleiben danach mit ihrem letzten Wert sichtbar, ganz ohne `RETURN` parst trotzdem (das ist eine Save-/Laufzeit-Prüfung,
verschachtelter Aufruf einer anderen Funktion im Schleifenkörper wird mit- kein Parse-Fehler); Fehler bei `FOR`/`IF` ohne passendes `END`/`DO`/`IN`/
expandiert, Arity-Prüfung gilt auch für „Mit Schleife"-Funktionen, eine `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, FOR-Quelle, die nach Parametersubstitution kein Zell-/Bereichsbezug ist,
wirft, mehr als 10.000 abgerollte Zellen wirft. Workbook-Integration: sowie eine Ausführung ohne je erreichtes `RETURN` ergeben alle `#ERR` statt
korrekter Wert und Neuberechnung bei Änderung einer Zelle *innerhalb* des eines Absturzes. Store-Ebene: reservierte Wörter (`FOR`/`IN`/`DO`/`END`/
übergebenen Bereichs, Überschreitung des Abroll-Limits und eine ungültige `IF`/`THEN`/`ELSE`/`RETURN`) als Parameter- oder lokaler Name abgelehnt (auch
FOR-Quelle ergeben `#ERR` statt eines Absturzes. Store-Ebene: reservierte wenn der lokale Name in einem verschachtelten `IF`-Zweig deklariert wird),
Wörter (`FOR`/`IN`/`DO`/`RETURN`) als Parameter- oder lokaler Name zellartiger lokaler Name abgelehnt, Wiederverwendung desselben lokalen
abgelehnt, zellartiger lokaler Name abgelehnt, Wiederverwendung desselben Namens über mehrere Anweisungen (das Akkumulator-Muster) ist **keine**
lokalen Namens über mehrere Anweisungen (das Akkumulator-Muster) ist Kollision, ein lokaler Name, der einen Parameter überschattet, wird
**keine** Kollision, ein lokaler Name, der einen Parameter überschattet, abgelehnt, eine berechnete FOR-Quelle (auch verschachtelt in einem `IF`)
wird abgelehnt, eine berechnete FOR-Quelle wird abgelehnt, Funktions-Zyklus wird abgelehnt, ein Codepfad ohne garantiertes `RETURN` (fehlt ganz, oder
durch einen Schleifenkörper wird erkannt, Undo/Redo, `kind` im JSON- nur ein `IF`-Zweig ohne `ELSE` returnt) wird beim Speichern abgelehnt — mit
Round-Trip, ein älteres gespeichertes Dokument ohne `kind`-Feld defaultet `ELSE`, bei dem **beide** Zweige returnen, wird akzeptiert — Funktions-Zyklus
auf `'expression'`. 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 - **Blatt-Qualifier „Blatt!Tabelle::Zelle"** — Parser-Ebene: die explizite
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle 3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
Rückwärtskompatibilität der 2-teiligen Form (`X!Y`, `X::Y`, auch ein 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 Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.). entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
- **„Mit Schleife"-Code** bewusst ohne: verschachtelte Schleifen (`FOR` - **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
innerhalb einer `DO`-Klausel), bedingte Anweisungen (`IF`), echtes nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar), bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
frühzeitiges `RETURN` mitten in der Anweisungsfolge (nur am Ende erlaubt). `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 ## Projektstruktur
+13 -8
View File
@@ -164,21 +164,26 @@ function close() {
<textarea <textarea
v-model="form.body" v-model="form.body"
class="formula-input" class="formula-input"
:rows="form.kind === 'block' ? 5 : 4" :rows="form.kind === 'block' ? 7 : 4"
:placeholder=" :placeholder="
form.kind === 'block' form.kind === 'block'
? 'ergebnis = 1\nFOR x IN bereich DO ergebnis = ergebnis * x\nRETURN ergebnis' ? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
: 'z. B. (revenue - cost) / revenue' : 'z. B. (revenue - cost) / revenue'
" "
/> />
</label> </label>
<p v-if="form.kind === 'block'" class="hint"> <p v-if="form.kind === 'block'" class="hint">
Eine Folge aus <strong>Zuweisungen</strong> (<code>name = ausdruck</code>) Eine Folge aus <strong>Zuweisungen</strong> (<code>name = ausdruck</code>),
und <strong>FOR-Schleifen</strong> (<code>FOR var IN bereich DO name = ausdruck</code>), <strong>FOR-Schleifen</strong> (<code>FOR var IN bereich DO … END</code>,
zuletzt <code>RETURN ausdruck</code>. Jeder eingeführte Name bleibt für beliebig viele Anweisungen dazwischen) und <strong>IF/ELSE</strong>
den Rest des Codes sichtbar. Beispiel — Produkt aller Elemente eines (<code>IF bedingung THEN … ELSE … END</code>, verschachtelbar in
Bereichs: jedem Block). <code>RETURN ausdruck</code> darf an beliebiger Stelle
<code>ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x / RETURN ergebnis</code>, stehen und bricht die Ausführung sofort ab — z. B. um eine Schleife
früh zu verlassen. Vergleiche mit <code>= &lt;&gt; &gt; &lt; &gt;= &lt;=</code>.
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:
<code>ergebnis = 1 / FOR x IN bereich DO ergebnis = ergebnis * x END / RETURN ergebnis</code>,
aufgerufen als <code>=PRODUCT(Umsatz!B2:E6)</code>. aufgerufen als <code>=PRODUCT(Umsatz!B2:E6)</code>.
</p> </p>
<p v-else class="hint"> <p v-else class="hint">
+130 -66
View File
@@ -16,11 +16,11 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|----------------|---------| |----------------|---------|
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. | | `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. | | `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). | | `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`). | | `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. | | `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()`. | | `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()`. | | `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
@@ -33,7 +33,16 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
```ebnf ```ebnf
formula = "=" expression ; 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 *) term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary unary = ("+" | "-") unary
| power ; | power ;
@@ -68,7 +77,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; 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` `$` 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 (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'` Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die (neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
unverändert bleibt): unverändert bleibt) – mit echtem Kontrollfluss:
```ebnf ```ebnf
functionBlock = { statement } "RETURN" expression ; functionBlock = block ; (* ein Block = die ganze Funktion *)
statement = assignment | forLoop ; block = { statement } ;
statement = assignment | forLoop | ifStmt | returnStmt ;
assignment = IDENT "=" expression ; 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`- `FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/`RETURN` sind – wie Funktionsnamen –
Token, case-insensitiv per Wortvergleich erkannt (`Parser.isKeyword`), kein gewöhnliche `IDENT`-Token, case-insensitiv per Wortvergleich erkannt
eigener Lexer-Token dafür nötig; `=` als Zuweisung ist ein neuer, rein (`Parser.isKeyword`), kein eigener Lexer-Token dafür nötig; `=` als Zuweisung
additiver `EQUALS`-Token (vorher ein `LexError`, kann also keine vorher nutzt den `EQUALS`-Token (rein additiv – vorher ein `LexError`, kann also
gültige Formel beeinflussen). `parseFunctionBlock(src, paramNames)` keine vorher gültige Formel beeinflussen; derselbe Token dient in
(`parser.ts`) parst sequenziell: jeder eingeführte Name (Zuweisungsziel oder `expression()` als Gleichheits-Vergleich, die beiden Grammatiken kollidieren
Schleifenvariable) wird sofort zu `Parser.paramNames` hinzugefügt – **flacher nicht, weil eine Zuweisung nur an Anweisungs-Anfang steht). `RETURN` ist eine
Namensraum**, kein Scope-Stack, ein Name bleibt für den *Rest* des Blocks gewöhnliche **Anweisung**, keine feste Blockendung – sie darf überall
sichtbar (auch nach seiner eigenen Schleife). Ergebnis ist ein `BlockNode` stehen, auch verschachtelt in `FOR`/`IF`.
(`statements: (AssignNode | ForNode)[]`, `ret: Node`) – bewusst **nicht**
Teil des `Node`-Unions, dadurch braucht keine bestehende
`switch (node.kind)`-Stelle (Evaluator, `collectRefs`, `substitute`) einen
neuen Fall.
**Keine Laufzeit-Schleife.** `userFunctions.ts#compileBlock(block, params, `parseFunctionBlock(src, paramNames)` (`parser.ts`) parst rekursiv (`parse-
args)` kompiliert einen Block – mit den *echten* Argumenten des Aufrufers, StatementBlock` ruft sich über `FOR`/`IF`-Körper selbst wieder auf): jeder
nicht mehr symbolisch – zu einem einzigen Ausdrucks-`Node`, bevor er in eingeführte Name (Zuweisungsziel oder Schleifenvariable) wird sofort zu
`expand()`s `func`-Fall landet: `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`). ```ts
2. Jede `assignment` ersetzt ihren Wert mittels der **bestehenden** interface BlockCallNode {
`substitute()` gegen `bound` und aktualisiert `bound`. kind: 'blockcall';
3. Jede `forLoop` löst ihre Quelle (nach Substitution) über `rangeOrCellTo- block: BlockNode; // bereits: verschachtelte Aufrufe anderer Funktionen aufgelöst
CellNodes()` in eine konkrete Zellliste auf (`expandRange`, wie args: Node[]; // die echten Argument-Ausdrücke des Aufrufers
`evaluator.ts#evalArg` es für ein Bereichsargument tut) – ein paramNames: string[];
Einzelzellbezug ergibt genau eine Iteration. Für jede Zelle, in label: string; // für Fehlermeldungen
Zellreihenfolge: die Schleifenvariable zeigt auf diese Zelle, der }
Zuweisungskörper wird gegen die **aktuelle** `bound`-Umgebung substituiert ```
(sieht also den Wert der *vorherigen* Iteration) – ein Links-Fold, ein
verschachtelter Ausdrucks-Teilbaum pro Zelle. Schleifenvariable und
Zuweisungsziel bleiben danach in `bound` (flacher Namensraum: sichtbar mit
ihrem letzten Wert für den Rest des Blocks).
4. `RETURN`s Ausdruck wird zuletzt gegen die finale `bound`-Umgebung
substituiert – das Ergebnis ist der komplette, statische Rückgabewert von
`compileBlock`.
Das Ergebnis kann noch unaufgelöste `FuncNode`s enthalten (verschachtelte **Warum kein Abrollen mehr (wie noch bei einer früheren Version dieses
Aufrufe im Schleifenkörper) – deshalb läuft es direkt danach noch einmal Features):** ein `IF`-Zweig oder ein bedingtes `RETURN` hängt vom
durch `expand()` (eine Ebene tiefer), genau wie jeder andere Funktionskörper. tatsächlichen Zellwert ab – das lässt sich nicht mehr rein aus der
`MAX_EXPANSION_DEPTH` gilt dadurch unverändert auch für verschachtelte Bereichsgröße vorausberechnen. `userFunctions.ts#expand()`s `func`-Fall löst
`kind: 'block'`-Aufrufe. 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 **Der Interpreter (`evaluator.ts`)** läuft, wenn `evalScalar` auf einen
eines `compileBlock`-Aufrufs) wirft einen `Error` bei Überschreitung – `blockcall`-Knoten trifft:
`#ERR`, wie jeder andere `expandUserFunctions`-Fehler. Ein `forLoop`, dessen
(substituierte) Quelle weder eine Zelle noch ein Bereich ist, wirft ebenso - `evaluateBlockCall(node, host)` baut die Startumgebung `env: Map<string,
(`rangeOrCellToCellNodes`); der Store prüft das bereits strukturell beim Node>` aus `paramNames`/`args` und ruft `execBlock`/`execStatement` auf.
**Speichern** (`findInvalidForSource`, exportiert), bevor es je zu einem - Eine `assignment`/`return`/`ifstmt`-Bedingung wird über `substitute(expr,
echten Aufruf kommt. 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 **Namens-Validierung ist Store-Sache, nicht Engine-Sache** (wie beim
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür,
`declaredBlockNames(block)` (jeder eingeführte Name, inkl. Wiederholungen jeweils rekursiv durch verschachtelte `FOR`/`IF`-Körper:
bei Wiederverwendung – z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
bereich DO ergebnis = ergebnis * x`, wo „ergebnis" zweimal auftaucht, aber - `declaredBlockNames(block)` – jeder eingeführte Name, inkl. Wiederholungen
**keine** Kollision ist) und `findInvalidForSource(block)`. `document.ts`s bei Wiederverwendung (z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
`validateCustomFunction` prüft damit: jeder Name gültig/nicht zellartig/kein bereich DO ergebnis = ergebnis * x END`, wo „ergebnis" zweimal auftaucht,
reserviertes Wort (`FOR`/`IN`/`DO`/`RETURN`), kein *unterschiedlicher* aber **keine** Kollision ist).
lokaler Name überschattet einen Parameter (Wiederverwendung *desselben* - `findInvalidForSource(block)` – die erste `FOR`-Quelle, die syntaktisch
Namens über mehrere Anweisungen ist dagegen erlaubt), und die weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
Zyklus-Prüfung nutzt `collectCalledFunctionNamesInBlock(block)` (läuft das - `collectCalledFunctionNamesInBlock(block)` – läuft das bestehende
bestehende `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des Blocks; von
Blocks – kein zweiter AST-Walker nötig). 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 ## Auswertungsmodell
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine - **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`. 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 - **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
gefundene Fehler gewinnt). gefundene Fehler gewinnt).
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position - 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 | | `#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) | | `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) | | `#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 | | `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* | | `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
+20
View File
@@ -68,4 +68,24 @@ describe('lexer', () => {
it('throws on an unexpected character', () => { it('throws on an unexpected character', () => {
expect(() => tokenize('A1 & B2')).toThrow(); 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('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
});
}); });
+98 -22
View File
@@ -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})`; return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param': case 'param':
return `param(${n.name})`; 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)); 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. */ /** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
function sexprStmt(s: Statement): string { function sexprStmt(s: Statement): string {
if (s.kind === 'assign') return `${s.name}=${sexpr(s.value)}`; switch (s.kind) {
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprStmt(s.body)})`; 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 { 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[]) => const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames)); sexprBlock(parseFunctionBlock(s, paramNames));
@@ -180,45 +192,101 @@ describe('parser errors', () => {
}); });
}); });
describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => { describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('a single FOR loop, accumulator pattern', () => { it('parses all six comparison operators', () => {
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis'; 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))');
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( 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', () => { it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
const src = 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( expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO total=(+ param(total) param(a))); ' + '{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)', 'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
); );
}); });
it('a block with no statements -- just RETURN <expression>', () => { it('IF ... THEN ... END, no ELSE', () => {
expect(parseBlock('RETURN 42', [])).toBe('{} RETURN 42'); 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', () => { it('IF ... THEN ... ELSE ... END', () => {
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5} RETURN (* param(x) 2)'); 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', () => { it('a plain assignment can reference an already-declared variable', () => {
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe( expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
'{a=1; b=(+ param(a) 1)} RETURN param(b)',
);
}); });
it('the FOR source may itself be a cell/range reference, not just a parameter', () => { 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( 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)', '{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', () => { it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
expect(() => parseFunctionBlock('x = 1', [])).toThrow(ParseError); 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', () => { 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', () => { 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, 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 "="', () => { it('throws when an assignment is missing "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError); expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
}); });
+135 -119
View File
@@ -248,63 +248,28 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
const productBlock = { const productBlock = {
params: ['bereich'], params: ['bereich'],
kind: 'block' as const, 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', () => { describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
it('a single FOR loop unrolls into one nested expression, left-to-right', () => { it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]); const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index); const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
// ((1 * A1) * A2) * A3 expect(expanded.kind).toBe('blockcall');
expect(expanded).toEqual({ if (expanded.kind !== 'blockcall') throw new Error('unreachable');
kind: 'binary', expect(expanded.label).toBe('PRODUCT2');
op: '*', expect(expanded.paramNames).toEqual(['bereich']);
left: { expect(expanded.args).toEqual([
kind: 'binary', {
op: '*', kind: 'range',
left: { qualifier: null,
kind: 'binary', start: { col: 0, row: 0, colAbs: false, rowAbs: false },
op: '*', end: { col: 0, row: 2, colAbs: false, rowAbs: false },
left: { kind: 'number', value: 1 },
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
}, },
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
},
right: { kind: 'cell', qualifier: null, col: 0, row: 2 },
});
});
it('a single-cell FOR source (no range) unrolls to exactly one iteration', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '*',
left: { kind: 'number', value: 1 },
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
});
});
it('a loop variable / local stays visible (holding its last value) after its own loop', () => {
const index = buildFunctionIndex([
def({
id: 'f1',
name: 'LASTPLUSONE',
params: ['bereich'],
kind: 'block',
body: 'acc = 0 FOR x IN bereich DO acc = acc + x RETURN x + 1', // returns the loop VAR, not acc
}),
]); ]);
const expanded = expandUserFunctions(parseFormula('=LASTPLUSONE(A1:A3)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'cell', qualifier: null, col: 0, row: 2 }, // A3 -- the last iterated cell
right: { kind: 'number', value: 1 },
});
}); });
it('a call inside a FOR body to another (expression-kind) function is expanded too', () => { it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
const index = buildFunctionIndex([ const index = buildFunctionIndex([
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }), def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
def({ def({
@@ -312,63 +277,27 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
name: 'SUMDOUBLED', name: 'SUMDOUBLED',
params: ['bereich'], params: ['bereich'],
kind: 'block', 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); const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left if (expanded.kind !== 'blockcall') throw new Error('unreachable');
expect(expanded).toEqual({ 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', kind: 'binary',
op: '+', op: '+',
left: { left: { kind: 'param', name: 'total' },
kind: 'binary',
op: '+',
left: { kind: 'number', value: 0 },
right: { right: {
kind: 'binary', kind: 'binary',
op: '*', op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 }, left: { kind: 'param', name: 'x' },
right: { kind: 'number', value: 2 }, right: { kind: 'number', value: 2 },
}, },
},
right: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
right: { kind: 'number', value: 2 },
},
});
});
it('multiple independent FOR loops each accumulate correctly', () => {
const index = buildFunctionIndex([
def({
id: 'c1',
name: 'COMBINED',
params: ['sumRange', 'prodRange'],
kind: 'block',
body:
'total = 0 FOR a IN sumRange DO total = total + a ' +
'prod = 1 FOR b IN prodRange DO prod = prod * b ' +
'RETURN total + prod',
}),
]);
const expanded = expandUserFunctions(parseFormula('=COMBINED(A1:A2; B1:B2)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: {
kind: 'binary',
op: '+',
left: { kind: 'binary', op: '+', left: { kind: 'number', value: 0 }, right: { kind: 'cell', qualifier: null, col: 0, row: 0 } },
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
},
right: {
kind: 'binary',
op: '*',
left: { kind: 'binary', op: '*', left: { kind: 'number', value: 1 }, right: { kind: 'cell', qualifier: null, col: 1, row: 0 } },
right: { kind: 'cell', qualifier: null, col: 1, row: 1 },
},
}); });
}); });
@@ -376,29 +305,9 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]); const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow(); 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', () => { 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 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 })]); 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); 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 umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]); const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR'); 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', name: 'BAD',
params: ['x'], params: ['x'],
kind: 'block', 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'); 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');
});
}); });
+155 -1
View File
@@ -11,16 +11,26 @@
* ---------------- * ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking * - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`. * string is coerced; any other string yields `#VALUE!`.
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
* - Any error among operands / arguments propagates unchanged (first wins). * - Any error among operands / arguments propagates unchanged (first wins).
* - A range may only appear as a function argument. Used in scalar position it * - A range may only appear as a function argument. Used in scalar position it
* yields `#VALUE!`. * yields `#VALUE!`.
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`. * - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - Division by zero -> `#DIV/0!`. * - 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 { getFunction } from './functions';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types'; import { FormulaError, isError, type CellValue, type EngineHost } from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue { export function evaluate(ast: Node, host: EngineHost): CellValue {
@@ -61,6 +71,18 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return l * r; return l * r;
case '/': case '/':
return r === 0 ? new FormulaError('#DIV/0!') : l / r; 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}`); 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 // in practice. Fails as a cell error rather than however an unhandled
// case would otherwise behave, matching the engine's "no crash" rule. // case would otherwise behave, matching the engine's "no crash" rule.
return new FormulaError('#ERR', `unresolved parameter ${node.name}`); 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<string, Node>(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<string, Node>, 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<string, Node>, 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<string, Node>,
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). */ /** Evaluate one function argument into a flat list of values (ranges expand). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
if (node.kind === 'range') { if (node.kind === 'range') {
+1
View File
@@ -40,6 +40,7 @@ export {
collectCalledFunctionNamesInBlock, collectCalledFunctionNamesInBlock,
declaredBlockNames, declaredBlockNames,
findInvalidForSource, findInvalidForSource,
blockAlwaysReturns,
type FunctionDef, type FunctionDef,
} from './userFunctions'; } from './userFunctions';
export { export {
+26 -1
View File
@@ -24,7 +24,12 @@ export type TokenType =
| 'COLON' | 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1) | 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG' | 'BANG'
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body | 'EQUALS' // "=" -- assignment (block body) or equality comparison
| 'NEQ' // "<>"
| 'LT' // "<"
| 'GT' // ">"
| 'LTE' // "<="
| 'GTE' // ">="
| 'EOF'; | 'EOF';
export interface Token { export interface Token {
@@ -49,6 +54,8 @@ const SINGLE: Record<string, TokenType> = {
':': 'COLON', ':': 'COLON',
'!': 'BANG', '!': 'BANG',
'=': 'EQUALS', '=': 'EQUALS',
'<': 'LT',
'>': 'GT',
}; };
function isDigit(ch: string): boolean { function isDigit(ch: string): boolean {
@@ -155,6 +162,24 @@ export function tokenize(src: string): Token[] {
continue; 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 // single-character punctuation / operators
const t = SINGLE[ch]; const t = SINGLE[ch];
if (t) { if (t) {
+193 -53
View File
@@ -6,7 +6,16 @@
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* formula = "=" expression ; * 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 *) * term = unary { ("*" | "/") unary } ; (* left assoc *)
* unary = ("+" | "-") unary * unary = ("+" | "-") unary
* | power ; * | power ;
@@ -40,7 +49,7 @@
* see reference() below). *) * see reference() below). *)
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; * 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 * `primary` also accepts a bare IDENT as a *parameter reference* -- but only
@@ -63,7 +72,8 @@ export type Node =
| FuncNode | FuncNode
| CellNode | CellNode
| RangeNode | RangeNode
| ParamNode; | ParamNode
| BlockCallNode;
export interface NumberNode { export interface NumberNode {
kind: 'number'; kind: 'number';
@@ -75,7 +85,7 @@ export interface StringNode {
} }
export interface BinaryNode { export interface BinaryNode {
kind: 'binary'; kind: 'binary';
op: '+' | '-' | '*' | '/' | '^'; op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
left: Node; left: Node;
right: Node; right: Node;
} }
@@ -121,42 +131,78 @@ export interface ParamNode {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// "Block" function bodies (`kind: 'block'` custom functions) -- a small, // "Block" function bodies (`kind: 'block'` custom functions) -- a small
// separate statement grammar for a FOR-loop-capable function body. NOT part // statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
// of the `Node` union above: a block is parsed by parseFunctionBlock() (a // ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
// distinct entry point from parseFormula()) and compiled down to a plain // a block is parsed by parseFunctionBlock() (a distinct entry point from
// `Node` by userFunctions.ts#compileBlock() *before* it ever reaches // parseFormula()), and -- unlike Phase 1's single-expression bodies, which
// expandUserFunctions()'s normal splicing/evaluation path -- so no existing // are fully inlined at parse time -- a block is only *partly* resolved at
// `switch (node.kind)` (evaluator, collectRefs, userFunctions#substitute) // parse time (nested calls to other functions) and then *interpreted* at
// needs a case for these. See src/engine/README.md's "Schleifen" section. // 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 /** `name = value` -- a local-variable assignment. */
* ForNode) one loop step. */
export interface AssignNode { export interface AssignNode {
kind: 'assign'; kind: 'assign';
name: string; name: string;
value: Node; value: Node;
} }
/** `FOR varName IN source DO body` -- source must resolve, once parameters /** `RETURN value` -- may appear anywhere in a block (top level or nested in
* are substituted with the caller's real arguments, to a single cell or a * a FOR/IF), not just at the end; ends the function's execution immediately
* range (checked by userFunctions.ts); compileBlock() unrolls it into one * with this value once reached. */
* nested expression per cell, in order. */ 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 { export interface ForNode {
kind: 'for'; kind: 'for';
varName: string; varName: string;
source: Node; source: Node;
body: AssignNode; body: BlockNode;
} }
export type Statement = AssignNode | ForNode; /** `IF cond THEN then [ELSE else] END`. May appear in any block, including
/** A whole "block" function body: a sequence of statements, then a `RETURN` * nested inside a FOR's body or another IF's branch. */
* expression. Every name introduced by a statement (assignment target or export interface IfNode {
* loop variable) stays visible for the rest of the block -- flat scope, no kind: 'ifstmt';
* nested block scoping. */ 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 { export interface BlockNode {
kind: 'block'; kind: 'block';
statements: Statement[]; 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 {} export class ParseError extends Error {}
@@ -198,26 +244,32 @@ class Parser {
return node; return node;
} }
/** Entry point for a "block" function body: `{ statement } "RETURN" expression`. /** Entry point for a "block" function body: a plain statement sequence
* See parseFunctionBlock() below. */ * (`RETURN` is just one of the statement kinds now, usable anywhere --
* see parseFunctionBlock() below). */
parseBlock(): BlockNode { parseBlock(): BlockNode {
const statements: Statement[] = []; const block = this.parseStatementBlock([]);
while (!this.isKeyword('RETURN')) {
statements.push(this.parseStatement());
}
this.next(); // consume RETURN
const ret = this.expression();
if (!this.at('EOF')) { if (!this.at('EOF')) {
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`); 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 { private parseStatement(): Statement {
if (this.isKeyword('FOR')) return this.parseForLoop(); if (this.isKeyword('FOR')) return this.parseForLoop();
if (this.at('EOF')) { if (this.isKeyword('IF')) return this.parseIfStmt();
throw new ParseError(`Expected a statement or RETURN but reached the end at ${this.peek().pos}`); if (this.isKeyword('RETURN')) return this.parseReturnStmt();
}
return this.parseAssignment(); return this.parseAssignment();
} }
@@ -235,34 +287,91 @@ class Parser {
return { kind: 'assign', name: nameTok.value, value }; 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 { private parseForLoop(): ForNode {
this.next(); // consume FOR this.next(); // consume FOR
if (!this.at('IDENT')) { if (!this.at('IDENT')) {
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`); throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
} }
const varTok = this.next(); const varTok = this.next();
if (!this.isKeyword('IN')) { this.expectKeyword('IN', `FOR ${varTok.value}`);
throw new ParseError(`Expected IN after 'FOR ${varTok.value}' at ${this.peek().pos}`);
}
this.next(); // consume IN
const source = this.expression(); const source = this.expression();
if (!this.isKeyword('DO')) { this.expectKeyword('DO', 'the FOR source');
throw new ParseError(`Expected DO after the FOR source at ${this.peek().pos}`); this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
} const body = this.parseStatementBlock(['END']);
this.next(); // consume DO this.expectKeyword('END', 'the FOR body');
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the DO assignment (and after)
const body = this.parseAssignment();
return { kind: 'for', varName: varTok.value, source, body }; 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` /** True iff the current token is an IDENT spelling exactly `word`
* (case-insensitive) -- how FOR/IN/DO/RETURN are recognised, the same way * (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/RETURN are
* function names are matched elsewhere (no reserved-word lexer tokens). */ * recognised, the same way function names are matched elsewhere (no
* reserved-word lexer tokens). */
private isKeyword(word: string): boolean { private isKeyword(word: string): boolean {
return this.at('IDENT') && this.peek().value.toUpperCase() === word; 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 { 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(); let left = this.term();
while (this.at('PLUS') || this.at('MINUS')) { while (this.at('PLUS') || this.at('MINUS')) {
const op = this.next().type === 'PLUS' ? '+' : '-'; 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 * Parse a "block" function body -- the statement grammar (assignments,
* grammar used by a `kind: 'block'` custom function (see * `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
* src/engine/userFunctions.ts and CustomFunctionsModal.vue). The leading * `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} / * "=" is optional, same as parseFormula(). Throws {@link ParseError} /
* {@link LexError} on malformed input, same conventions as parseFormula(). * {@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(); 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<CellNode | RangeNode> = []): Array<CellNode | RangeNode> { export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
switch (node.kind) { switch (node.kind) {
case 'cell': case 'cell':
@@ -485,6 +599,32 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
case 'func': case 'func':
node.args.forEach((a) => collectRefs(a, out)); node.args.forEach((a) => collectRefs(a, out));
break; break;
case 'blockcall':
node.args.forEach((a) => collectRefs(a, out));
collectRefsInBlock(node.block, out);
break;
} }
return out; return out;
} }
function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode>): 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;
}
}
}
+167 -138
View File
@@ -6,35 +6,35 @@
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* -- * The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) -- * 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 * 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 * parameter environment". Rather than rework the evaluator, a `kind:
* user-defined function is *inlined* into the caller's AST at parse time, * 'expression'` call (a single-formula body) is *inlined* into the caller's
* before the graph/evaluator ever see it: the `FuncNode` is replaced by a * AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
* (recursively expanded) copy of the function body, with every `ParamNode` * is replaced by a (recursively expanded) copy of the function body, with
* swapped for the caller's own argument subtree. After that, the stored AST * every `ParamNode` swapped for the caller's own argument subtree. After
* looks exactly as if the user had written the expanded formula by hand -- * that, the stored AST looks exactly as if the user had written the
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified, * expanded formula by hand -- `collectRefs`/`precedentsOf`/`evaluate` all
* and correctly pick up cell references that live *only* inside the * work completely unmodified, and correctly pick up cell references that
* function body, not just the ones passed as arguments. * 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 * A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
* `parseFunctionBlock`) works the same way in the end: `compileBlock()` * `parseFunctionBlock`) is different: this module only resolves *nested*
* below *unrolls* every FOR loop -- whose range size is already known once * function calls inside it (`expandBlock`, below) -- still at parse time,
* the caller's real argument is substituted in -- into a single nested * still the same "inside-out" inlining -- and wraps the result in a
* expression tree, one iteration deep per cell. There is no runtime loop in * `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
* the evaluator; by the time `expand()` is done, a block-kind call has * branching, an early RETURN) is only resolved at evaluation time, by a
* become exactly the same shape of plain expression tree as any other * small interpreter in `evaluator.ts`, since it genuinely depends on real
* inlined function call. See src/engine/README.md's "Schleifen" section. * cell values. See src/engine/README.md's "Schleifen" section for the full
* design and why that split is necessary.
*/ */
import { import {
parseFormula, parseFormula,
parseFunctionBlock, parseFunctionBlock,
type BlockNode, type BlockNode,
type CellNode,
type ForNode, type ForNode,
type Node, type Node,
} from './parser'; } from './parser';
import { expandRange } from './refs';
export interface FunctionDef { export interface FunctionDef {
id: string; id: string;
@@ -43,23 +43,14 @@ export interface FunctionDef {
/** parameter names, in call order. */ /** parameter names, in call order. */
params: string[]; params: string[];
/** raw formula text, no leading "=" -- a single expression for /** raw formula text, no leading "=" -- a single expression for
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for * `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
* `kind: 'block'`. */ * IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
body: string; body: string;
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop /** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
* capable). Every function predating this field is 'expression'. */ * "code"). Every function predating this field is 'expression'. */
kind: 'expression' | 'block'; 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 /** Recursion guard for `expandUserFunctions` -- a cycle should always be
* rejected at definition time (see the store's cycle check), this is only * 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 * 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<string, Fu
} }
/** /**
* Replace every call to a user-defined function in `ast` with a (recursively * Replace every call to a user-defined function in `ast` with either a
* expanded, parameter-substituted) copy of its body. Calls to built-in or * (recursively expanded, parameter-substituted) copy of its body
* genuinely unknown names are left untouched -- `evaluate()`'s existing * (`kind: 'expression'`), or a `BlockCallNode` wrapping its (nested-calls-
* `#NAME?` path already covers "unknown function", built-in or not. * resolved) block for evaluator.ts's interpreter to run (`kind: 'block'`).
* Calls to built-in or genuinely unknown names are left untouched --
* `evaluate()`'s existing `#NAME?` path already covers "unknown function",
* built-in or not.
* *
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here, * Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
* just structural problems: wrong argument count, or a definition cycle * just structural problems: wrong argument count, or a definition cycle
@@ -109,6 +103,11 @@ function expand(
left: expand(node.left, index, bodyCache, blockCache, depth), left: expand(node.left, index, bodyCache, blockCache, depth),
right: expand(node.right, index, bodyCache, blockCache, depth), right: expand(node.right, index, bodyCache, blockCache, depth),
}; };
case 'blockcall':
// never present in freshly-parsed input -- only expand() itself ever
// produces one (see the 'func' case below) -- but the type checker
// doesn't know that, so: a defensive leaf, nothing to do.
return node;
case 'func': { case 'func': {
// expand the arguments regardless of whether this call is itself a // expand the arguments regardless of whether this call is itself a
// user function -- a built-in like SUM(...) may still take a // user function -- a built-in like SUM(...) may still take a
@@ -132,15 +131,16 @@ function expand(
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
blockCache.set(def.id, block); blockCache.set(def.id, block);
} }
// compileBlock() already substitutes this function's own parameters // resolve any *other* function calls the block contains (built-ins
// (it needs their *real* values to know how many cells a FOR loop // untouched, other user functions inlined/turned into their own
// covers), so -- unlike the expression-kind path below -- there is // BlockCallNode) -- same "inside-out" principle as the
// no separate substitute() step here. Its result may still contain // expression-kind path below. This function's *own* parameters
// nested calls (to built-ins or other user functions), so it goes // stay as ParamNode inside `block`; evaluator.ts's interpreter
// through expand() once more, one level deeper, exactly like any // binds them from `args` at evaluation time, since a FOR loop's
// other function body. // iteration count and an IF's branch both depend on real cell
const compiled = compileBlock(block, def.params, args); // values that aren't known yet here.
return expand(compiled, index, bodyCache, blockCache, depth + 1); const expandedBlock = expandBlock(block, index, bodyCache, blockCache, depth + 1);
return { kind: 'blockcall', block: expandedBlock, args, paramNames: def.params, label: def.name };
} }
let body = bodyCache.get(def.id); let body = bodyCache.get(def.id);
@@ -160,7 +160,14 @@ function expand(
} }
} }
function substitute(node: Node, bound: ReadonlyMap<string, Node>): 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<string, Node>): Node {
switch (node.kind) { switch (node.kind) {
case 'param': case 'param':
// always found in practice (arity is checked before this runs, and // always found in practice (arity is checked before this runs, and
@@ -179,78 +186,52 @@ function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) }; return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
case 'func': case 'func':
return { ...node, args: node.args.map((a) => substitute(a, bound)) }; 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)) };
} }
} }
/** /** {@link expand}, run over every leaf expression a `BlockNode` contains
* Compile a `kind: 'block'` function body down to a single plain expression * (each statement's value/condition/source, recursing into nested FOR/IF
* `Node`, given the caller's *real* argument nodes for this call. Statements * bodies) instead of over a single `Node` -- resolves nested function
* run in order against a growing `bound` environment (name -> already- * calls throughout a block-kind function's body before it's wrapped in a
* substituted expression tree): a plain assignment substitutes its value * `BlockCallNode`. */
* against the current environment and stores it; a FOR loop resolves its function expandBlock(
* source to a concrete list of cells (throwing if it isn't one -- can only block: BlockNode,
* happen if a param turned out, at this call site, not to be a cell/range; index: ReadonlyMap<string, FunctionDef>,
* the store's save-time check catches the far more common case of a bodyCache: Map<string, Node>,
* body that could *never* work), then unrolls one nested substitution per blockCache: Map<string, BlockNode>,
* cell, each seeing the *previous* iteration's value for whichever name the depth: number,
* loop body assigns -- exactly a left fold. The loop variable and the ): BlockNode {
* loop body's assignment target both stay in `bound` afterwards (flat return {
* scope: visible, holding their last iteration's value, for the rest of the kind: 'block',
* block) -- parseFunctionBlock() already parsed the source text with that statements: block.statements.map((stmt) => {
* same flat-scope assumption, so this matches. switch (stmt.kind) {
* case 'assign':
* Throws a plain `Error` on a bad FOR source or exceeding return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
* {@link MAX_UNROLL_STEPS}; the caller (`expand()`'s `func` case, in turn case 'return':
* `Workbook.parseCell`) turns it into `#ERR`, same as any other return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
* expandUserFunctions() failure. case 'for':
*/ return {
function compileBlock(block: BlockNode, paramNames: readonly string[], args: readonly Node[]): Node { ...stmt,
const bound = new Map<string, Node>(paramNames.map((p, i) => [p.toUpperCase(), args[i]])); source: expand(stmt.source, index, bodyCache, blockCache, depth),
let steps = 0; body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
for (const stmt of block.statements) { };
if (stmt.kind === 'assign') { case 'ifstmt':
bound.set(stmt.name.toUpperCase(), substitute(stmt.value, bound)); return {
continue; ...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,
};
} }
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`);
} }
/** /**
@@ -273,62 +254,110 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
collectCalledFunctionNames(ast.operand, out); collectCalledFunctionNames(ast.operand, out);
break; break;
default: default:
break; // number/string/cell/range/param -- no nested calls break; // number/string/cell/range/param/blockcall -- no nested calls
} }
return out; return out;
} }
/** Every name a block declares -- an assignment target or a loop variable -- /** 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 * in the order first declared, recursing into nested FOR/IF bodies. Used
* names the same way it validates parameters (not cell-shaped, no * by the store to validate these local names the same way it validates
* collisions with each other or the parameters, not a reserved word); the * parameters (not cell-shaped, no collision with a parameter, not a
* engine itself has no opinion on naming policy or German error text, so * reserved word); the engine itself has no opinion on naming policy or
* it only exposes the raw names here. */ * German error text, so it only exposes the raw names here. The *same*
export function declaredBlockNames(block: BlockNode): string[] { * name legitimately reappears (an accumulator re-assigned inside a loop,
const out: string[] = []; * 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) { for (const stmt of block.statements) {
if (stmt.kind === 'assign') { switch (stmt.kind) {
case 'assign':
out.push(stmt.name); out.push(stmt.name);
} else { break;
out.push(stmt.varName, stmt.body.name); 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; return out;
} }
/** The first FOR loop, if any, whose source isn't (syntactically) a /** The first FOR loop, anywhere in the block (including nested inside
* parameter, a single cell, or a range -- i.e. can never resolve to a * another FOR/IF), whose source isn't (syntactically) a parameter, a
* concrete list of cells no matter what the caller passes. Checked at * single cell, or a range -- i.e. can never resolve to a concrete list of
* save time so this is caught immediately, not just on the first real * cells no matter what the caller passes. Checked at save time so this is
* call; `compileBlock` re-checks the same thing on the *substituted* * caught immediately, not just on the first real call; the evaluator's
* source at call time (needed regardless, since a parameter's shape isn't * 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. */ * known until then) as the actual enforcement. */
export function findInvalidForSource(block: BlockNode): ForNode | null { export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) { for (const stmt of block.statements) {
if (stmt.kind === 'for' && stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') { if (stmt.kind === 'for') {
if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
return stmt; 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; return null;
} }
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it /** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
* over every leaf expression a `BlockNode` contains (each statement's * over every leaf expression a `BlockNode` contains (each statement's
* value/source, a FOR loop's body assignment, the RETURN expression) and * value/condition/source, recursing into nested FOR/IF bodies) and merges
* merges the results -- `collectCalledFunctionNames` itself needs no * the results -- `collectCalledFunctionNames` itself needs no changes,
* changes, since a block is never part of the `Node` union it switches on. */ * since a block is never part of the `Node` union it switches on. */
export function collectCalledFunctionNamesInBlock( export function collectCalledFunctionNamesInBlock(
block: BlockNode, block: BlockNode,
out: Set<string> = new Set(), out: Set<string> = new Set(),
): Set<string> { ): Set<string> {
for (const stmt of block.statements) { for (const stmt of block.statements) {
if (stmt.kind === 'assign') { switch (stmt.kind) {
case 'assign':
case 'return':
collectCalledFunctionNames(stmt.value, out); collectCalledFunctionNames(stmt.value, out);
} else { break;
case 'for':
collectCalledFunctionNames(stmt.source, out); collectCalledFunctionNames(stmt.source, out);
collectCalledFunctionNames(stmt.body.value, 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; 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;
});
}
+81 -11
View File
@@ -815,10 +815,10 @@ describe('custom functions (document-level library)', () => {
}); });
}); });
describe('custom functions: "block" kind (FOR loops)', () => { describe('custom functions: "block" kind (FOR/IF/RETURN "code")', () => {
const productBody = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis'; 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 s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name; const name = s.tableById(id)!.name;
@@ -870,7 +870,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction( const result = s.addCustomFunction(
'F', 'F',
['bereich'], ['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', 'block',
) as { error: string }; ) as { error: string };
expect(result.error).toBeTruthy(); expect(result.error).toBeTruthy();
@@ -881,7 +881,7 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction( const result = s.addCustomFunction(
'F', 'F',
['bereich'], ['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', 'block',
) as { error: string }; ) as { error: string };
expect(result.error).toBeTruthy(); expect(result.error).toBeTruthy();
@@ -892,7 +892,18 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction( const result = s.addCustomFunction(
'SHADOWTEST', 'SHADOWTEST',
['bereich'], ['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', 'block',
) as { error: string }; ) as { error: string };
expect(result.error).toBeTruthy(); expect(result.error).toBeTruthy();
@@ -912,13 +923,24 @@ describe('custom functions: "block" kind (FOR loops)', () => {
const result = s.addCustomFunction( const result = s.addCustomFunction(
'F', 'F',
['x'], ['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', 'block',
) as { error: string }; ) as { error: string };
expect(result.error).toBeTruthy(); 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 s = fresh();
const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as { const result = s.addCustomFunction('F', ['bereich'], 'ergebnis = 1', 'block') as {
error: string; error: string;
@@ -926,6 +948,31 @@ describe('custom functions: "block" kind (FOR loops)', () => {
expect(result.error).toBeTruthy(); 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', () => { it('a block-kind function can call another custom function inside its loop', () => {
const s = fresh(); const s = fresh();
const id = s.addTable(s.doc.activeSheetId); const id = s.addTable(s.doc.activeSheetId);
@@ -937,16 +984,39 @@ describe('custom functions: "block" kind (FOR loops)', () => {
s.addCustomFunction( s.addCustomFunction(
'SUMDOUBLED', 'SUMDOUBLED',
['bereich'], ['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', 'block',
); );
setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)'); setCell(s, name, 'B1', '=SUMDOUBLED(A1:A2)');
expect(val(s, name, 'B1')).toBe(2 * 2 + 3 * 2); 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', () => { it('detects a cycle that runs through a block-kind function\'s loop body', () => {
const s = fresh(); 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'); expect(typeof a).toBe('string');
// B calling A closes the cycle A -> B -> A // B calling A closes the cycle A -> B -> A
const result = s.addCustomFunction('B', ['x'], 'A(x) + 1') as { error: string }; 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( s.addCustomFunction(
'PRODUCT2', 'PRODUCT2',
['bereich'], ['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', 'block',
); );
+8
View File
@@ -32,6 +32,7 @@ import {
collectCalledFunctionNamesInBlock, collectCalledFunctionNamesInBlock,
declaredBlockNames, declaredBlockNames,
findInvalidForSource, findInvalidForSource,
blockAlwaysReturns,
type CellValue, type CellValue,
type TableModel, type TableModel,
type Token, type Token,
@@ -787,6 +788,13 @@ function createStore() {
if (badFor) { if (badFor) {
return `FOR ${badFor.varName} IN ...: die Quelle muss ein Parameter, eine Zelle oder ein Bereich sein, kein berechneter Ausdruck.`; 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); ownCalls = collectCalledFunctionNamesInBlock(block);
} else { } else {
let ast; let ast;