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:
wkh
2026-09-02 21:58:59 +02:00
co-authored by Claude Sonnet 5
parent 7340307a70
commit 6daea9f956
13 changed files with 1094 additions and 48 deletions
+72 -2
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 200 Tests
npm run test # Vitest-Suite (Engine + Store + IO), 237 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
@@ -193,6 +193,48 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
#### Mit Schleife: FOR-Code statt einer einzelnen Formel
Der „Art"-Umschalter im Formular bietet neben **„Einfache Formel"** (s. o.)
auch **„Mit Schleife"** — echter Code statt eines einzelnen Ausdrucks, für
Fälle, die eine einzelne Formel nicht ausdrücken kann: über die Elemente
eines Bereichs selbst iterieren (z. B. ein Produkt aller Zellen, nicht nur
Summe/Mittel/Min/Max). Das Code-Feld akzeptiert eine Folge aus:
- **Zuweisungen** — `name = ausdruck`.
- **FOR-Schleifen** — `FOR var IN bereich DO name = ausdruck`; `bereich` ist
ein Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck).
— beliebig oft, beliebig gemischt, zuletzt **`RETURN ausdruck`**. Jeder
eingeführte Name (Zuweisungsziel oder Schleifenvariable) bleibt für den
**restlichen** Code sichtbar, auch nach dem Ende „seiner" Schleife (mit dem
Wert der letzten Iteration) — flacher Namensraum, kein Block-Scoping.
`FOR`/`IN`/`DO`/`RETURN` sind reservierte Wörter, nicht als Parameter-/
Variablenname erlaubt.
Beispiel — Produkt aller Elemente eines Bereichs (`PRODUCT` gibt es nicht als
eingebaute Funktion, nur `SUM`/`AVG`/`MIN`/`MAX`/`COUNT`):
```
ergebnis = 1
FOR x IN bereich DO ergebnis = ergebnis * x
RETURN ergebnis
```
aufgerufen als `=PRODUCT(Umsatz!B2:E6)`.
**Keine echte Laufzeit-Schleife im Evaluator:** Da die Größe eines Bereichs
beim Aufruf bereits feststeht, wird eine `FOR`-Schleife beim Einsetzen des
Funktionsaufrufs vollständig **abgerollt** — jede Iteration wird zu einem
eigenen, ineinander verschachtelten Ausdrucks-Teilbaum (ein Links-Fold). Am
Ende steht wieder eine ganz normale, statische Formel-AST — Evaluator und
Abhängigkeitsgraph sehen keinen Unterschied zu einer von Hand ausgeschriebenen
Formel, Neuberechnung bei Änderung *jeder* im Bereich enthaltenen Zelle
funktioniert automatisch korrekt. Kein Endlos-Risiko: mehr als insgesamt
10.000 abgerollte Zellen (über alle Schleifen einer Funktion) ergeben `#ERR`
statt zu hängen. Details: [`src/engine/README.md`](src/engine/README.md)s
„Schleifen"-Abschnitt.
### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -445,7 +487,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests
`npm run test` — Vitest, 200 Tests:
`npm run test` — Vitest, 237 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -521,6 +563,30 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
v1→v2-Migration.
- **„Mit Schleife" (FOR-Code)** — Parser-Ebene (`parseFunctionBlock`):
eine Schleife (Akkumulator-Muster), mehrere unabhängige Schleifen
nacheinander, mehrere Zuweisungen vor/zwischen Schleifen, ein Name aus
einer früheren Anweisung in einer späteren Schleife nutzbar; Fehler bei
fehlendem `RETURN`, `FOR` ohne `DO`/`IN`, fehlendem `=`, Selbstbezug im
eigenen Initialisierer. Engine-Ebene (`compileBlock`): korrektes Abrollen
(ein Bereich → verschachtelte Ausdrücke in Zellreihenfolge), ein einzelner
Zellbezug als Quelle → genau eine Iteration, die Schleifenvariable/das
letzte Zuweisungsziel bleiben danach mit ihrem letzten Wert sichtbar,
verschachtelter Aufruf einer anderen Funktion im Schleifenkörper wird mit-
expandiert, Arity-Prüfung gilt auch für „Mit Schleife"-Funktionen, eine
FOR-Quelle, die nach Parametersubstitution kein Zell-/Bereichsbezug ist,
wirft, mehr als 10.000 abgerollte Zellen wirft. Workbook-Integration:
korrekter Wert und Neuberechnung bei Änderung einer Zelle *innerhalb* des
übergebenen Bereichs, Überschreitung des Abroll-Limits und eine ungültige
FOR-Quelle ergeben `#ERR` statt eines Absturzes. Store-Ebene: reservierte
Wörter (`FOR`/`IN`/`DO`/`RETURN`) als Parameter- oder lokaler Name
abgelehnt, zellartiger lokaler Name abgelehnt, Wiederverwendung desselben
lokalen Namens über mehrere Anweisungen (das Akkumulator-Muster) ist
**keine** Kollision, ein lokaler Name, der einen Parameter überschattet,
wird abgelehnt, eine berechnete FOR-Quelle wird abgelehnt, Funktions-Zyklus
durch einen Schleifenkörper wird erkannt, Undo/Redo, `kind` im JSON-
Round-Trip, ein älteres gespeichertes Dokument ohne `kind`-Feld defaultet
auf `'expression'`.
- **Blatt-Qualifier „Blatt!Tabelle::Zelle"** — Parser-Ebene: die explizite
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
Rückwärtskompatibilität der 2-teiligen Form (`X!Y`, `X::Y`, auch ein
@@ -564,6 +630,10 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
- **„Mit Schleife"-Code** bewusst ohne: verschachtelte Schleifen (`FOR`
innerhalb einer `DO`-Klausel), bedingte Anweisungen (`IF`), echtes
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar),
frühzeitiges `RETURN` mitten in der Anweisungsfolge (nur am Ende erlaubt).
## Projektstruktur