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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 22:41:55 +02:00
co-authored by Claude Sonnet 5
parent 6daea9f956
commit 01ea18106c
13 changed files with 1143 additions and 484 deletions
+130 -66
View File
@@ -16,11 +16,11 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|----------------|---------|
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
| `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). |
| `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. |
| `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()`. |
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
@@ -33,7 +33,16 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
```ebnf
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 *)
unary = ("+" | "-") unary
| power ;
@@ -68,7 +77,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
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`
(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'`
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
unverändert bleibt):
unverändert bleibt) – mit echtem Kontrollfluss:
```ebnf
functionBlock = { statement } "RETURN" expression ;
statement = assignment | forLoop ;
functionBlock = block ; (* ein Block = die ganze Funktion *)
block = { statement } ;
statement = assignment | forLoop | ifStmt | returnStmt ;
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`-
Token, case-insensitiv per Wortvergleich erkannt (`Parser.isKeyword`), kein
eigener Lexer-Token dafür nötig; `=` als Zuweisung ist ein neuer, rein
additiver `EQUALS`-Token (vorher ein `LexError`, kann also keine vorher
gültige Formel beeinflussen). `parseFunctionBlock(src, paramNames)`
(`parser.ts`) parst sequenziell: jeder eingeführte Name (Zuweisungsziel oder
Schleifenvariable) wird sofort zu `Parser.paramNames` hinzugefügt – **flacher
Namensraum**, kein Scope-Stack, ein Name bleibt für den *Rest* des Blocks
sichtbar (auch nach seiner eigenen Schleife). Ergebnis ist ein `BlockNode`
(`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.
`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/`RETURN` sind – wie Funktionsnamen –
gewöhnliche `IDENT`-Token, case-insensitiv per Wortvergleich erkannt
(`Parser.isKeyword`), kein eigener Lexer-Token dafür nötig; `=` als Zuweisung
nutzt den `EQUALS`-Token (rein additiv – vorher ein `LexError`, kann also
keine vorher gültige Formel beeinflussen; derselbe Token dient in
`expression()` als Gleichheits-Vergleich, die beiden Grammatiken kollidieren
nicht, weil eine Zuweisung nur an Anweisungs-Anfang steht). `RETURN` ist eine
gewöhnliche **Anweisung**, keine feste Blockendung – sie darf überall
stehen, auch verschachtelt in `FOR`/`IF`.
**Keine Laufzeit-Schleife.** `userFunctions.ts#compileBlock(block, params,
args)` kompiliert einen Block – mit den *echten* Argumenten des Aufrufers,
nicht mehr symbolisch – zu einem einzigen Ausdrucks-`Node`, bevor er in
`expand()`s `func`-Fall landet:
`parseFunctionBlock(src, paramNames)` (`parser.ts`) parst rekursiv (`parse-
StatementBlock` ruft sich über `FOR`/`IF`-Körper selbst wieder auf): jeder
eingeführte Name (Zuweisungsziel oder Schleifenvariable) wird sofort zu
`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`).
2. Jede `assignment` ersetzt ihren Wert mittels der **bestehenden**
`substitute()` gegen `bound` und aktualisiert `bound`.
3. Jede `forLoop` löst ihre Quelle (nach Substitution) über `rangeOrCellTo-
CellNodes()` in eine konkrete Zellliste auf (`expandRange`, wie
`evaluator.ts#evalArg` es für ein Bereichsargument tut) – ein
Einzelzellbezug ergibt genau eine Iteration. Für jede Zelle, in
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`.
```ts
interface BlockCallNode {
kind: 'blockcall';
block: BlockNode; // bereits: verschachtelte Aufrufe anderer Funktionen aufgelöst
args: Node[]; // die echten Argument-Ausdrücke des Aufrufers
paramNames: string[];
label: string; // für Fehlermeldungen
}
```
Das Ergebnis kann noch unaufgelöste `FuncNode`s enthalten (verschachtelte
Aufrufe im Schleifenkörper) – deshalb läuft es direkt danach noch einmal
durch `expand()` (eine Ebene tiefer), genau wie jeder andere Funktionskörper.
`MAX_EXPANSION_DEPTH` gilt dadurch unverändert auch für verschachtelte
`kind: 'block'`-Aufrufe.
**Warum kein Abrollen mehr (wie noch bei einer früheren Version dieses
Features):** ein `IF`-Zweig oder ein bedingtes `RETURN` hängt vom
tatsächlichen Zellwert ab – das lässt sich nicht mehr rein aus der
Bereichsgröße vorausberechnen. `userFunctions.ts#expand()`s `func`-Fall löst
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
eines `compileBlock`-Aufrufs) wirft einen `Error` bei Überschreitung –
`#ERR`, wie jeder andere `expandUserFunctions`-Fehler. Ein `forLoop`, dessen
(substituierte) Quelle weder eine Zelle noch ein Bereich ist, wirft ebenso
(`rangeOrCellToCellNodes`); der Store prüft das bereits strukturell beim
**Speichern** (`findInvalidForSource`, exportiert), bevor es je zu einem
echten Aufruf kommt.
**Der Interpreter (`evaluator.ts`)** läuft, wenn `evalScalar` auf einen
`blockcall`-Knoten trifft:
- `evaluateBlockCall(node, host)` baut die Startumgebung `env: Map<string,
Node>` aus `paramNames`/`args` und ruft `execBlock`/`execStatement` auf.
- Eine `assignment`/`return`/`ifstmt`-Bedingung wird über `substitute(expr,
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
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür
`declaredBlockNames(block)` (jeder eingeführte Name, inkl. Wiederholungen
bei Wiederverwendung – z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
bereich DO ergebnis = ergebnis * x`, wo „ergebnis" zweimal auftaucht, aber
**keine** Kollision ist) und `findInvalidForSource(block)`. `document.ts`s
`validateCustomFunction` prüft damit: jeder Name gültig/nicht zellartig/kein
reserviertes Wort (`FOR`/`IN`/`DO`/`RETURN`), kein *unterschiedlicher*
lokaler Name überschattet einen Parameter (Wiederverwendung *desselben*
Namens über mehrere Anweisungen ist dagegen erlaubt), und die
Zyklus-Prüfung nutzt `collectCalledFunctionNamesInBlock(block)` (läuft das
bestehende `collectCalledFunctionNames()` über jeden Blatt-Ausdruck des
Blocks – kein zweiter AST-Walker nötig).
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür,
jeweils rekursiv durch verschachtelte `FOR`/`IF`-Körper:
- `declaredBlockNames(block)` – jeder eingeführte Name, inkl. Wiederholungen
bei Wiederverwendung (z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
bereich DO ergebnis = ergebnis * x END`, wo „ergebnis" zweimal auftaucht,
aber **keine** Kollision ist).
- `findInvalidForSource(block)` – die erste `FOR`-Quelle, die syntaktisch
weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
- `collectCalledFunctionNamesInBlock(block)` – läuft das bestehende
`collectCalledFunctionNames()` über jeden Blatt-Ausdruck des Blocks; von
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
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
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
gefundene Fehler gewinnt).
- 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 |
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
| `#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 |
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
+20
View File
@@ -68,4 +68,24 @@ describe('lexer', () => {
it('throws on an unexpected character', () => {
expect(() => tokenize('A1 & B2')).toThrow();
});
it('tokenizes assignment/equality and comparison operators', () => {
expect(types('= < > <= >= <>')).toEqual([
'EQUALS',
'LT',
'GT',
'LTE',
'GTE',
'NEQ',
'EOF',
]);
});
it('prefers the two-character comparison operators over their single-character prefix', () => {
expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
});
});
+98 -22
View File
@@ -28,6 +28,10 @@ function sexpr(n: Node): string {
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
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));
@@ -35,11 +39,19 @@ const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
function sexprStmt(s: Statement): string {
if (s.kind === 'assign') return `${s.name}=${sexpr(s.value)}`;
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprStmt(s.body)})`;
switch (s.kind) {
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 {
return `{${b.statements.map(sexprStmt).join('; ')}} RETURN ${sexpr(b.ret)}`;
return `{${b.statements.map(sexprStmt).join('; ')}}`;
}
const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames));
@@ -180,45 +192,101 @@ describe('parser errors', () => {
});
});
describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
it('a single FOR loop, accumulator pattern', () => {
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x RETURN ergebnis';
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('parses all six comparison operators', () => {
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(
'{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', () => {
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(
'{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)',
'{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)}',
);
});
it('a block with no statements -- just RETURN <expression>', () => {
expect(parseBlock('RETURN 42', [])).toBe('{} RETURN 42');
it('IF ... THEN ... END, no ELSE', () => {
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', () => {
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5} RETURN (* param(x) 2)');
it('IF ... THEN ... ELSE ... END', () => {
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', () => {
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe(
'{a=1; b=(+ param(a) 1)} RETURN param(b)',
);
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
});
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(
'{total=0; FOR(x IN range(0,0:0,4) DO total=(+ param(total) param(x)))} RETURN param(total)',
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)}',
);
});
it('throws when RETURN is missing entirely', () => {
expect(() => parseFunctionBlock('x = 1', [])).toThrow(ParseError);
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
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', () => {
@@ -226,11 +294,19 @@ describe('parseFunctionBlock -- FOR/DO/RETURN "block" function bodies', () => {
});
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,
);
});
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 "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
});
+135 -119
View File
@@ -248,63 +248,28 @@ describe('Workbook integration: user-defined functions end-to-end', () => {
const productBlock = {
params: ['bereich'],
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', () => {
it('a single FOR loop unrolls into one nested expression, left-to-right', () => {
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
// ((1 * A1) * A2) * A3
expect(expanded).toEqual({
kind: 'binary',
op: '*',
left: {
kind: 'binary',
op: '*',
left: {
kind: 'binary',
op: '*',
left: { kind: 'number', value: 1 },
right: { kind: 'cell', qualifier: null, col: 0, row: 0 },
},
right: { kind: 'cell', qualifier: null, col: 0, row: 1 },
expect(expanded.kind).toBe('blockcall');
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
expect(expanded.label).toBe('PRODUCT2');
expect(expanded.paramNames).toEqual(['bereich']);
expect(expanded.args).toEqual([
{
kind: 'range',
qualifier: null,
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
},
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([
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
def({
@@ -312,93 +277,37 @@ describe('expandUserFunctions (AST-level): kind "block" -- FOR loops', () => {
name: 'SUMDOUBLED',
params: ['bereich'],
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);
// (0 + (A1*2)) + (A2*2) -- DOUBLE() fully inlined per iteration, no func nodes left
expect(expanded).toEqual({
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
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',
op: '+',
left: {
kind: 'binary',
op: '+',
left: { kind: 'number', value: 0 },
right: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 2 },
},
},
left: { kind: 'param', name: 'total' },
right: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 1 },
left: { kind: 'param', name: 'x' },
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 },
},
});
});
it('arity mismatch is still checked for block-kind functions', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
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', () => {
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 })]);
@@ -412,7 +321,107 @@ describe('Workbook integration: block-kind (FOR loop) user-defined functions', (
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 wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
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',
params: ['x'],
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');
});
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
const wb = new Workbook([umsatz], [noReturn]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
});
+155 -1
View File
@@ -11,16 +11,26 @@
* ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`.
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
* - Any error among operands / arguments propagates unchanged (first wins).
* - A range may only appear as a function argument. Used in scalar position it
* yields `#VALUE!`.
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - 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 { expandRange } from './refs';
import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue {
@@ -61,6 +71,18 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return l * r;
case '/':
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}`);
}
@@ -99,9 +121,141 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// in practice. Fails as a cell error rather than however an unhandled
// case would otherwise behave, matching the engine's "no crash" rule.
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). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
if (node.kind === 'range') {
+1
View File
@@ -40,6 +40,7 @@ export {
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
blockAlwaysReturns,
type FunctionDef,
} from './userFunctions';
export {
+26 -1
View File
@@ -24,7 +24,12 @@ export type TokenType =
| 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG'
| 'EQUALS' // "=" -- assignment, only meaningful in a user-function "block" body
| 'EQUALS' // "=" -- assignment (block body) or equality comparison
| 'NEQ' // "<>"
| 'LT' // "<"
| 'GT' // ">"
| 'LTE' // "<="
| 'GTE' // ">="
| 'EOF';
export interface Token {
@@ -49,6 +54,8 @@ const SINGLE: Record<string, TokenType> = {
':': 'COLON',
'!': 'BANG',
'=': 'EQUALS',
'<': 'LT',
'>': 'GT',
};
function isDigit(ch: string): boolean {
@@ -155,6 +162,24 @@ export function tokenize(src: string): Token[] {
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
const t = SINGLE[ch];
if (t) {
+193 -53
View File
@@ -6,7 +6,16 @@
* ---------------------------------------------------------------------------
* 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 *)
* unary = ("+" | "-") unary
* | power ;
@@ -40,7 +49,7 @@
* see reference() below). *)
* 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
@@ -63,7 +72,8 @@ export type Node =
| FuncNode
| CellNode
| RangeNode
| ParamNode;
| ParamNode
| BlockCallNode;
export interface NumberNode {
kind: 'number';
@@ -75,7 +85,7 @@ export interface StringNode {
}
export interface BinaryNode {
kind: 'binary';
op: '+' | '-' | '*' | '/' | '^';
op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
left: Node;
right: Node;
}
@@ -121,42 +131,78 @@ export interface ParamNode {
}
// ---------------------------------------------------------------------------
// "Block" function bodies (`kind: 'block'` custom functions) -- a small,
// separate statement grammar for a FOR-loop-capable function body. NOT part
// of the `Node` union above: a block is parsed by parseFunctionBlock() (a
// distinct entry point from parseFormula()) and compiled down to a plain
// `Node` by userFunctions.ts#compileBlock() *before* it ever reaches
// expandUserFunctions()'s normal splicing/evaluation path -- so no existing
// `switch (node.kind)` (evaluator, collectRefs, userFunctions#substitute)
// needs a case for these. See src/engine/README.md's "Schleifen" section.
// "Block" function bodies (`kind: 'block'` custom functions) -- a small
// statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
// ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
// a block is parsed by parseFunctionBlock() (a distinct entry point from
// parseFormula()), and -- unlike Phase 1's single-expression bodies, which
// are fully inlined at parse time -- a block is only *partly* resolved at
// parse time (nested calls to other functions) and then *interpreted* at
// 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
* ForNode) one loop step. */
/** `name = value` -- a local-variable assignment. */
export interface AssignNode {
kind: 'assign';
name: string;
value: Node;
}
/** `FOR varName IN source DO body` -- source must resolve, once parameters
* are substituted with the caller's real arguments, to a single cell or a
* range (checked by userFunctions.ts); compileBlock() unrolls it into one
* nested expression per cell, in order. */
/** `RETURN value` -- may appear anywhere in a block (top level or nested in
* a FOR/IF), not just at the end; ends the function's execution immediately
* with this value once reached. */
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 {
kind: 'for';
varName: string;
source: Node;
body: AssignNode;
body: BlockNode;
}
export type Statement = AssignNode | ForNode;
/** A whole "block" function body: a sequence of statements, then a `RETURN`
* expression. Every name introduced by a statement (assignment target or
* loop variable) stays visible for the rest of the block -- flat scope, no
* nested block scoping. */
/** `IF cond THEN then [ELSE else] END`. May appear in any block, including
* nested inside a FOR's body or another IF's branch. */
export interface IfNode {
kind: 'ifstmt';
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 {
kind: 'block';
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 {}
@@ -198,26 +244,32 @@ class Parser {
return node;
}
/** Entry point for a "block" function body: `{ statement } "RETURN" expression`.
* See parseFunctionBlock() below. */
/** Entry point for a "block" function body: a plain statement sequence
* (`RETURN` is just one of the statement kinds now, usable anywhere --
* see parseFunctionBlock() below). */
parseBlock(): BlockNode {
const statements: Statement[] = [];
while (!this.isKeyword('RETURN')) {
statements.push(this.parseStatement());
}
this.next(); // consume RETURN
const ret = this.expression();
const block = this.parseStatementBlock([]);
if (!this.at('EOF')) {
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 {
if (this.isKeyword('FOR')) return this.parseForLoop();
if (this.at('EOF')) {
throw new ParseError(`Expected a statement or RETURN but reached the end at ${this.peek().pos}`);
}
if (this.isKeyword('IF')) return this.parseIfStmt();
if (this.isKeyword('RETURN')) return this.parseReturnStmt();
return this.parseAssignment();
}
@@ -235,34 +287,91 @@ class Parser {
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 {
this.next(); // consume FOR
if (!this.at('IDENT')) {
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
}
const varTok = this.next();
if (!this.isKeyword('IN')) {
throw new ParseError(`Expected IN after 'FOR ${varTok.value}' at ${this.peek().pos}`);
}
this.next(); // consume IN
this.expectKeyword('IN', `FOR ${varTok.value}`);
const source = this.expression();
if (!this.isKeyword('DO')) {
throw new ParseError(`Expected DO after the FOR source at ${this.peek().pos}`);
}
this.next(); // consume DO
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the DO assignment (and after)
const body = this.parseAssignment();
this.expectKeyword('DO', 'the FOR source');
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
const body = this.parseStatementBlock(['END']);
this.expectKeyword('END', 'the FOR 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`
* (case-insensitive) -- how FOR/IN/DO/RETURN are recognised, the same way
* function names are matched elsewhere (no reserved-word lexer tokens). */
* (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/RETURN are
* recognised, the same way function names are matched elsewhere (no
* reserved-word lexer tokens). */
private isKeyword(word: string): boolean {
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 {
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();
while (this.at('PLUS') || this.at('MINUS')) {
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
* grammar used by a `kind: 'block'` custom function (see
* src/engine/userFunctions.ts and CustomFunctionsModal.vue). The leading
* Parse a "block" function body -- the statement grammar (assignments,
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
* `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} /
* {@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();
}
/** 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> {
switch (node.kind) {
case 'cell':
@@ -485,6 +599,32 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
case 'func':
node.args.forEach((a) => collectRefs(a, out));
break;
case 'blockcall':
node.args.forEach((a) => collectRefs(a, out));
collectRefsInBlock(node.block, out);
break;
}
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;
}
}
}
+173 -144
View File
@@ -6,35 +6,35 @@
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* 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
* parameter environment". Rather than rework the evaluator, a call to a
* user-defined function is *inlined* into the caller's AST at parse time,
* before the graph/evaluator ever see it: the `FuncNode` is replaced by a
* (recursively expanded) copy of the function body, with every `ParamNode`
* swapped for the caller's own argument subtree. After that, the stored AST
* looks exactly as if the user had written the expanded formula by hand --
* `collectRefs`/`precedentsOf`/`evaluate` all work completely unmodified,
* and correctly pick up cell references that live *only* inside the
* function body, not just the ones passed as arguments.
* parameter environment". Rather than rework the evaluator, a `kind:
* 'expression'` call (a single-formula body) is *inlined* into the caller's
* AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
* is replaced by a (recursively expanded) copy of the function body, with
* every `ParamNode` swapped for the caller's own argument subtree. After
* that, the stored AST looks exactly as if the user had written the
* expanded formula by hand -- `collectRefs`/`precedentsOf`/`evaluate` all
* work completely unmodified, and correctly pick up cell references that
* 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
* `parseFunctionBlock`) works the same way in the end: `compileBlock()`
* below *unrolls* every FOR loop -- whose range size is already known once
* the caller's real argument is substituted in -- into a single nested
* expression tree, one iteration deep per cell. There is no runtime loop in
* the evaluator; by the time `expand()` is done, a block-kind call has
* become exactly the same shape of plain expression tree as any other
* inlined function call. See src/engine/README.md's "Schleifen" section.
* A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
* `parseFunctionBlock`) is different: this module only resolves *nested*
* function calls inside it (`expandBlock`, below) -- still at parse time,
* still the same "inside-out" inlining -- and wraps the result in a
* `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
* branching, an early RETURN) is only resolved at evaluation time, by a
* small interpreter in `evaluator.ts`, since it genuinely depends on real
* cell values. See src/engine/README.md's "Schleifen" section for the full
* design and why that split is necessary.
*/
import {
parseFormula,
parseFunctionBlock,
type BlockNode,
type CellNode,
type ForNode,
type Node,
} from './parser';
import { expandRange } from './refs';
export interface FunctionDef {
id: string;
@@ -43,23 +43,14 @@ export interface FunctionDef {
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=" -- a single expression for
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
* `kind: 'block'`. */
* `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
* IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
body: string;
/** 'expression' (the original, Phase-1 form) or 'block' (FOR-loop
* capable). Every function predating this field is 'expression'. */
/** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
* "code"). Every function predating this field is 'expression'. */
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
* 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
@@ -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
* expanded, parameter-substituted) copy of its body. Calls to built-in or
* genuinely unknown names are left untouched -- `evaluate()`'s existing
* `#NAME?` path already covers "unknown function", built-in or not.
* Replace every call to a user-defined function in `ast` with either a
* (recursively expanded, parameter-substituted) copy of its body
* (`kind: 'expression'`), or a `BlockCallNode` wrapping its (nested-calls-
* 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,
* just structural problems: wrong argument count, or a definition cycle
@@ -109,6 +103,11 @@ function expand(
left: expand(node.left, 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': {
// expand the arguments regardless of whether this call is itself 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
blockCache.set(def.id, block);
}
// compileBlock() already substitutes this function's own parameters
// (it needs their *real* values to know how many cells a FOR loop
// covers), so -- unlike the expression-kind path below -- there is
// no separate substitute() step here. Its result may still contain
// nested calls (to built-ins or other user functions), so it goes
// through expand() once more, one level deeper, exactly like any
// other function body.
const compiled = compileBlock(block, def.params, args);
return expand(compiled, index, bodyCache, blockCache, depth + 1);
// resolve any *other* function calls the block contains (built-ins
// untouched, other user functions inlined/turned into their own
// BlockCallNode) -- same "inside-out" principle as the
// expression-kind path below. This function's *own* parameters
// stay as ParamNode inside `block`; evaluator.ts's interpreter
// binds them from `args` at evaluation time, since a FOR loop's
// iteration count and an IF's branch both depend on real cell
// values that aren't known yet here.
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);
@@ -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) {
case 'param':
// 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) };
case 'func':
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)) };
}
}
/**
* Compile a `kind: 'block'` function body down to a single plain expression
* `Node`, given the caller's *real* argument nodes for this call. Statements
* run in order against a growing `bound` environment (name -> already-
* substituted expression tree): a plain assignment substitutes its value
* against the current environment and stores it; a FOR loop resolves its
* source to a concrete list of cells (throwing if it isn't one -- can only
* happen if a param turned out, at this call site, not to be a cell/range;
* the store's save-time check catches the far more common case of a
* body that could *never* work), then unrolls one nested substitution per
* cell, each seeing the *previous* iteration's value for whichever name the
* loop body assigns -- exactly a left fold. The loop variable and the
* loop body's assignment target both stay in `bound` afterwards (flat
* scope: visible, holding their last iteration's value, for the rest of the
* block) -- parseFunctionBlock() already parsed the source text with that
* same flat-scope assumption, so this matches.
*
* Throws a plain `Error` on a bad FOR source or exceeding
* {@link MAX_UNROLL_STEPS}; the caller (`expand()`'s `func` case, in turn
* `Workbook.parseCell`) turns it into `#ERR`, same as any other
* expandUserFunctions() failure.
*/
function compileBlock(block: BlockNode, paramNames: readonly string[], args: readonly Node[]): Node {
const bound = new Map<string, Node>(paramNames.map((p, i) => [p.toUpperCase(), args[i]]));
let steps = 0;
for (const stmt of block.statements) {
if (stmt.kind === 'assign') {
bound.set(stmt.name.toUpperCase(), substitute(stmt.value, bound));
continue;
}
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`);
/** {@link expand}, run over every leaf expression a `BlockNode` contains
* (each statement's value/condition/source, recursing into nested FOR/IF
* bodies) instead of over a single `Node` -- resolves nested function
* calls throughout a block-kind function's body before it's wrapped in a
* `BlockCallNode`. */
function expandBlock(
block: BlockNode,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
blockCache: Map<string, BlockNode>,
depth: number,
): BlockNode {
return {
kind: 'block',
statements: block.statements.map((stmt) => {
switch (stmt.kind) {
case 'assign':
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
case 'return':
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
case 'for':
return {
...stmt,
source: expand(stmt.source, index, bodyCache, blockCache, depth),
body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
};
case 'ifstmt':
return {
...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,
};
}
}),
};
}
/**
@@ -273,40 +254,60 @@ export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set
collectCalledFunctionNames(ast.operand, out);
break;
default:
break; // number/string/cell/range/param -- no nested calls
break; // number/string/cell/range/param/blockcall -- no nested calls
}
return out;
}
/** 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
* names the same way it validates parameters (not cell-shaped, no
* collisions with each other or the parameters, not a reserved word); the
* engine itself has no opinion on naming policy or German error text, so
* it only exposes the raw names here. */
export function declaredBlockNames(block: BlockNode): string[] {
const out: string[] = [];
* in the order first declared, recursing into nested FOR/IF bodies. Used
* by the store to validate these local names the same way it validates
* parameters (not cell-shaped, no collision with a parameter, not a
* reserved word); the engine itself has no opinion on naming policy or
* German error text, so it only exposes the raw names here. The *same*
* name legitimately reappears (an accumulator re-assigned inside a loop,
* 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) {
if (stmt.kind === 'assign') {
out.push(stmt.name);
} else {
out.push(stmt.varName, stmt.body.name);
switch (stmt.kind) {
case 'assign':
out.push(stmt.name);
break;
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;
}
/** The first FOR loop, if any, whose source isn't (syntactically) a
* parameter, a single cell, or a range -- i.e. can never resolve to a
* concrete list of cells no matter what the caller passes. Checked at
* save time so this is caught immediately, not just on the first real
* call; `compileBlock` re-checks the same thing on the *substituted*
* source at call time (needed regardless, since a parameter's shape isn't
/** The first FOR loop, anywhere in the block (including nested inside
* another FOR/IF), whose source isn't (syntactically) a parameter, a
* single cell, or a range -- i.e. can never resolve to a concrete list of
* cells no matter what the caller passes. Checked at save time so this is
* caught immediately, not just on the first real call; the evaluator's
* 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. */
export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'for' && stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
return stmt;
if (stmt.kind === 'for') {
if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
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;
@@ -314,21 +315,49 @@ export function findInvalidForSource(block: BlockNode): ForNode | null {
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
* over every leaf expression a `BlockNode` contains (each statement's
* value/source, a FOR loop's body assignment, the RETURN expression) and
* merges the results -- `collectCalledFunctionNames` itself needs no
* changes, since a block is never part of the `Node` union it switches on. */
* value/condition/source, recursing into nested FOR/IF bodies) and merges
* the results -- `collectCalledFunctionNames` itself needs no changes,
* since a block is never part of the `Node` union it switches on. */
export function collectCalledFunctionNamesInBlock(
block: BlockNode,
out: Set<string> = new Set(),
): Set<string> {
for (const stmt of block.statements) {
if (stmt.kind === 'assign') {
collectCalledFunctionNames(stmt.value, out);
} else {
collectCalledFunctionNames(stmt.source, out);
collectCalledFunctionNames(stmt.body.value, out);
switch (stmt.kind) {
case 'assign':
case 'return':
collectCalledFunctionNames(stmt.value, out);
break;
case 'for':
collectCalledFunctionNames(stmt.source, 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;
}
/**
* 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;
});
}