The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
37 KiB
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), Funktionsnamen-Erkennung fürs Autovervollständigen (currentIdentifierAtCaret). |
functions.ts |
SUM/AVG/MIN/MAX/COUNT (+ Alias AVERAGE), DATE/TIME/TODAY/NOW als Tabelle { NAME → fn(values) }. IF und XLOOKUP sind hier nur als Platzhalter registriert (für hasFunction()/Namensreservierung); die echte Auswertung sitzt in evaluator.ts, s. u. Eingebaute Namen sind nie überschreibbar. |
dates.ts |
Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): dateToSerial/timeSerial (Bausteine für DATE/TIME), todaySerial/nowSerial, serialToUtcDate (für die Anzeige), parseDateTimeLiteral (Eingabe-Erkennung, s. u. „Datum & Zeit"). |
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(). literalValue() erkennt Datums-/Zeit-Eingaben (dates.ts) vor dem generischen Zahl-Test. |
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 = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht
verkettbar; Ergebnis
1/0 (siehe Auswertungs-
modell unten) *)
concat = additive { "&" additive } ; (* links-assoziativ; Text-
Verkettung, zwischen
Vergleich und "+ -" *)
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
| ERRORLIT
| "(" expression ")"
| funcall
| reference
| namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
namedref = IDENT ; (* unqualifizierter, nicht zellförmiger
Bezeichner, kein Parameter -- ein
benannter-Bereich-Kandidat (s. u.),
nur außerhalb eines Funktionskörpers *)
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}/ ;
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
"#VALUE!" | "#N/A" ; (* longest-match in the lexer -- see
lexer.ts's ERROR_CODES_BY_LENGTH *)
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.
refs.ts#currentIdentifierAtCaret(formula, caret) treibt die Funktions-
Autovervollständigung (Formelleiste und Zell-Editor, src/composables/ useFormulaAutocomplete.ts): strukturell dasselbe Muster wie
cycleReferenceLocks (= abstreifen, tokenizen, am Cursor nachsehen), aber
nur der Text bis zum Cursor wird überhaupt tokenisiert (was danach
kommt, spielt nie eine Rolle — wie bei den meisten Editoren), und nur das
letzte Token zählt. Ein IDENT-Token, das bis genau an den Cursor reicht
und nicht wie ein Zellbezug aussieht (looksLikeCell), ist der gerade
getippte Funktionsname; alles andere (kein Token dort, ein Zellbezug, der
Cursor sitzt hinter Leerraum nach einem abgeschlossenen Wort, tokenize()
wirft bei einem kaputten Präfix) liefert null — kein Dropdown.
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 (paramNames === undefined, jede gewöhnliche
Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne
Zellform seit benannten Bereichen geändert: er parst jetzt als
NamedRefNode (Feld namedRefFallback am Parser, s. u. „Benannte
Bereiche") statt als ParseError. Mit paramNames — auch als leeres
Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte
Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name
ist weiterhin ein harter ParseError. Grund: eine lokale/Schleifen-Variable,
die vor ihrer eigenen deklarierenden Anweisung verwendet wird (x = x + 1),
soll sofort beim Speichern scheitern, statt sich still zu einem
gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur
Laufzeit als #NAME? aufzufallen).
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):
sheetNamegegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen auf diesem Blatt trifft; sonstnull(→#REF).sheetNamefehlt und der Tabellenname ist eindeutig im Dokument → wie bisher, unverändert.sheetNamefehlt und der Name kommt auf mehreren Blättern vor → bevorzugt die Tabelle auf demselben Blatt wiethis.homeTableId(dafür setzen sowohlevaluateCellals auchprecedentsOfhomeTableId, 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?.
Benannte Bereiche
Anders als eine benutzerdefinierte Funktion wird ein NamedRefNode
nicht beim Parsen aufgelöst/inlined, sondern live, bei jeder
Auswertung, gegen EngineHost.resolveNamedRange(name) – dieselbe
"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier
(auch der wird nie zur Parse-Zeit zu einer Tabellen-ID aufgelöst). Grund:
so übersteht ein benannter Bereich eine spätere Umbenennung seiner
Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden
müsste.
types.ts#NamedRangeDef({id, name, tableId, start, end}) wird demWorkbook-Konstruktor als dritter Parameter mitgegeben (analog zu denFunctionDefs);store/document.ts#toNamedRangeDefsmapptDocumentState.namedRangesdorthin.workbook.ts#Workbook.resolveNamedRange(name)sucht case-insensitiv/ getrimmt (dieselbe Konvention wieresolveTableName), lösttableIdübertableById()in den aktuellen Tabellennamen auf und gibt{qualifier, sheetQualifier, start, end}zurück – exakt die Form, die einCellNode/RangeNodesonst auch trägt. Zieltabelle nicht mehr vorhanden →null→#NAME?(dieselbe Fehlerklasse wie ein unbekannter Name).evaluator.ts#resolveNamedRef()baut aus diesem Ergebnis einen synthetischenCellNode(1×1-Bereich) oderRangeNodeund lässt jeden Aufrufer (evalScalarscase 'namedref',evalArg,rangeOrCellToCellNodesfür eineFOR-Quelle) in die bereits vorhandene Zell-/Bereichs-Logik durchfallen – keine Duplikation.parser.ts#collectRefssammelt einenNamedRefNodeunaufgelöst (der Parser kennt das Dokument nicht);workbook.ts#precedentsOflöst ihn dann überresolveNamedRangein konkrete Zell-Keys für den Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine Neuberechnung nötig,evaluate()liefert ohnehin#NAME?).- Grammatik-Änderung, die das erst ermöglicht: ein unqualifizierter,
nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein
NamedRefNodestatt einesParseError– aber nur außerhalb eines Funktionskörpers (ParsersnamedRefFallback-Flag,truenur wennparseFormula()ganz ohneparamNames-Argument aufgerufen wird). Ein Aufrufer kann trotzdem einen benannten Bereich als Argument an eine Funktion übergeben – derNamedRefNodesteht dann innode.args, wird überexpand()/substitute()(beide bekamen dafür einencase 'namedref': return node;-Leaf, wiecell/range) an die passende Parameterstelle im Funktionskörper substituiert, und trifft dort z. B. alsFOR-Quelle aufrangeOrCellToCellNodes, das ihn ganz normal auflöst.userFunctions.ts#findInvalidForSourceerlaubtnamedrefdeshalb ausdrücklich als gültigeFOR-Quellenform (nebenparam/cell/range). - Kein Zyklus-Check. Ein benannter Bereich hat keinen eigenen Ausdruck, nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders als eine benutzerdefinierte Funktion.
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 Startumgebungenv: Map<string, Node>ausparamNames/argsund ruftexecBlock/execStatementauf.- Eine
assignment/return/ifstmt-Bedingung wird übersubstitute(expr, env)(aususerFunctions.ts, dafür exportiert) gegenenvaufgelöst und dann ganz normalevalScalar-ausgewertet; das Ergebnis wird (als Literal-Node) zurück inenvgeschrieben. - Eine
forLooplöst ihre (substituierte) Quelle überrangeOrCellToCell- Nodes()in eine konkrete Zellliste auf (expandRange, wieevalArges für ein Bereichsargument tut) – ein Einzelzellbezug ergibt genau eine Iteration – und führtbody(ein vollständiger, ggf. verschachtelterBlockNode) einmal je Zelle aus, in Zellreihenfolge. Schleifenvariable und alle im Körper zugewiesenen Namen bleiben danach inenv(flacher Namensraum: sichtbar mit ihrem letzten Wert für den Rest der Ausführung). returnwirft einReturnSignal(Wert), das nurevaluateBlockCallfängt – ein Standard-"Unwind zu einer bekannten Grenze"-Muster für frühen Ausstieg. Das bricht sofort jede laufende Schleife/jedesIFab, egal wie tief verschachtelt.- Läuft die Ausführung durch, ohne dass je
ReturnSignalgeworfen wurde, wirftevaluateBlockCallselbst einenError("… 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-Musterergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END, wo „ergebnis" zweimal auftaucht, aber keine Kollision ist).findInvalidForSource(block)– die ersteFOR-Quelle, die syntaktisch weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).collectCalledFunctionNamesInBlock(block)– läuft das bestehendecollectCalledFunctionNames()über jeden Blatt-Ausdruck des Blocks; von der Store-Zyklusprüfung genutzt.blockAlwaysReturns(block)– konservative statische Prüfung "erreicht jeder Pfad garantiert einRETURN": wahr, wenn der Block einereturn-Anweisung enthält, oder einIFmitELSE, bei dem beide Zweige immer returnen. EineFOR-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 als0. Eine zahlenförmige Zeichenkette wird gecastet, jede andere ergibt#VALUE!. - Vergleiche (
= <> > < >= <=), Ergebnis1(wahr) oder0(falsch); genutzt vor allem in einerIF-Bedingung eines „Mit Schleife"- Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder eine leere Zelle, die als0zählt) vergleichen numerisch (toNumber()-Koerzierung wie bei+ - * /); zwei Texte (oder eine leere Zelle, die dann als''zählt) vergleichen textuell – case-insensitiv und getrimmt, dieselbe Konvention wieXLOOKUPs Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der anderen Seite an, sodass sowohl=A1=0als auch=A1=""auf einer echt leerenA1funktionieren. Eine echte (nicht-leere) Zahl gegen echten Text ist ein Typ-Mismatch, kein Crossmatch:=/<>antworten eindeutig falsch/wahr,> < >= <=haben keine sinnvolle Antwort und ergeben#VALUE!(sieheevaluator.tsscompareValues()). &verkettet als Text (toText()): eine Zahl rendertString(n)ohne Locale-Formatierung (keinIntl, dieselbe „locale-neutral"- Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt''bei (nicht'0'). Präzedenz zwischen+ -und einem Vergleich (s. o. EBNF), links-assoziativ.CONCAT/CONCATENATE(Text1; Text2; …)sind das Funktions-Äquivalent (functions.ts), ohne Operator-Präzedenz.- 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!. - Ein
NamedRefNode(benannter Bereich) löst live gegenhost.resolveNamedRange()auf und verhält sich danach exakt wie die Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für einen mehrzelligen Treffer). Unbekannter Name →#NAME?. Details: „Benannte Bereiche" oben. 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.IF(Bedingung; Dann; Sonst?)ist die einzige Funktion, die nicht eager ausgewertet wird. Jede andere Funktion sieht infunctions.tsbereits fertig berechneteCellValues (der Evaluator wertet vorher jedes Argument aus und short-circuited beim ersten Fehler);IFwird stattdessen schon inevalScalarscase 'func':abgefangen, bevorfunctions.tsje erreicht wird (evalIf()): nur die Bedingung wird immer ausgewertet, danach ausschließlich der genommene Zweig – der andere Zweig wird gar nicht erst evaluiert, ein Fehler darin schlägt also nie durch (klassisch:=IF(B1=0; 0; A1/B1)). Ohne Sonst-Zweig liefert eine falsche Bedingung0.collectRefs()bleibt fürIFbewusst generisch/konservativ (läuft durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe Konvention wie beikind: 'block'sIF-Abhängigkeiten (s. u.). Ein benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem eingebautenFUNCTIONS.IF-Eintrag).AND/OR/NOTbrauchen dagegen keine Sonderbehandlung — anders alsIF/XLOOKUPsind es ganz normalefunctions.ts-Einträge, ohneevaluator.ts-Kurzschluss:AND/ORwerten (wie in echtem Excel) alle Argumente eager aus, ein Fehler in irgendeinem Argument propagiert also immer, egal ob das logische Ergebnis schon feststeht (=AND(1=2; 1/0)→#DIV/0!, nicht0).functions.ts#isTruthy()ist die eine Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung gebraucht wird –evalIf()undexecStatementsifstmt-Fall importieren sie jetzt ausfunctions.ts, statt sie (wie vor diesem Feature) je einmal inline zu duplizieren.
Datum & Zeit
Kein eigener Zellwert-Typ — ein Datum ist wie in jeder Tabellenkalkulation
einfach eine Zahl (dates.ts): das "Serial", Tage seit dem Nullpunkt
1899-12-30 (Google-Sheets-Konvention: wie Excel, aber ohne dessen
historischen 1900-Schaltjahr-Bug), Uhrzeit als Bruchteil eines Tages
(0.5 = 12:00). Serial 1 = 1899-12-31, Serial 2 = 1900-01-01. Alle
Berechnungen laufen über UTC-Felder (Date.UTC/getUTC*), damit ein Serial
unabhängig von der Browser-Zeitzone exakt rundtrippt. Weil ein Datum einfach
eine Zahl ist, funktionieren +/-/Vergleiche darauf automatisch — keine
Änderung an Operatoren nötig.
dateToSerial(y, mo, d, h?, mi?, s?)— Baustein fürDATE(), reinesDate.UTC, Überlauf bewusst erlaubt (dateToSerial(2026, 13, 1)= 2027-01-01), wie ExcelsDATE().timeSerial(h, mi, s)— Baustein fürTIME(), wrapt modulo 86400 Sekunden wie ExcelsTIME()(timeSerial(25,0,0)= der1/24-Anteil des Folgetags).todaySerial()/nowSerial()— nutzen das lokale Kalenderdatum (ein Datum ist ein Kalender-, kein Zeitzonen-Konzept), nicht UTC.parseDateTimeLiteral(raw)— erkennt eine rohe Zelleingabe als Datum/ Zeit/Datum+Zeit, genutzt sowohl vonworkbook.tssliteralValue()(Text → Serial) als auch vom Store (document.tsssetCellInput, Auto-Format beim Eintippen, s. u.). Bewusst konservativ: nur ISO (2026-09-02, optional[ T]HH:MM[:SS]) und Punkt-Daten (02.09.2026,02.09.26— 2-stellig: 00–29 → 20xx, 30–99 → 19xx —, ebenfalls mit Uhrzeit) sowie reineHH:MM[:SS]-Zeiten werden erkannt. Schrägstrich-Daten (02/09/2026) absichtlich nicht — Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales (en-US monatzuerst, alle anderen tagzuerst) nicht eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen, das bleibt schlechter als es als Text stehen zu lassen. Ein intern überDate.UTCgebauter Kandidat wird durch Rück-Dekomposition verifiziert, um stillen Überlauf abzulehnen (31.04.2026,30.02.2026→ kein Treffer, bleibt Text) — anders alsdateToSerial/DATE(), die Überlauf bewusst zulassen.DATE/TIME/TODAY/NOW(functions.ts) —DATE/TIMEerwarten genau 3 Argumente (sonst#ERR), koerzieren jedes wieevaluator.tsstoNumber()(null → 0, zahlenförmiger Text gecastet, sonst#VALUE!);TODAY/NOWerwarten 0 Argumente. Nicht volatil: diese Engine berechnet rein vorgänger-getrieben neu, ohne Timer/Hintergrundarbeit — eine Zelle mit=TODAY()/=NOW()aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht spontan mit der Zeit (dokumentierte Grenze).- Anzeige (
store/format.ts) —CellNumFmt-Wertedate/time/datetimerendern überserialToUtcDate()+Intl.DateTimeFormat(locale, { timeZone: 'UTC', ... }); das Pseudo-Locale'plain'bekommt eine manuelle, locale-neutraleYYYY-MM-DD/HH:MM-Formatierung (bewusst nichtIntlmitundefined-Locale, das würde lautlos die Host-Locale des Browsers durchsickern lassen). Formel-Ergebnisse bekommen kein automatisches Format — wie jedes andere Formelergebnis auch. Ein viertesCellNumFmt—dateiso— rendert dasselbeYYYY-MM-DDunabhängig von der Locale (nutzt intern dieselbe Formatier-Funktion wie'plain's Datumsanteil), für export-nahe/sortierbare Datumsspalten, die sich nicht mit der Blatt-/Dokument-Locale mitändern sollen. - Auto-Format beim Eintippen (
store/document.tsssetCellInput) — eine erkannte Literal-Eingabe (nicht=-Formeln) setzt automatisch das passendenumFmt, aber nur solange die Zelle noch'auto'ist; ein bereits explizit gesetztes Format wird nie überschrieben.
Leere Argumentstelle (OmittedNode)
funcalls Grammatik (s. o.) erlaubt jetzt eine leere Stelle zwischen zwei
; (oder zwischen einem ; und dem schließenden )) statt eines
Ausdrucks — Excels "dieses optionale Argument einfach auslassen"-Idiom,
z. B. XLOOKUP(x; a; b; ; -1), das WennNichtGefunden überspringt, um
Vergleichsmodus zu erreichen. Der Parser produziert dafür einen eigenen
OmittedNode (Teil des Node-Unions, s. o.) statt eines Parse-Fehlers.
evalScalar wertet ihn wie eine leere Zelle aus (null) — für die meisten
Funktionen (SUM, COUNT, …) verhält er sich also genauso wie eine echte
leere Zelle in einem Bereich. Eine Funktion, die "explizit ausgelassen"
von "wertet zu leer aus" unterscheiden muss (nur XLOOKUP, bisher), prüft
arg.kind === 'omitted' auf dem rohen Argument-Knoten, bevor sie ihn
auswertet (evalXLookups given()-Hilfsfunktion in evaluator.ts).
expand()/substitute() (userFunctions.ts) behandeln ihn als Blatt ohne
weitere Rekursion, collectRefs() ignoriert ihn stillschweigend (kein
Bezug enthalten).
Nachschlagen (XLOOKUP)
XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]) ist – wie IF – eine Funktion, die
evalScalars case 'func': vor dem generischen eager/flatten-Pfad
abfängt (evalXLookup()), aus zwei Gründen, nicht nur einem:
WennNichtGefundenmuss lazy ausgewertet werden — nur wenn tatsächlich kein Treffer gefunden wurde (derselbe Kurzschluss-Grundsatz wie beiIFs Zweigen).SuchbereichundRückgabebereichdürfen nicht zusammen flach geklopft werden. Der generische Pfad (jede Funktion außerIFundXLOOKUP) wertet jedes Argument überevalArg()aus und hängt alle Ergebnisse an eine gemeinsame flache Liste an (siehefunctions.tss Modul-Kommentar) — dabei geht die Grenze zwischen zwei Bereichs-Argumenten komplett verloren.evalArg()selbst liefert pro Argument aber bereits ein eigenes Array zurück;evalXLookup()ruft es fürSuchbereichundRückgabebereichseparat auf und hält die beiden Arrays getrennt, statt sie (wie der generische Pfad) zusammenzuführen.
Trefferlogik: Text wird über eine eigene xlookupEquals() verglichen
(case-insensitiv, getrimmt) statt über den =-Operator (der über
toNumber() koerziert und deshalb nie auf Text träfe – XLOOKUPs
häufigster Anwendungsfall). Zahl und Text gelten nie als gleich. Bei
Vergleichsmodus -1/1 gewinnt ein exakter Treffer immer sofort, sonst
wird der nächstkleinere/-größere numerische Kandidat gesucht (Text
wird dabei übersprungen, nicht verglichen). Suchmodus -1 kehrt einfach
die Durchlaufreihenfolge um (kein Binärsuche-Algorithmus, kein sortierter
Bereich vorausgesetzt).
#N/A (kein Treffer, kein WennNichtGefunden) ist eine dokumentierte
Erweiterung des ursprünglichen 5-Fehlercode-Sets, mit derselben Begründung
wie seinerzeit #VALUE!. collectRefs() braucht keine Änderung –
sein generischer case 'func': läuft bereits durch alle Argumente
(inkl. Suchbereich/Rückgabebereich), unabhängig davon, ob ein Treffer
gefunden wird — dieselbe konservative Konvention wie bei IF.
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.
Workbook.graphPrecedents/graphDependents (dünne öffentliche Wrapper
um DependencyGraph.getPrecedents/getDependents) sind der einzige Ort, an
dem der Graph abgefragt statt nur für die Neuberechnung benutzt wird –
die Datenquelle des interaktiven Abhängigkeiten-Panels (src/store/ document.tss cellGraphNeighbors/cellGraphSelf, src/components/ DependencyGraphView.vue, s. Haupt-README „Abhängigkeiten erkunden"). Rein
lesend, ohne jeden Effekt auf Auswertung/Neuberechnung; bleiben auch für
eine Zelle auf einem Zirkelbezug korrekt (die Kante existiert im Graphen
unabhängig davon, dass topoOrder sie aus der Auswertung ausschließt).
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); auch XLOOKUP mit ungleich großem Such-/Rückgabebereich. Erweiterung über die Aufgabenstellung hinaus, dokumentiert. |
#N/A |
XLOOKUP ohne Treffer und ohne WennNichtGefunden. 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.
Jeder dieser sieben Codes ist außerdem als ErrorLiteralNode direkt in
eine Formel tippbar (=#REF, =IF(A1=0;#N/A;A1), …) – der Lexer erkennt
ihn als eigenes ERRORLIT-Token (längster Treffer zuerst, s. o. EBNF),
evalScalars case 'errorliteral': gibt unbedingt new FormulaError(code)
zurück. store/document.tss Zeilen-/Spalten-Löschen ist der eine Ort, der
selbst Formeltext mit #REF-Literalen erzeugt (für eine dadurch
kollabierte Referenz) – ansonsten ist es reine Nutzer-Eingabe, wie eine
Zahl- oder Text-Konstante. Kein ! am Ende (#REF, nicht Excels #REF!)
– derselbe Code, den diese Engine selbst überall sonst produziert.