From 6daea9f95616c7bb0769265f453aa96da2fdaf1f Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 2 Sep 2026 21:58:59 +0200 Subject: [PATCH] Add FOR-loop "code" bodies to user-defined functions (kind: 'block') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 74 +++++++- src/components/CustomFunctionsModal.vue | 78 +++++++- src/engine/README.md | 82 ++++++++- src/engine/__tests__/parser.spec.ts | 81 ++++++++- src/engine/__tests__/userFunctions.spec.ts | 196 ++++++++++++++++++++- src/engine/index.ts | 5 + src/engine/lexer.ts | 2 + src/engine/parser.ts | 130 +++++++++++++- src/engine/userFunctions.ts | 191 +++++++++++++++++++- src/io/json.ts | 3 +- src/store/__tests__/document.spec.ts | 185 ++++++++++++++++++- src/store/document.ts | 107 +++++++++-- src/store/types.ts | 8 +- 13 files changed, 1094 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 4ac3dc2..d71762e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/CustomFunctionsModal.vue b/src/components/CustomFunctionsModal.vue index 35b96e6..331e823 100644 --- a/src/components/CustomFunctionsModal.vue +++ b/src/components/CustomFunctionsModal.vue @@ -16,9 +16,10 @@ const emit = defineEmits<{ (e: 'close'): void }>(); const store = useDocument(); type Mode = 'list' | 'edit'; +type Kind = 'expression' | 'block'; const mode = ref('list'); const editingId = ref(null); -const form = reactive({ name: '', params: [''] as string[], body: '' }); +const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind }); const formError = ref(null); // reset to the list every time the modal is (re-)opened, so a stray edit-in- @@ -38,6 +39,7 @@ function openNew() { form.name = ''; form.params = ['']; form.body = ''; + form.kind = 'expression'; formError.value = null; mode.value = 'edit'; } @@ -49,6 +51,7 @@ function openEdit(id: string) { form.name = f.name; form.params = f.params.length ? [...f.params] : ['']; form.body = f.body; + form.kind = f.kind; formError.value = null; mode.value = 'edit'; } @@ -63,8 +66,8 @@ function removeParamField(i: number) { function save() { const params = form.params.map((p) => p.trim()).filter((p) => p !== ''); const result = editingId.value - ? store.updateCustomFunction(editingId.value, form.name, params, form.body) - : store.addCustomFunction(form.name, params, form.body); + ? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind) + : store.addCustomFunction(form.name, params, form.body, form.kind); if (result && typeof result === 'object' && 'error' in result) { formError.value = result.error; return; @@ -103,7 +106,10 @@ function close() {

  • - {{ f.name }}({{ f.params.join('; ') }}) + + ↻ + {{ f.name }}({{ f.params.join('; ') }}) + @@ -119,6 +125,22 @@ function close() { +
    + Art +
    + + +
    +
    +
    Parameter
    @@ -138,15 +160,28 @@ function close() {