Files
numolus/src/engine
wkhandClaude Sonnet 5 fdde4ed5a7 Show a range-handling pattern in the function-editing UI
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>
2026-09-02 15:59:56 +02:00
..
2026-08-29 17:47:12 +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 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 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 (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.