Files
numolus/src/engine
wkhandClaude Sonnet 5 01ea18106c 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>
2026-09-02 22:41:55 +02:00
..
2026-08-29 17:47:12 +02:00
2026-08-29 17:47:12 +02:00

Formel-Engine

Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kein formula-parser, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.

Die adressierbare Einheit ist eine Tabelle – ein Tabellen-Objekt auf einer Numbers-artigen Blattfläche. Tabellennamen sind nur pro Blatt eindeutig (zwei Blätter dürfen je eine Tabelle "Umsatz" haben) – Umsatz!B2 (bzw. Umsatz::B2) löst dann zur Tabelle auf dem gleichen Blatt wie die Formel auf, sofern eindeutig; die explizite Form Blatt!Tabelle::B2 trifft immer genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.

Module

Datei Aufgabe
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. 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 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().

Die Engine kennt kein Vue. Der Vue-Store (src/store/) legt eine Workbook- Instanz in ein reactive() und spiegelt berechnete Werte über einen recalcTick-Ref – dadurch verhält sich jede Formelzelle im UI wie ein computed.

Grammatik (EBNF)

formula     = "=" expression ;

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 ;
power       = primary [ "^" unary ] ;               (* rechts-assoziativ; bindet
                                                       stärker als unäres Minus:
                                                       -2^2 = -(2^2) = -4        *)
primary     = NUMBER
            | STRING
            | "(" expression ")"
            | funcall
            | reference ;

funcall     = IDENT "(" [ expression { ";" expression } ] ")" ;

reference   = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier   = (IDENT | QUOTED) ("!" | "::") ;       (* Tabellenname; "!" und der
                                                       Numbers-Stil "::" sind
                                                       austauschbar              *)
sheetQualifier = (IDENT | QUOTED) "!" ;             (* Blattname -- nur erkannt,
                                                       wenn direkt gefolgt von
                                                       vollem "Tabelle::" (die
                                                       3-teilige Form); ein
                                                       einzelnes "X!Y" bleibt
                                                       die alte, rückwärts-
                                                       kompatible 2-teilige
                                                       Form (X = Tabelle)      *)

NUMBER      = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/  |  /"." [0-9]+/ ;
STRING      = /" ... "/  (Backslash-Escapes) ;
IDENT       = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED      = /' ( [^'] | '' )* '/     (* Tabellenname mit Leer-/Sonderzeichen *)
CELL        = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;

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 ist rein positionsbasiert, $ wird geparst und sonst ignoriert. Es wirkt sich ausschließlich beim Kopieren/Ausschneiden aus (shiftFormula in src/store/document.ts, außerhalb der Engine): eine gesperrte Achse bleibt beim Kopieren exakt wie eingegeben stehen, folgt aber beim Ausschneiden trotzdem der verschobenen Zelle (dort verschiebt sich die referenzierte Zelle selbst – $ ändert nur, ob eine Kopie mitzieht, nicht wohin ein Verschieben zeigt). refs.ts#cycleReferenceLocks implementiert den F4-Zyklus (relativ → $A$1 → A$1 → $A1 → relativ) für Formelleiste und Zell-Editor — mit Cursor nur den einen Bezug dort, mit echter Textauswahl jeden darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand. Tabellennamen werden case-insensitiv und whitespace-tolerant aufgelöst: Kosten!A2, 'Kosten'!A2, kosten!a2, Kosten::A2 sind identisch.

