A user function parameter already worked as a range when passed straight into a range-capable call (SUM/AVG/MIN/MAX/another user function) -- expandUserFunctions splices the caller's RangeNode in unchanged, so it behaves exactly like a normal formula (and #VALUE! outside a function-argument position, also exactly like normal). Added 2 tests confirming this (incl. recalculation on a change inside the passed range) and a hint under the Formel field in CustomFunctionsModal.vue showing the pattern (SPANNE(bereich) = MAX(bereich) - MIN(bereich)), since nothing surfaced that this was possible. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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) }. Eingebaute Namen sind nie überschreibbar. |
userFunctions.ts |
Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-README.md). expandUserFunctions() setzt jeden Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein. |
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 = 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.
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.
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?.
Auswertungsmodell
- Arithmetik ist numerisch. Leere Zelle (
null) zählt als0. 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!.COUNTzählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.^=Math.pow, rechts-assoziativ:=2^3^2→2^(3^2)=512.
Neuberechnung
setCellreparst eine Zelle, aktualisiert ihre Graph-Kanten.graph.affected(dirty)= geänderte Zellen + alle transitiven Abhängigen.graph.topoOrder(affected)liefert eine sichere Auswertungsreihenfolge (Präzedenzen zuerst) und die Menge der Zellen auf einem Zyklus.- 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 (eingebaut oder benutzerdefiniert) |
#ERR |
Parse-Fehler (=A1+, =SUM(); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
#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.