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>
595 lines
37 KiB
Markdown
595 lines
37 KiB
Markdown
# 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.
|