Add FOR-loop "code" bodies to user-defined functions (kind: 'block')
A custom function body could previously only be a single expression -- no way to iterate over a range's elements to build something like PRODUCT (multiply every cell together), since none of the built-ins (SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing functions, not define a new reduction. This adds a second body kind, 'block': a sequence of `name = expression` assignments and `FOR var IN source DO name = expression` loops, ending in `RETURN expression`. Flat scope -- every introduced name stays visible for the rest of the body, including after its own loop, holding its last iteration's value. No runtime loop in the evaluator: a loop's source size is already known once the caller's real argument is substituted in, so userFunctions.ts's new compileBlock() unrolls every FOR loop into a single nested expression (a left fold) at the same parse-time inlining point Phase 1 already used -- the result is exactly the same shape of static AST as any other inlined function call, so evaluator.ts and graph.ts need no changes, dependency edges on every unrolled cell are picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps it crash/hang-free. parser.ts's parseFunctionBlock() parses the new grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are plain case-matched IDENTs, not new reserved tokens) into a BlockNode kept outside the existing Node union, so no existing switch needs a new case. Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'` (defaulted for every function saved before this existed, including via JSON import). validateCustomFunction validates the new grammar's local/ loop-variable names the same way as parameters (not cell-shaped, no reserved words, no shadowing a parameter -- but reusing the same local across statements, the accumulator pattern, is correctly not a collision) and that every FOR source is structurally a parameter, cell, or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel" and "Mit Schleife", with its own placeholder/hint (a PRODUCT example) and a small badge on block-kind rows in the list. 237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live in the running app: defined PRODUCT via the loop UI, called =PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation on a change to a cell inside the range, and an inline rejection for an invalid FOR source at save time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+80
-2
@@ -20,7 +20,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter 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. |
|
||||
| `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. |
|
||||
| `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()`. |
|
||||
|
||||
@@ -147,6 +147,84 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||
|
||||
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||
|
||||
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
|
||||
unverändert bleibt):
|
||||
|
||||
```ebnf
|
||||
functionBlock = { statement } "RETURN" expression ;
|
||||
statement = assignment | forLoop ;
|
||||
assignment = IDENT "=" expression ;
|
||||
forLoop = "FOR" IDENT "IN" expression "DO" assignment ;
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
**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:
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
@@ -179,7 +257,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 oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
|
||||
| `#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 |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user