# 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 dokumentweit eindeutig, deshalb löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf. ## 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. | | `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) }`. | | `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. | | `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) ```ebnf formula = "=" expression ; expression = term { ("+" | "-") term } ; (* links-assoziativ *) 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 = [ qualifier ] CELL [ ":" CELL ] ; qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der Numbers-Stil "::" sind austauschbar *) 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: `+ -` < `* /` < 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. ## Auswertungsmodell - **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`. - **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 **oder** unbekannter Tabellenname | | `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) | | `#NAME?` | unbekannte Funktion | | `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`) | | `#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.