Files
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

595 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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 `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.