# 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) ```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 `FunctionDef`s); `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 (`evalScalar`s `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** (`Parser`s `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: ```ebnf 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: ```ts 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` 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 `evaluateCell`s 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.ts`s `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 **text**uell – case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s 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.ts`s `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 `CellValue`s (der Evaluator wertet vorher jedes Argument aus und short-circuited beim ersten Fehler); `IF` wird stattdessen schon in `evalScalar`s `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 `execStatement`s `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.ts`s `literalValue()` (Text → Serial) als auch vom Store (`document.ts`s `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.ts`s `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.ts`s `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`) `funcall`s 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 (`evalXLookup`s `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 `evalScalar`s `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 `IF`s 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.ts`s 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.ts`s `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), `evalScalar`s `case 'errorliteral':` gibt unbedingt `new FormulaError(code)` zurück. `store/document.ts`s 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.