Files
numolus/app/frontend/engine
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
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>
2026-09-28 20:09:06 +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 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):

  • sheetName gegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen auf diesem Blatt trifft; sonst null (→ #REF).
  • sheetName fehlt und der Tabellenname ist eindeutig im Dokument → wie bisher, unverändert.
  • sheetName fehlt und der Name kommt auf mehreren Blättern vor → bevorzugt die Tabelle auf demselben Blatt wie this.homeTableId (dafür setzen sowohl evaluateCell als auch precedentsOf homeTableId, 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 dem Workbook-Konstruktor als dritter Parameter mitgegeben (analog zu den FunctionDefs); store/document.ts#toNamedRangeDefs mappt DocumentState.namedRanges dorthin.
  • workbook.ts#Workbook.resolveNamedRange(name) sucht case-insensitiv/ getrimmt (dieselbe Konvention wie resolveTableName), löst tableId über tableById() in den aktuellen Tabellennamen auf und gibt {qualifier, sheetQualifier, start, end} zurück – exakt die Form, die ein CellNode/RangeNode sonst auch trägt. Zieltabelle nicht mehr vorhanden → null → #NAME? (dieselbe Fehlerklasse wie ein unbekannter Name).
  • evaluator.ts#resolveNamedRef() baut aus diesem Ergebnis einen synthetischen CellNode (1×1-Bereich) oder RangeNode und lässt jeden Aufrufer (evalScalars case 'namedref', evalArg, rangeOrCellToCellNodes für eine FOR-Quelle) in die bereits vorhandene Zell-/Bereichs-Logik durchfallen – keine Duplikation.
  • parser.ts#collectRefs sammelt einen NamedRefNode unaufgelöst (der Parser kennt das Dokument nicht); workbook.ts#precedentsOf löst ihn dann über resolveNamedRange in 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 NamedRefNode statt eines ParseError – aber nur außerhalb eines Funktionskörpers (Parsers namedRefFallback-Flag, true nur wenn parseFormula() ganz ohne paramNames-Argument aufgerufen wird). Ein Aufrufer kann trotzdem einen benannten Bereich als Argument an eine Funktion übergeben – der NamedRefNode steht dann in node.args, wird über expand()/substitute() (beide bekamen dafür einen case 'namedref': return node;-Leaf, wie cell/range) an die passende Parameterstelle im Funktionskörper substituiert, und trifft dort z. B. als FOR-Quelle auf rangeOrCellToCellNodes, das ihn ganz normal auflöst. userFunctions.ts#findInvalidForSource erlaubt namedref deshalb ausdrücklich als gültige FOR-Quellenform (neben param/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 Startumgebung env: Map<string, Node> aus paramNames/args und ruft execBlock/execStatement auf.
  • Eine assignment/return/ifstmt-Bedingung wird über substitute(expr, env) (aus userFunctions.ts, dafür exportiert) gegen env aufgelöst und dann ganz normal evalScalar-ausgewertet; das Ergebnis wird (als Literal-Node) zurück in env geschrieben.
  • Eine forLoop löst ihre (substituierte) Quelle über rangeOrCellToCell- Nodes() in eine konkrete Zellliste auf (expandRange, wie evalArg es für ein Bereichsargument tut) – ein Einzelzellbezug ergibt genau eine Iteration – und führt body (ein vollständiger, ggf. verschachtelter BlockNode) einmal je Zelle aus, in Zellreihenfolge. Schleifenvariable und alle im Körper zugewiesenen Namen bleiben danach in env (flacher Namensraum: sichtbar mit ihrem letzten Wert für den Rest der Ausführung).
  • return wirft ein ReturnSignal (Wert), das nur evaluateBlockCall fängt – ein Standard-"Unwind zu einer bekannten Grenze"-Muster für frühen Ausstieg. Das bricht sofort jede laufende Schleife/jedes IF ab, egal wie tief verschachtelt.
  • Läuft die Ausführung durch, ohne dass je ReturnSignal geworfen wurde, wirft evaluateBlockCall selbst einen Error ("… 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-Muster ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END, wo „ergebnis" zweimal auftaucht, aber keine Kollision ist).
  • findInvalidForSource(block) – die erste FOR-Quelle, die syntaktisch weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
  • collectCalledFunctionNamesInBlock(block) – läuft das bestehende collectCalledFunctionNames() über jeden Blatt-Ausdruck des Blocks; von der Store-Zyklusprüfung genutzt.
  • blockAlwaysReturns(block) – konservative statische Prüfung "erreicht jeder Pfad garantiert ein RETURN": wahr, wenn der Block eine return-Anweisung enthält, oder ein IF mit ELSE, bei dem beide Zweige immer returnen. Eine FOR-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 als 0. Eine zahlenförmige Zeichenkette wird gecastet, jede andere ergibt #VALUE!.
  • Vergleiche (= <> > < >= <=), Ergebnis 1 (wahr) oder 0 (falsch); genutzt vor allem in einer IF-Bedingung eines „Mit Schleife"- Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder eine leere Zelle, die als 0 zä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 wie XLOOKUPs 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=0 als auch =A1="" auf einer echt leeren A1 funktionieren. 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! (siehe evaluator.tss compareValues()).
  • & verkettet als Text (toText()): eine Zahl rendert String(n) ohne Locale-Formatierung (kein Intl, 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 gegen host.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!.
  • 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.
  • IF(Bedingung; Dann; Sonst?) ist die einzige Funktion, die nicht eager ausgewertet wird. Jede andere Funktion sieht in functions.ts bereits fertig berechnete CellValues (der Evaluator wertet vorher jedes Argument aus und short-circuited beim ersten Fehler); IF wird stattdessen schon in evalScalars case 'func': abgefangen, bevor functions.ts je 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 Bedingung 0. collectRefs() bleibt für IF bewusst generisch/konservativ (läuft durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe Konvention wie bei kind: 'block's IF-Abhängigkeiten (s. u.). Ein benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem eingebauten FUNCTIONS.IF-Eintrag).
  • AND/OR/NOT brauchen dagegen keine Sonderbehandlung — anders als IF/XLOOKUP sind es ganz normale functions.ts-Einträge, ohne evaluator.ts-Kurzschluss: AND/OR werten (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!, nicht 0). functions.ts#isTruthy() ist die eine Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung gebraucht wird – evalIf() und execStatements ifstmt-Fall importieren sie jetzt aus functions.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ür DATE(), reines Date.UTC, Überlauf bewusst erlaubt (dateToSerial(2026, 13, 1) = 2027-01-01), wie Excels DATE().
  • timeSerial(h, mi, s) — Baustein für TIME(), wrapt modulo 86400 Sekunden wie Excels TIME() (timeSerial(25,0,0) = der 1/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 von workbook.tss literalValue() (Text → Serial) als auch vom Store (document.tss setCellInput, 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 reine HH: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 über Date.UTC gebauter Kandidat wird durch Rück-Dekomposition verifiziert, um stillen Überlauf abzulehnen (31.04.2026, 30.02.2026 → kein Treffer, bleibt Text) — anders als dateToSerial/DATE(), die Überlauf bewusst zulassen.
  • DATE/TIME/TODAY/NOW (functions.ts) — DATE/TIME erwarten genau 3 Argumente (sonst #ERR), koerzieren jedes wie evaluator.tss toNumber() (null → 0, zahlenförmiger Text gecastet, sonst #VALUE!); TODAY/NOW erwarten 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-Werte date/time/ datetime rendern über serialToUtcDate() + Intl.DateTimeFormat(locale, { timeZone: 'UTC', ... }); das Pseudo-Locale 'plain' bekommt eine manuelle, locale-neutrale YYYY-MM-DD/HH:MM-Formatierung (bewusst nicht Intl mit undefined-Locale, das würde lautlos die Host-Locale des Browsers durchsickern lassen). Formel-Ergebnisse bekommen kein automatisches Format — wie jedes andere Formelergebnis auch. Ein viertes CellNumFmt — dateiso — rendert dasselbe YYYY-MM-DD unabhä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.tss setCellInput) — eine erkannte Literal-Eingabe (nicht =-Formeln) setzt automatisch das passende numFmt, 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:

  1. WennNichtGefunden muss lazy ausgewertet werden — nur wenn tatsächlich kein Treffer gefunden wurde (derselbe Kurzschluss-Grundsatz wie bei IFs Zweigen).
  2. Suchbereich und Rückgabebereich dürfen nicht zusammen flach geklopft werden. Der generische Pfad (jede Funktion außer IF und XLOOKUP) wertet jedes Argument über evalArg() aus und hängt alle Ergebnisse an eine gemeinsame flache Liste an (siehe functions.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ür Suchbereich und Rückgabebereich separat 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

  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.

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.