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>
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user