Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere
Replaces the previous FOR-only, single-statement-body, static-unrolling design with genuine control flow: FOR var IN source DO <block> END loop bodies with any number of statements (including nested loops/IFs), IF cond THEN <block> [ELSE <block>] END branching (nestable in any block), and RETURN as an ordinary statement usable anywhere -- it aborts execution immediately with its value, including breaking out of a loop early. This required a real, if small, architecture change: an IF branch or an early RETURN depends on actual cell values, which the previous "unroll everything into one static formula at parse time" approach can't resolve (loop *counts* are knowable up front from a range's size; branch *outcomes* aren't). A kind: 'block' call is now inlined only as far as resolving nested function calls (expandBlock, parse-time, same inside-out principle as before) and wrapped in a new BlockCallNode; its own control flow is interpreted at evaluation time by a small tree-walking interpreter added to evaluator.ts (execBlock/execStatement, a thrown ReturnSignal for early exit), reusing userFunctions.ts's substitute() (now exported) to resolve parameter/local-variable names against the current environment. Dependency tracking stays fully correct and conservative: collectRefs() walks into a BlockCallNode's block (new collectRefsInBlock), so a cell referenced only in a branch that doesn't happen to run is still a tracked precedent -- same principle as an ordinary IF() in any spreadsheet. Also added: comparison operators (= <> > < >= <=, result 1/0) as a new, loosest-binding precedence level in the *general* expression grammar (not just inside a block's IF) -- needed for IF conditions to be useful at all. A definition-time static check (blockAlwaysReturns) rejects a function at save time when it's not obviously guaranteed to reach RETURN on every path (an IF without an ELSE, or one where only one branch returns); the interpreter's own runtime "ended without RETURN" -> #ERR remains the actual enforcement for cases the static check can't see (e.g. a loop over an empty range). Nested loops (FOR inside FOR) and nested IFs work as an emergent property of the recursive statement grammar -- verified with dedicated tests, not just assumed. 257 tests passing (30 net new/rewritten for this change), vue-tsc/ eslint/build clean. Verified live in the running app: a FIRSTOVER(range; threshold) function using a FOR loop with a nested IF and an early RETURN, confirmed it returns the first match, confirmed changing a cell *past* the match point doesn't affect the result while changing the match point itself does, and confirmed a function without a guaranteed RETURN is rejected inline at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
|||||||
```bash
|
```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
|
||||||
|
|
||||||
|
|||||||
@@ -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>= <> > < >= <=</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
@@ -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.* |
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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') {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export {
|
|||||||
collectCalledFunctionNamesInBlock,
|
collectCalledFunctionNamesInBlock,
|
||||||
declaredBlockNames,
|
declaredBlockNames,
|
||||||
findInvalidForSource,
|
findInvalidForSource,
|
||||||
|
blockAlwaysReturns,
|
||||||
type FunctionDef,
|
type FunctionDef,
|
||||||
} from './userFunctions';
|
} from './userFunctions';
|
||||||
export {
|
export {
|
||||||
|
|||||||
+26
-1
@@ -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
@@ -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
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user