This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# 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)`, Spaltenbuchstaben, Bereichs-Expansion. |
| `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 `+ -` < `^`.
`$` in Referenzen wird toleriert, aber ignoriert (es gibt kein Kopieren/Füllen,
alle Bezüge sind relativ). 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.