parseFormula(src, paramNames?) akzeptiert einen optionalen zweiten Parameter: eine Liste von Namen, gegen die ein bare IDENT (das nicht wie eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines ParseError einen ParamNode. Nur beim Parsen eines Funktionskörpers verwendet (userFunctions.ts); ohne das Argument ist das Verhalten exakt wie zuvor, jeder bare Bezeichner ohne Zellform bleibt ein ParseError.

Blatt-Qualifier ("Blatt!Tabelle::Zelle")

parser.ts#Parser.afterFirstQualifierToken entscheidet per Lookahead, ob ein ! einen (alten) Tabellen-Qualifier einleitet oder einen Blatt-Qualifier: nur wenn direkt danach IDENT/QUOTED "::" folgt, ist es ein Blattname und der Parser konsumiert drei Segmente (Blatt, Tabelle, Zelle) statt zwei — sonst bleibt die alte, einstufige Bedeutung (X!Y = Tabelle X, Zelle Y) exakt erhalten. CellNode/RangeNode bekommen dafür ein optionales sheetQualifier?: string-Feld neben dem bestehenden qualifier.

Die Auflösung passiert in workbook.ts#Workbook.resolveTableName(name, sheetName?) (Teil von EngineHost):

  • sheetName gegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen auf diesem Blatt trifft; sonst null (→ #REF).
  • sheetName fehlt und der Tabellenname ist eindeutig im Dokument → wie bisher, unverändert.
  • sheetName fehlt und der Name kommt auf mehreren Blättern vor → bevorzugt die Tabelle auf demselben Blatt wie this.homeTableId (dafür setzen sowohl evaluateCell als auch precedentsOf homeTableId, bevor sie referenzieren — Letzteres, damit auch die Graph-Kanten beim Parsen schon dieselbe Auflösung wie später die Auswertung verwenden). Bleibt es danach mehrdeutig: null (→ #REF).

TableModel (workbook.ts) trägt dafür zusätzlich sheetName: string. Die Store-Seite (document.ts#toTableModels) befüllt es aus dem Blatt, dem die Tabelle gehört; tableNamesOnSheet(sheetId)/objectNamesOnSheet(sheetId) ersetzen dort die früher dokumentweiten Eindeutigkeits-Prüfungen beim Anlegen/ Umbenennen einer Tabelle. qualifyRef() (Point-Mode) fügt die volle 3-teilige Form nur dann ein, wenn der angeklickte Tabellenname wirklich mehrdeutig und auf einem anderen Blatt als der Editor liegt — sonst reicht weiterhin die einfache Form.

Benutzerdefinierte Funktionen

Kein Laufzeit-Closure, keine Änderung an evaluator.ts/graph.ts: ein Aufruf einer benutzerdefinierten Funktion wird beim Parsen der aufrufenden Zelle in deren AST eingesetzt (Inlining). userFunctions.ts#expandUserFunctions läuft direkt nach parseFormula() in workbook.ts#parseCell und ersetzt jeden FuncNode, dessen Name in der Funktions-Bibliothek steht (eingebaute Namen haben Vorrang und werden nie ersetzt), durch eine Kopie des – selbst wieder rekursiv expandierten – Funktionskörpers, mit jedem ParamNode durch den zugehörigen Argument-Teilbaum des Aufrufers ersetzt. Danach sieht die gespeicherte AST der Zelle so aus, als wäre die Formel von Hand ausgeschrieben worden – collectRefs/precedentsOf/evaluate laufen unverändert, auch für Zellen, die nur im Funktionskörper referenziert werden, nicht im Aufruf.

Ein Arity-Mismatch oder eine Expansionstiefe über 32 (Sicherheitsnetz gegen einen Definitionszyklus, der eigentliche Schutz ist die Zyklus-Prüfung beim Speichern einer Funktion im Store) wirft in expandUserFunctions einen Error, den parseCell genau wie einen ParseError zu #ERR macht. Ein 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) – mit echtem Kontrollfluss:

functionBlock = block ;                             (* ein Block = die ganze Funktion *)
block         = { statement } ;
statement     = assignment | forLoop | ifStmt | returnStmt ;
assignment    = IDENT "=" expression ;
forLoop       = "FOR" IDENT "IN" expression "DO" block "END" ;
ifStmt        = "IF" expression "THEN" block [ "ELSE" block ] "END" ;
returnStmt    = "RETURN" expression ;

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.

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:

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
}

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.

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 evaluateCells 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, 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.tss 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 ergibt er #VALUE!.
  • SUM/MIN/MAX über „keine Zahlen“ → 0; AVG über „keine Zahlen“ → #DIV/0!.
  • COUNT zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
  • ^ = Math.pow, rechts-assoziativ: =2^3^2 → 2^(3^2) = 512.

Neuberechnung

  1. setCell reparst eine Zelle, aktualisiert ihre Graph-Kanten.
  2. graph.affected(dirty) = geänderte Zellen + alle transitiven Abhängigen.
  3. graph.topoOrder(affected) liefert eine sichere Auswertungsreihenfolge (Präzedenzen zuerst) und die Menge der Zellen auf einem Zyklus.
  4. Zyklus-Zellen bekommen direkt #CIRC; der Rest wird in topologischer Reihenfolge ausgewertet. Nicht betroffene Zellen behalten ihren Wert („nur betroffene Zellen neu rechnen“).

Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen) rufen rebuild() für einen vollständigen – trotzdem billigen – Durchlauf.

Fehlermodell

Wert Bedeutung
#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, 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.

Es gibt keinen Absturz und keinen Hang: Lexer/Parser werfen intern, Workbook fängt alles ab und schreibt einen Fehlerwert in die Zelle.