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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+594
View File
@@ -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.
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS } from '../functions';
import { dateToSerial, timeSerial } from '../dates';
import { isError, type CellValue } from '../types';
const call = (name: string, ...args: CellValue[]) => FUNCTIONS[name](args);
const d = (y: number, m: number, day: number) => dateToSerial(y, m, day);
describe('date parts', () => {
const t = dateToSerial(2026, 9, 2, 14, 30, 15);
it('YEAR / MONTH / DAY / HOUR / MINUTE / SECOND', () => {
expect(call('YEAR', t)).toBe(2026);
expect(call('MONTH', t)).toBe(9);
expect(call('DAY', t)).toBe(2);
expect(call('HOUR', t)).toBe(14);
expect(call('MINUTE', t)).toBe(30);
expect(call('SECOND', t)).toBe(15);
});
it('rounds to the whole second, so float noise never shows as :59', () => {
const noisy = timeSerial(14, 29, 59) + 1 / 86400;
expect(call('HOUR', noisy)).toBe(14);
expect(call('MINUTE', noisy)).toBe(30);
expect(call('SECOND', noisy)).toBe(0);
});
it('checks argument count and type', () => {
expect(isError(call('YEAR'))).toBe(true);
expect(isError(call('YEAR', 1, 2))).toBe(true);
expect(isError(call('YEAR', 'kein Datum'))).toBe(true);
});
});
describe('WEEKDAY (ISO default: Monday = 1)', () => {
const wed = d(2026, 9, 2);
it('defaults to ISO numbering', () => {
expect(call('WEEKDAY', wed)).toBe(3);
expect(call('WEEKDAY', d(2026, 9, 6))).toBe(7); // Sunday
});
it('supports Excel types 1, 2, 3', () => {
expect(call('WEEKDAY', wed, 1)).toBe(4); // Sunday = 1
expect(call('WEEKDAY', wed, 2)).toBe(3);
expect(call('WEEKDAY', wed, 3)).toBe(2); // Monday = 0
expect(isError(call('WEEKDAY', wed, 7))).toBe(true);
});
});
describe('ISO week numbers', () => {
it('handles the year boundaries', () => {
expect(call('ISOWEEKNUM', d(2026, 1, 1))).toBe(1); // Thursday
expect(call('ISOWEEKNUM', d(2027, 1, 1))).toBe(53); // Friday -> last week of 2026
expect(call('ISOWEEKNUM', d(2021, 1, 3))).toBe(53); // Sunday -> week 53 of 2020
expect(call('ISOWEEKNUM', d(2024, 12, 30))).toBe(1); // Monday -> week 1 of 2025
expect(call('ISOWEEKNUM', d(2026, 9, 2))).toBe(36);
});
it('WEEKNUM is ISO by default, US-style with type 1/2', () => {
expect(call('WEEKNUM', d(2021, 1, 3))).toBe(53);
expect(call('WEEKNUM', d(2026, 1, 3), 1)).toBe(1); // Saturday, same week as 1 Jan
expect(call('WEEKNUM', d(2026, 1, 4), 1)).toBe(2); // Sunday starts week 2
expect(call('WEEKNUM', d(2026, 1, 4), 2)).toBe(1); // ... but not when weeks start Monday
expect(call('WEEKNUM', d(2026, 1, 5), 2)).toBe(2);
});
});
describe('month arithmetic', () => {
it('EDATE clamps to the end of a shorter month', () => {
expect(call('EDATE', d(2026, 1, 31), 1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2024, 1, 31), 1)).toBe(d(2024, 2, 29));
expect(call('EDATE', d(2026, 3, 31), -1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2026, 9, 2), 12)).toBe(d(2027, 9, 2));
});
it('EOMONTH', () => {
expect(call('EOMONTH', d(2026, 9, 2), 0)).toBe(d(2026, 9, 30));
expect(call('EOMONTH', d(2026, 9, 2), -1)).toBe(d(2026, 8, 31));
expect(call('EOMONTH', d(2026, 1, 15), 1)).toBe(d(2026, 2, 28));
});
});
describe('differences', () => {
it('DAYS counts whole calendar days, ignoring the time', () => {
expect(call('DAYS', d(2026, 12, 31), d(2026, 1, 1))).toBe(364);
expect(call('DAYS', d(2026, 1, 2) + 0.9, d(2026, 1, 1) + 0.1)).toBe(1);
});
it('DATEDIF in every unit', () => {
const s = d(2020, 2, 29);
const e = d(2026, 9, 2);
expect(call('DATEDIF', s, e, 'Y')).toBe(6);
expect(call('DATEDIF', s, e, 'M')).toBe(78);
expect(call('DATEDIF', s, e, 'D')).toBe(e - s);
expect(call('DATEDIF', s, e, 'YM')).toBe(6);
expect(call('DATEDIF', s, e, 'MD')).toBe(4); // 29.08. -> 02.09.
expect(call('DATEDIF', s, e, 'yd')).toBe(d(2026, 9, 2) - d(2026, 2, 28));
expect(isError(call('DATEDIF', e, s, 'D'))).toBe(true);
expect(isError(call('DATEDIF', s, e, 'W'))).toBe(true);
});
});
describe('working days (Monday-Friday)', () => {
it('NETWORKDAYS counts both ends and skips holidays on weekdays', () => {
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30))).toBe(22);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 15))).toBe(21);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 5))).toBe(22); // Saturday
expect(call('NETWORKDAYS', d(2026, 9, 30), d(2026, 9, 1))).toBe(-22);
});
it('WORKDAY steps over weekends and holidays, both directions', () => {
expect(call('WORKDAY', d(2026, 9, 4), 1)).toBe(d(2026, 9, 7)); // Fri -> Mon
expect(call('WORKDAY', d(2026, 9, 7), -1)).toBe(d(2026, 9, 4));
expect(call('WORKDAY', d(2026, 9, 4), 1, d(2026, 9, 7))).toBe(d(2026, 9, 8));
expect(call('WORKDAY', d(2026, 9, 2), 0)).toBe(d(2026, 9, 2));
});
});
describe('DATEVALUE / TIMEVALUE', () => {
it('read the same formats a cell entry is recognized in', () => {
expect(call('DATEVALUE', '2026-09-02')).toBe(d(2026, 9, 2));
expect(call('DATEVALUE', '02.09.2026 14:30')).toBe(d(2026, 9, 2));
expect(call('TIMEVALUE', '14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
expect(call('TIMEVALUE', '2026-09-02 14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
});
it('pass a number through and reject other text', () => {
expect(call('DATEVALUE', d(2026, 9, 2) + 0.5)).toBe(d(2026, 9, 2));
expect(isError(call('DATEVALUE', 'hallo'))).toBe(true);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import {
dateToSerial,
timeSerial,
serialToUtcDate,
todaySerial,
parseDateTimeLiteral,
} from '../dates';
describe('dateToSerial / serialToUtcDate', () => {
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
expect(dateToSerial(1899, 12, 31)).toBe(1);
expect(dateToSerial(1900, 1, 1)).toBe(2);
});
it('round-trips through serialToUtcDate', () => {
const s = dateToSerial(2026, 9, 2);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2026);
expect(d.getUTCMonth()).toBe(8); // 0-based
expect(d.getUTCDate()).toBe(2);
});
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
const s = dateToSerial(2026, 13, 1);
const d = serialToUtcDate(s);
expect(d.getUTCFullYear()).toBe(2027);
expect(d.getUTCMonth()).toBe(0);
expect(d.getUTCDate()).toBe(1);
});
it('carries a fractional part for a time-of-day component', () => {
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
expect(s % 1).toBeCloseTo(0.5, 10);
});
});
describe('timeSerial', () => {
it('noon = 0.5, midnight = 0', () => {
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
expect(timeSerial(0, 0, 0)).toBe(0);
});
it('wraps past 24h like Excel TIME()', () => {
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
});
});
describe('todaySerial', () => {
it('matches the local calendar date', () => {
const n = new Date();
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
});
});
describe('parseDateTimeLiteral', () => {
it('recognizes ISO dates', () => {
const r = parseDateTimeLiteral('2026-09-02');
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
});
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
});
});
it('recognizes dot dates, 4- and 2-digit years', () => {
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.26')).toEqual({
kind: 'date',
serial: dateToSerial(2026, 9, 2),
});
expect(parseDateTimeLiteral('02.09.99')).toEqual({
kind: 'date',
serial: dateToSerial(1999, 9, 2),
});
});
it('recognizes dot datetimes', () => {
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
kind: 'datetime',
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
});
});
it('recognizes plain times', () => {
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
});
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
});
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
});
it('rejects an out-of-range minute/second', () => {
expect(parseDateTimeLiteral('12:60')).toBeNull();
expect(parseDateTimeLiteral('37:30:60')).toBeNull();
});
it('reads 24 hours or more as a duration, not a time of day', () => {
expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 });
expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 });
expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12);
expect(parseDateTimeLiteral('23:59')!.kind).toBe('time');
});
it('does not misfire on plain numbers or arbitrary text', () => {
expect(parseDateTimeLiteral('1234.5')).toBeNull();
expect(parseDateTimeLiteral('hello')).toBeNull();
expect(parseDateTimeLiteral('')).toBeNull();
});
});
@@ -0,0 +1,445 @@
import { describe, it, expect } from 'vitest';
import { parseFormula } from '../parser';
import { evaluate } from '../evaluator';
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
/** Minimal in-memory host: two tables addressed by name, plus an optional
* name -> named-range map (name lower-cased, same matching convention as
* the real Workbook). */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'S1',
namedRanges: Record<string, NamedRangeShape> = {},
): EngineHost {
const sizes: Record<string, { cols: number; rows: number }> = {
S1: { cols: 10, rows: 10 },
S2: { cols: 10, rows: 10 },
};
const idFor = (name: string): string | null => {
const n = name.trim().toLowerCase();
if (n === 's1') return 'S1';
if (n === 's2') return 'S2';
return null;
};
return {
homeTableId: home,
resolveTableName: idFor,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
tableSize: (id) => sizes[id] ?? null,
getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1);
const v = data[tableId]?.[cell];
return v === undefined ? null : v;
},
};
}
const ev = (src: string, host: EngineHost) => evaluate(parseFormula(src), host);
describe('evaluator arithmetic', () => {
const host = makeHost({ S1: { B2: 10, C2: 5, D2: 8 } });
it('respects precedence and associativity', () => {
expect(ev('=(B2+C2)*2-D2/4', host)).toBe(28); // (15)*2 - 2
expect(ev('=2^3^2', host)).toBe(512);
expect(ev('=-2^2', host)).toBe(-4);
expect(ev('=2+3*4', host)).toBe(14);
});
it('treats an empty cell as 0', () => {
expect(ev('=B2+Z9', host)).toBe(10);
});
it('division by zero -> #DIV/0!', () => {
expect(ev('=1/0', host)).toEqual(new FormulaError('#DIV/0!'));
});
});
describe('evaluator functions', () => {
const host = makeHost({
S1: { B2: 1200, B3: 980, B4: 1550, B5: 1340, B6: 1105 },
});
it('SUM / AVG / MIN / MAX / COUNT over a range', () => {
expect(ev('=SUM(B2:B6)', host)).toBe(6175);
expect(ev('=AVG(B2:B6)', host)).toBe(1235);
expect(ev('=MIN(B2:B6)', host)).toBe(980);
expect(ev('=MAX(B2:B6)', host)).toBe(1550);
expect(ev('=COUNT(B2:B6)', host)).toBe(5);
});
it('AVERAGE is an alias of AVG', () => {
expect(ev('=AVERAGE(B2:B6)', host)).toBe(1235);
});
it('mixes ranges, literals and single cells', () => {
expect(ev('=SUM(B2:B3; 100; B4)', host)).toBe(1200 + 980 + 100 + 1550);
});
it('COUNT ignores empty cells', () => {
expect(ev('=COUNT(B2:B10)', host)).toBe(5);
});
it('AVG of no numbers -> #DIV/0!', () => {
const r = ev('=AVG(Z1:Z5)', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('unknown function -> #NAME?', () => {
const r = ev('=FOO(1)', host);
expect(isError(r) && r.code).toBe('#NAME?');
});
it('an omitted argument slot (e.g. SUM(1;;3)) behaves like an empty cell', () => {
expect(ev('=SUM(1;;3)', host)).toBe(4);
expect(ev('=COUNT(1;;3)', host)).toBe(2); // omitted, like an empty cell, isn't counted
});
});
describe('evaluator IF', () => {
const host = makeHost({ S1: { A1: 8, B1: 0 } });
it('picks the then-branch when the condition is truthy', () => {
expect(ev('=IF(1;2;3)', host)).toBe(2);
expect(ev('=IF(A1>5;"groß";"klein")', host)).toBe('groß');
});
it('picks the else-branch when the condition is falsy', () => {
expect(ev('=IF(0;2;3)', host)).toBe(3);
expect(ev('=IF(A1<5;"groß";"klein")', host)).toBe('klein');
});
it('returns 0 for a falsy condition with no else-branch', () => {
expect(ev('=IF(0;2)', host)).toBe(0);
});
it('never evaluates the untaken branch (short-circuit)', () => {
expect(ev('=IF(1;2;1/0)', host)).toBe(2);
expect(ev('=IF(B1=0;0;A1/B1)', host)).toBe(0);
});
it('propagates an error from the condition itself', () => {
const r = ev('=IF(1/0;2;3)', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=IF(1)', host))).toBe(true);
expect(isError(ev('=IF(1;2;3;4)', host))).toBe(true);
});
it('works with comparison operators as the condition', () => {
expect(ev('=IF(A1>=8;1;0)', host)).toBe(1);
expect(ev('=IF(A1<>8;1;0)', host)).toBe(0);
});
});
describe('evaluator AND/OR/NOT', () => {
const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } });
it('AND is true only when every argument is truthy', () => {
expect(ev('=AND(1;1;1)', host)).toBe(1);
expect(ev('=AND(1;0;1)', host)).toBe(0);
expect(ev('=AND(A1>0;A1<10)', host)).toBe(1);
expect(ev('=AND(A1>0;A1<5)', host)).toBe(0);
});
it('OR is true when at least one argument is truthy', () => {
expect(ev('=OR(0;0;1)', host)).toBe(1);
expect(ev('=OR(0;0;0)', host)).toBe(0);
});
it('NOT inverts a single condition', () => {
expect(ev('=NOT(1)', host)).toBe(0);
expect(ev('=NOT(0)', host)).toBe(1);
expect(ev('=NOT(B1=0)', host)).toBe(0);
});
it('a range argument checks every cell (Excel behaviour)', () => {
const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } });
expect(ev('=AND(A1:A3)', h)).toBe(0);
expect(ev('=OR(A1:A3)', h)).toBe(1);
});
it('empty cell and non-empty text follow the shared truthy convention', () => {
expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy
expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy
});
it('composes inside IF, unlike a bare chained comparison', () => {
expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok');
});
it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => {
expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true);
expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true);
});
it('wrong arity -> #ERR', () => {
expect(isError(ev('=AND()', host))).toBe(true);
expect(isError(ev('=NOT(1;2)', host))).toBe(true);
expect(isError(ev('=NOT()', host))).toBe(true);
});
});
describe('evaluator error literals (ErrorLiteralNode)', () => {
const host = makeHost({ S1: {} });
it('evaluates directly to that FormulaError', () => {
const r = ev('=#REF', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates through arithmetic like any other error', () => {
const r = ev('=1+#DIV/0!', host);
expect(isError(r) && r.code).toBe('#DIV/0!');
});
it('usable as an IF branch, e.g. a deliberate #N/A', () => {
expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5);
const r = ev('=IF(1=1;#N/A;5)', host);
expect(isError(r) && r.code).toBe('#N/A');
});
});
describe('evaluator comparisons', () => {
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
it('numeric comparisons work as before, empty cell counts as 0', () => {
expect(ev('=B1>=5', host)).toBe(1);
expect(ev('=B1<>5', host)).toBe(0);
expect(ev('=Z9=0', host)).toBe(1);
});
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
expect(ev('=A1="Nord"', host)).toBe(1);
expect(ev('=A1=" nord "', host)).toBe(1);
expect(ev('=A1="Süd"', host)).toBe(0);
});
it('text ordering compares lexicographically', () => {
expect(ev('=A1<"Süd"', host)).toBe(1);
expect(ev('=A1>"Süd"', host)).toBe(0);
});
it('an empty cell takes on whichever type the other side is', () => {
expect(ev('=Z9=""', host)).toBe(1);
expect(ev('=Z9="x"', host)).toBe(0);
});
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
expect(ev('=B1="5"', host)).toBe(0);
expect(ev('=B1<>"5"', host)).toBe(1);
expect(isError(ev('=B1>"5"', host))).toBe(true);
expect(isError(ev('=A1<5', host))).toBe(true);
});
});
describe('evaluator & (text concatenation)', () => {
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
it('joins two strings', () => {
expect(ev('="a"&"b"', host)).toBe('ab');
});
it('renders a number plain, no locale formatting', () => {
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
});
it('an empty cell contributes "" (not "0")', () => {
expect(ev('="["&Z9&"]"', host)).toBe('[]');
});
it('is left-associative and chains', () => {
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
});
it('binds looser than + but tighter than a comparison', () => {
expect(ev('="a"&1+2', host)).toBe('a3');
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
});
it('propagates an error from either side', () => {
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
});
});
describe('evaluator CONCAT/CONCATENATE', () => {
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
it('joins any number of arguments, no separator', () => {
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
});
it('an empty cell contributes ""', () => {
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
});
});
describe('evaluator named ranges (NamedRefNode)', () => {
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
});
expect(ev('=Preis + 1', host)).toBe(43);
});
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
});
expect(ev('=PREIS', host)).toBe(42);
});
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
});
expect(ev('=SUM(Bereich)', host)).toBe(15);
});
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
});
expect(isError(ev('=Bereich', host))).toBe(true);
});
it('an unknown name is #NAME?', () => {
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
});
it('resolves through another table via its qualifier', () => {
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
});
expect(ev('=Andernorts * 2', host)).toBe(14);
});
});
describe('evaluator XLOOKUP', () => {
const textHost = makeHost({
S1: {
A2: 'Apfel', B2: 1.5,
A3: 'Birne', B3: 2,
A4: 'Kirsche', B4: 5,
A5: 'Birne', B5: 99, // duplicate, for search-mode tests
},
});
const bracketHost = makeHost({
S1: { D2: 0, E2: 'A', D3: 10, E3: 'B', D4: 20, E4: 'C', D5: 30, E5: 'D' },
});
it('exact-matches text, case-insensitively and trimmed', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4)', textHost)).toBe(2);
expect(ev('=XLOOKUP(" BIRNE ";A2:A4;B2:B4)', textHost)).toBe(2);
});
it('exact-matches numbers', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5)', bracketHost)).toBe('B');
});
it('does not cross-match a number against text', () => {
const r = ev('=XLOOKUP(5;A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match and no if_not_found -> #N/A', () => {
const r = ev('=XLOOKUP("Traube";A2:A4;B2:B4)', textHost);
expect(isError(r) && r.code).toBe('#N/A');
});
it('no match falls back to if_not_found', () => {
expect(ev('=XLOOKUP("Traube";A2:A4;B2:B4;"?")', textHost)).toBe('?');
});
it('if_not_found is evaluated lazily (never touched on a match)', () => {
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4;1/0)', textHost)).toBe(2);
});
it('search_mode: 1 (default) finds the first match, -1 the last', () => {
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5)', textHost)).toBe(2);
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5;;0;-1)', textHost)).toBe(99);
});
it('match_mode -1/1: exact match still wins outright', () => {
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B');
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;1)', bracketHost)).toBe('B');
});
it('match_mode -1: nearest smaller', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B'); // 10
});
it('match_mode 1: nearest larger', () => {
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;1)', bracketHost)).toBe('C'); // 20
});
it('match_mode -1/1: no qualifying candidate -> #N/A (or if_not_found)', () => {
expect(isError(ev('=XLOOKUP(-5;D2:D5;E2:E5;;-1)', bracketHost))).toBe(true);
expect(ev('=XLOOKUP(-5;D2:D5;E2:E5;"keine";-1)', bracketHost)).toBe('keine');
});
it('unsupported match_mode (2) or search_mode (2/-2) -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;2)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;0;2)', textHost))).toBe(true);
});
it('mismatched lookup/return range sizes -> #VALUE!', () => {
const r = ev('=XLOOKUP("x";A2:A4;B2:B5)', textHost);
expect(isError(r) && r.code).toBe('#VALUE!');
});
it('wrong number of arguments -> #ERR', () => {
expect(isError(ev('=XLOOKUP("x";A2:A4)', textHost))).toBe(true);
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;1;2;3;4)', textHost))).toBe(true);
});
it('works with single cells (a 1-cell "range") for lookup/return', () => {
expect(ev('=XLOOKUP("Apfel";A2;B2)', textHost)).toBe(1.5);
});
});
describe('evaluator cross-sheet & errors', () => {
const host = makeHost({
S1: { A1: 3 },
S2: { A1: 7, B1: 100 },
});
it('resolves Name!Ref and is case-insensitive', () => {
expect(ev('=S2!A1 + S2!B1', host)).toBe(107);
expect(ev('=s2!a1', host)).toBe(7);
});
it('accepts "::" the same as "!"', () => {
expect(ev('=S2::A1 + S2::B1', host)).toBe(107);
expect(ev('=s2::a1', host)).toBe(ev('=S2!A1', host));
});
it('unknown sheet -> #REF', () => {
const r = ev('=Nix!A1', host);
expect(isError(r) && r.code).toBe('#REF');
});
it('propagates an error from a referenced cell', () => {
const h = makeHost({ S1: { A1: new FormulaError('#DIV/0!') } });
const r = ev('=A1 + 1', h);
expect(isError(r) && r.code).toBe('#DIV/0!');
const r2 = ev('=SUM(A1; 5)', h);
expect(isError(r2) && r2.code).toBe('#DIV/0!');
});
it('a bare range in scalar position -> #VALUE!', () => {
const r = ev('=A1:A3 + 1', host);
expect(isError(r) && r.code).toBe('#VALUE!');
});
it('text in arithmetic -> #VALUE!, numeric text is coerced', () => {
const h = makeHost({ S1: { A1: 'abc', A2: '12' } });
expect(isError(ev('=A1*2', h))).toBe(true);
expect(ev('=A2*2', h)).toBe(24);
});
});
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS, BUILTIN_SIGNATURES } from '../functions';
describe('BUILTIN_SIGNATURES', () => {
it('has exactly one entry per registered built-in function, names matching', () => {
const registered = Object.keys(FUNCTIONS).sort();
const signatures = BUILTIN_SIGNATURES.map((s) => s.name).sort();
// A future new built-in without a matching hint entry fails loudly here
// instead of just silently being missing from the autocomplete dropdown.
expect(signatures).toEqual(registered);
});
it('every hint actually starts with its own name', () => {
for (const s of BUILTIN_SIGNATURES) {
expect(s.hint.startsWith(s.name)).toBe(true);
}
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { DependencyGraph } from '../graph';
/**
* Helper: build a graph from "cell <- precedent, precedent" lines.
* Node names are arbitrary strings here (real code uses `keyOf`).
*/
function build(edges: Record<string, string[]>): DependencyGraph {
const g = new DependencyGraph();
for (const [cell, precs] of Object.entries(edges)) g.setPrecedents(cell, precs);
return g;
}
describe('DependencyGraph.affected', () => {
it('returns dirty cells plus their transitive dependents', () => {
// A -> B -> C ; A -> D
const g = build({ B: ['A'], C: ['B'], D: ['A'] });
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B', 'C', 'D']);
expect([...g.affected(['B'])].sort()).toEqual(['B', 'C']);
});
});
describe('DependencyGraph.topoOrder', () => {
it('orders precedents before dependents', () => {
const g = build({ B: ['A'], C: ['A', 'B'], D: ['C'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect(circular.size).toBe(0);
const pos = (n: string) => order.indexOf(n);
expect(pos('A')).toBeLessThan(pos('B'));
expect(pos('B')).toBeLessThan(pos('C'));
expect(pos('C')).toBeLessThan(pos('D'));
});
it('detects a direct self-reference', () => {
const g = build({ A: ['A'] });
const { circular } = g.topoOrder(g.affected(['A']));
expect([...circular]).toEqual(['A']);
});
it('detects a 2-cycle and flags both nodes', () => {
const g = build({ A: ['B'], B: ['A'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect([...circular].sort()).toEqual(['A', 'B']);
expect(order).toEqual([]);
});
it('detects a longer cycle, leaves acyclic tail evaluable', () => {
// cycle A->B->C->A ; plus D reads C (D is not on the cycle)
const g = build({ A: ['C'], B: ['A'], C: ['B'], D: ['C'] });
const { order, circular } = g.topoOrder(g.affected(['A']));
expect([...circular].sort()).toEqual(['A', 'B', 'C']);
expect(order).toEqual(['D']);
});
it('rewiring precedents removes stale edges', () => {
const g = build({ B: ['A'] });
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B']);
g.setPrecedents('B', []); // B no longer reads A
expect([...g.affected(['A'])].sort()).toEqual(['A']);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import { tokenize } from '../lexer';
const types = (s: string) => tokenize(s).map((t) => t.type);
const values = (s: string) => tokenize(s).map((t) => t.value);
describe('lexer', () => {
it('tokenizes numbers incl. decimals and exponents', () => {
expect(values('1 2.5 .5 1e3 1.2E-3')).toEqual([
'1',
'2.5',
'.5',
'1e3',
'1.2E-3',
'',
]);
expect(types('42')).toEqual(['NUMBER', 'EOF']);
});
it('tokenizes operators and punctuation', () => {
expect(types('+-*/^&():;!')).toEqual([
'PLUS',
'MINUS',
'STAR',
'SLASH',
'CARET',
'AMP',
'LPAREN',
'RPAREN',
'COLON',
'SEMICOLON',
'BANG',
'EOF',
]);
});
it('reads bare words as IDENT (cell refs, functions, sheet names alike)', () => {
expect(types('SUM(A1:B2)')).toEqual([
'IDENT',
'LPAREN',
'IDENT',
'COLON',
'IDENT',
'RPAREN',
'EOF',
]);
});
it('reads single-quoted sheet names with spaces', () => {
const t = tokenize("'Kosten Q3'!B2");
expect(t[0]).toMatchObject({ type: 'QUOTED', value: 'Kosten Q3' });
expect(t[1].type).toBe('BANG');
expect(t[2]).toMatchObject({ type: 'IDENT', value: 'B2' });
});
it('handles escaped double-quoted strings', () => {
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
});
it('reads $-locked cell references as one IDENT', () => {
expect(types('$A$1 A$1 $A1')).toEqual(['IDENT', 'IDENT', 'IDENT', 'EOF']);
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']);
});
it('reads identifiers with non-ASCII letters (umlauts etc.) as a single IDENT', () => {
// a table/sheet name routinely contains "ä"/"ö"/"ü"/"ß" in this
// German-first app -- these must lex as ordinary IDENT characters, not
// throw, so an unquoted qualifier like "Übersicht::A1" actually works.
expect(types('Übersicht::B4')).toEqual(['IDENT', 'DBLCOLON', 'IDENT', 'EOF']);
expect(values('Übersicht')).toEqual(['Übersicht', '']);
expect(values('Größe_Prämie')).toEqual(['Größe_Prämie', '']);
});
it('skips whitespace', () => {
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
});
it('throws on an unexpected character', () => {
expect(() => tokenize('A1 @ B2')).toThrow();
});
it('tokenizes assignment/equality and comparison operators', () => {
expect(types('= < > <= >= <>')).toEqual([
'EQUALS',
'LT',
'GT',
'LTE',
'GTE',
'NEQ',
'EOF',
]);
});
it('prefers the two-character comparison operators over their single-character prefix', () => {
expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
});
it('tokenizes every error code as one ERRORLIT token', () => {
expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']);
expect(values('#REF')).toEqual(['#REF', '']);
expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']);
expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']);
});
it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => {
expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']);
// "#REF" matches greedily -- a trailing "!" some might expect (Excel
// spells it "#REF!") is its own BANG token, not part of the literal;
// this engine's own error code is "#REF" without "!" (see types.ts).
expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']);
});
it('throws on an unrecognized "#..." sequence', () => {
expect(() => tokenize('#FOO')).toThrow();
});
});
@@ -0,0 +1,456 @@
import { describe, it, expect } from 'vitest';
import { parseMacroBlock } from '../parser';
import { runMacroActions } from '../evaluator';
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
import {
FormulaError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
/** Same shape as evaluator.spec.ts's makeHost, but resolves any of a small
* fixed set of table names ("Umsatz"/"Kosten" -- matching this file's test
* formulas, chosen to read naturally rather than the generic "S1"/"S2")
* case-insensitively to themselves, since a macro test cares about which
* name got resolved, not an opaque id. */
function makeHost(
data: Record<string, Record<string, CellValue>>,
home = 'Umsatz',
namedRanges: Record<string, NamedRangeShape> = {},
): EngineHost {
const known = ['Umsatz', 'Kosten'];
const idFor = (name: string): string | null => known.find((t) => t.toLowerCase() === name.trim().toLowerCase()) ?? null;
return {
homeTableId: home,
resolveTableName: idFor,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
tableSize: () => ({ cols: 10, rows: 2000 }),
getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1);
const v = data[tableId]?.[cell];
return v === undefined ? null : v;
},
};
}
type RecordedCall = { op: string; args: unknown[] };
/** Records every call instead of mutating anything -- these tests are about
* the interpreter dispatching the right action with the right resolved
* arguments, not about a real document (that's store/document.spec.ts's
* job, against the real store primitives). */
function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
const calls: RecordedCall[] = [];
const record =
(op: string) =>
(...args: unknown[]): void => {
calls.push({ op, args });
};
const host: MacroWriteHost = {
setValue: record('setValue'),
insertRow: record('insertRow'),
deleteRow: record('deleteRow'),
insertCol: record('insertCol'),
deleteCol: record('deleteCol'),
addTable: record('addTable'),
renameTable: record('renameTable'),
deleteTable: record('deleteTable'),
addSheet: record('addSheet'),
renameSheet: record('renameSheet'),
deleteSheet: record('deleteSheet'),
};
return { host, calls };
}
/** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a
* value to resolve with (wrapped as `{ok:true, value}`) or an Error to
* reject with (`{ok:false, error}`); an un-mapped request rejects with a
* loud "no mock for this request" error so a test that forgets to mock a
* call fails clearly instead of hanging. */
function makeNetHost(
responses: Record<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } {
const calls: { method: string; url: string; body?: string }[] = [];
const host: MacroNetworkHost = {
async request(method, url, body) {
calls.push({ method, url, body });
const key = `${method} ${url}`;
const mocked = responses[key];
if (!mocked) throw new Error(`no mock for ${key}`);
if (!mocked.ok) throw mocked.error;
return mocked.value;
},
};
return { host, calls };
}
const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) };
const runMacro = (
src: string,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost = emptyNetHost,
) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost);
describe('runMacroActions -- SETVALUE target resolution', () => {
it('a literal cell target writes exactly one cell', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
});
it('a range target broadcasts the same value to every cell in one call', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
expect(calls).toHaveLength(1);
const [cells, text] = calls[0].args as [CellRef[], string];
expect(cells).toEqual([
{ tableId: 'Umsatz', col: 0, row: 0 },
{ tableId: 'Umsatz', col: 0, row: 1 },
{ tableId: 'Umsatz', col: 0, row: 2 },
]);
expect(text).toBe('x');
});
it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
expect(calls).toEqual([
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
]);
});
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
});
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
});
it('rejects a computed (non-reference) target', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
});
it('rejects an unknown table name', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/);
});
});
describe('runMacroActions -- RETURN / fall-through', () => {
it('RETURN short-circuits and its value is returned', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig');
});
it('a RETURN inside an IF stops the remaining statements from running', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
expect(calls).toHaveLength(0);
});
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.toBeNull();
});
});
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro(
[
'INSERTROW("Umsatz"; 3)',
'DELETEROW("Umsatz"; 4)',
'INSERTCOL("Umsatz"; 1)',
'DELETECOL("Umsatz"; 2)',
'ADDTABLE("Blatt1"; "Neu")',
'RENAMETABLE("Umsatz"; "Verkauf")',
'DELETETABLE("Alt")',
'ADDSHEET("Blatt2")',
'RENAMESHEET("Blatt1"; "Analyse")',
'DELETESHEET("Blatt3")',
].join(' '),
makeHost({}),
writeHost,
);
expect(calls).toEqual([
{ op: 'insertRow', args: ['Umsatz', 3] },
{ op: 'deleteRow', args: ['Umsatz', 4] },
{ op: 'insertCol', args: ['Umsatz', 1] },
{ op: 'deleteCol', args: ['Umsatz', 2] },
{ op: 'addTable', args: ['Blatt1', 'Neu'] },
{ op: 'renameTable', args: ['Umsatz', 'Verkauf'] },
{ op: 'deleteTable', args: ['Alt'] },
{ op: 'addSheet', args: ['Blatt2'] },
{ op: 'renameSheet', args: ['Blatt1', 'Analyse'] },
{ op: 'deleteSheet', args: ['Blatt3'] },
]);
});
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', async () => {
const { host: writeHost, calls } = makeWriteHost();
await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
});
it('an unknown action name throws', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
});
it('a wrong argument count throws', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.toThrow(/erwartet 1 Argument/);
});
});
describe('runMacroActions -- macro-specific step budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', async () => {
const { host: writeHost } = makeWriteHost();
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
// (10,000) an ordinary block-kind function loop gets.
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
it('sequential GET calls in a loop also count toward the budget', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } });
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
});
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', async () => {
const { host: writeHost, calls } = makeWriteHost();
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
];
const index = buildFunctionIndex(defs);
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
});
it('an ActionNode can never appear inside a nested custom-function body -- parseFunctionBlock (always allowWrites:false) rejects write syntax even if a macro author tried to sneak one in', () => {
expect(() => {
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'BAD', params: [], body: 'SETVALUE(A1;1) RETURN 1', kind: 'block' },
];
const index = buildFunctionIndex(defs);
expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; BAD())'), index);
}).toThrow();
});
});
describe('runMacroActions -- error propagation', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
const { host: writeHost } = makeWriteHost();
await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow(
new FormulaError('#DIV/0!').code,
);
});
});
describe('runMacroActions -- GET/POST', () => {
it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'GET https://api/kunde': { ok: true, value: { name: 'Nord' } },
});
await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost);
expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('POST sends a body and binds the parsed JSON response', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'POST https://api/kunden': { ok: true, value: { id: 42 } },
});
await runMacro(
'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))',
makeHost({}),
writeHost,
netHost,
);
expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]);
});
it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => {
const { host: writeHost } = makeWriteHost();
// nested inside an expression -- SETVALUE's error wrapping only
// surfaces the FormulaError *code*, not its detail text, so #NAME?
// (not the literal words "unknown function") is what's actually visible
await expect(
runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost),
).rejects.toThrow(/NAME/);
// inside an IF condition -- an error in a condition resolves the whole
// macro run with that FormulaError as its "value" (same as any other
// ifstmt-condition error, existing behaviour predating this feature),
// it does not reject the promise
const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost);
expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET'));
});
it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } });
await runMacro(
'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('a network failure aborts the run cleanly with the failure message', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') },
});
await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/);
});
it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") },
});
await expect(
runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost),
).rejects.toThrow(/abgelehnt/);
expect(calls).toHaveLength(0);
});
});
describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => {
const cases: Array<[unknown, CellValue]> = [
[null, null],
['Nord', 'Nord'],
[42, 42],
[true, 1],
[false, 0],
[{ a: 1 }, '{"a":1}'],
[[1, 2], '[1,2]'],
];
for (const [value, expected] of cases) {
it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(String(expected ?? ''));
});
}
});
describe('runMacroActions -- JSONGET path resolution', () => {
const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } };
it('resolves a nested object path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('2');
});
it('resolves an array index within the path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('Nord');
});
it('a nested array-of-strings path resolves too', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('b');
});
it('a missing path resolves to JSON null, not a throw', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell
});
it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost),
).rejects.toThrow(/JSON-Ergebnis/);
});
it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost),
).rejects.toThrow(/VALUE/);
});
});
describe('runMacroActions -- FOR over a JSON array', () => {
it('iterates each element, usable via JSONGET', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] },
});
await runMacro(
'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
it('FOR over a JSON object (not an array) throws a clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('FOR over a JSON primitive throws the same clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } },
});
await runMacro(
'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
});
@@ -0,0 +1,491 @@
import { describe, it, expect } from 'vitest';
import {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type Statement,
type BlockNode,
} from '../parser';
/** Compact s-expression rendering of an AST for precedence assertions. */
function sexpr(n: Node): string {
switch (n.kind) {
case 'number':
return String(n.value);
case 'string':
return JSON.stringify(n.value);
case 'errorliteral':
return n.code;
case 'unary':
return `(${n.op} ${sexpr(n.operand)})`;
case 'binary':
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
case 'func':
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
case 'cell':
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
case 'range':
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param':
return `param(${n.name})`;
case 'namedref':
return `namedref(${n.name})`;
case 'blockcall':
// never appears in freshly-parsed input (see parser.ts's comment on
// BlockCallNode) -- exhaustiveness only, no test exercises this.
return `blockcall(${n.label})`;
case 'omitted':
return 'omitted';
case 'json':
// never produced by the parser (see parser.ts's comment on
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
return `json(${JSON.stringify(n.value)})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
function sexprStmt(s: Statement): string {
switch (s.kind) {
case 'assign':
return `${s.name}=${sexpr(s.value)}`;
case 'return':
return `RETURN ${sexpr(s.value)}`;
case 'for':
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
case 'ifstmt':
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
case 'action':
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
}
}
function sexprBlock(b: BlockNode): string {
return `{${b.statements.map(sexprStmt).join('; ')}}`;
}
const parseBlock = (s: string, paramNames: readonly string[]) =>
sexprBlock(parseFunctionBlock(s, paramNames));
describe('parser precedence & associativity', () => {
it('* / bind tighter than + -', () => {
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
});
it('+ - are left-associative', () => {
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
});
it('^ binds tighter than * / and is right-associative', () => {
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
});
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
});
it('parentheses override precedence', () => {
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
expect(parse('=(B2+C2)*2-D2/4')).toBe(
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
);
});
it('& binds looser than + - but tighter than a comparison', () => {
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
});
it('& is left-associative', () => {
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
});
});
describe('parser references', () => {
it('parses local cells and ranges', () => {
expect(parse('=A1')).toBe('cell(0,0)');
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
});
it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
expect(parse('=$A$1')).toBe('cell(0,0)');
expect(parse('=A$1')).toBe('cell(0,0)');
expect(parse('=$A1')).toBe('cell(0,0)');
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
});
it('parses table-qualified refs, bare and quoted', () => {
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
});
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
});
it('parses function calls with ; separator and mixed args', () => {
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
);
});
it('collectRefs finds every reference', () => {
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
expect(refs).toHaveLength(3);
});
it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => {
expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe(
'(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))',
);
expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)');
expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)');
expect(parse('=SUM()')).toBe('(SUM )');
});
});
describe('parser whole-column / whole-row references', () => {
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
it('B:B, A:C and $B:$B are whole columns', () => {
expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } });
expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } });
expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } });
expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } });
});
it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => {
expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } });
expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } });
expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } });
});
it('take table and sheet qualifiers', () => {
expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' });
expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' });
expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' });
});
it('work as function arguments and are collected as refs', () => {
const ast = parseFormula('SUM(B:B; 2:3) + 1');
expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']);
});
it('reject mixed or invalid shapes', () => {
expect(() => parseFormula('A:1')).toThrow();
expect(() => parseFormula('0:0')).toThrow();
// a plain number still is one
expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 });
});
});
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
it('parses the explicit 3-part form, bare and quoted', () => {
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
});
it('is unaffected in a range end / function call', () => {
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
});
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
// exactly as parseFormula() behaved before the sheet qualifier existed.
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
});
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
});
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
});
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
});
});
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
it('a name in paramNames parses as a param reference, case-insensitively', () => {
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
});
it('a bare name not in paramNames still behaves exactly as without the list', () => {
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
});
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
// same string, no param list -- "revenue" doesn't look like a cell, but
// this is now an ordinary top-level formula, where that's a named-range
// candidate (see the "named ranges" describe block below) rather than a
// ParseError -- unlike inside a function body (the case just above),
// where the fallback is deliberately off.
expect(parse('=revenue')).toBe('namedref(revenue)');
});
it('a param can mix with real cell references in the same formula', () => {
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
});
it('a param is never qualified or part of a range -- those still need a real cell', () => {
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
});
});
describe('parser errors', () => {
it('throws ParseError on a dangling operator', () => {
expect(() => parseFormula('=A1+')).toThrow(ParseError);
});
it('throws on an unbalanced paren', () => {
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
});
});
describe('parser named-range references (NamedRefNode)', () => {
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
});
it('mixes with real cell/table references and functions', () => {
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
);
});
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
});
it('never a range-start: "Name:B2" is not supported', () => {
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
});
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
});
});
describe('parser error literals (ErrorLiteralNode)', () => {
it('every error code parses as a leaf', () => {
expect(parse('=#REF')).toBe('#REF');
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
expect(parse('=#NAME?')).toBe('#NAME?');
expect(parse('=#ERR')).toBe('#ERR');
expect(parse('=#CIRC')).toBe('#CIRC');
expect(parse('=#VALUE!')).toBe('#VALUE!');
expect(parse('=#N/A')).toBe('#N/A');
});
it('composes with operators/functions like any other literal', () => {
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
});
});
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
it('parses all six comparison operators', () => {
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
});
it('binds looser than + -', () => {
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
});
it('is not chainable -- "a < b < c" is a parse error', () => {
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
});
});
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
expect(parseBlock(src, ['bereich'])).toBe(
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
);
});
it('a FOR body with multiple statements', () => {
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
);
});
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
const src =
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
);
});
it('IF ... THEN ... END, no ELSE', () => {
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
});
it('IF ... THEN ... ELSE ... END', () => {
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
expect(parseBlock(src, ['x'])).toBe(
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
);
});
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
const src =
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
expect(parseBlock(src, ['bereich'])).toBe(
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
'total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a FOR loop nested inside another FOR loop\'s body', () => {
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
expect(parseBlock(src, ['r1', 'r2'])).toBe(
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
);
});
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
});
it('a plain assignment can reference an already-declared variable', () => {
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
});
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
);
});
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
expect(parseBlock('x = 1', [])).toBe('{x=1}');
});
it('throws on FOR without a matching END', () => {
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on FOR without a matching DO', () => {
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
});
it('throws on FOR without IN', () => {
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
ParseError,
);
});
it('throws on IF without THEN', () => {
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
});
it('throws on IF without a matching END', () => {
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
});
it('throws when an assignment is missing "="', () => {
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
});
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
});
});
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
it('a bare NAME(args) at statement position parses as an action call', () => {
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
);
});
it('an action with no arguments', () => {
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
});
it('";"-separated arguments, including an omitted middle slot', () => {
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
});
it('an action call composes with local variables and & concatenation', () => {
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
);
});
it('an action call can appear inside FOR/IF bodies', () => {
expect(
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
});
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
});
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
// at statement start falls through to parseAssignment(), which then
// fails immediately for lack of "=".
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
});
it('rejects an action call missing its closing ")"', () => {
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
});
});
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
it('collects a SETVALUE action\'s reference arguments too', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
const refs = collectRefsInBlock(block);
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
});
it('collects refs from every action argument, not just the first', () => {
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
const refs = collectRefsInBlock(block);
// INSERTROW's args are plain strings/numbers -- no refs from that
// statement; SETVALUE contributes both its target and its value's ref.
expect(refs).toHaveLength(2);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => {
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
});
it('toA1 round-trips the lock markers', () => {
expect(toA1(0, 0)).toBe('A1');
expect(toA1(0, 0, true, true)).toBe('$A$1');
expect(toA1(0, 0, false, true)).toBe('A$1');
expect(toA1(0, 0, true, false)).toBe('$A1');
});
});
describe('cycleReferenceLocks (F4) -- collapsed caret', () => {
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
let f = '=A1';
let pos = 3;
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
const res = cycleReferenceLocks(f, pos, pos)!;
expect(res.text).toBe(expected);
expect(res.start).toBe(res.end); // a collapsed caret in -> a collapsed caret out
f = res.text;
pos = res.end;
}
});
it('toggles only the reference the caret sits in/against', () => {
const res = cycleReferenceLocks('=A1+B2', 2, 2)!; // caret inside "A1"
expect(res.text).toBe('=$A$1+B2');
const res2 = cycleReferenceLocks('=A1+B2', 6, 6)!; // caret inside "B2"
expect(res2.text).toBe('=A1+$B$2');
});
it('picks the right side of a range depending on the caret', () => {
const left = cycleReferenceLocks('=A1:B2', 2, 2)!;
expect(left.text).toBe('=$A$1:B2');
const right = cycleReferenceLocks('=A1:B2', 6, 6)!;
expect(right.text).toBe('=A1:$B$2');
});
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 5, 5)).toBeNull();
expect(cycleReferenceLocks('42', 1, 1)).toBeNull();
});
it('never toggles a table-name qualifier, even one shaped like a cell', () => {
// "AB12" reads as a cell reference on its own, but here it's a table name
const formula = '=AB12!A1';
expect(cycleReferenceLocks(formula, 2, 2)).toBeNull(); // caret inside "AB12"
expect(cycleReferenceLocks(formula, 7, 7)?.text).toBe('=AB12!$A$1'); // caret inside the actual cell
});
});
describe('cycleReferenceLocks (F4) -- a real text selection spanning several refs', () => {
it('toggles every reference the selection overlaps, each from its own state', () => {
// select "A1+$B2" inside "=A1+$B2+C3" -- C3 sits outside the selection.
// A1 (relative) -> $A$1; $B2 (already col-locked-only, the cycle's last
// stop before relative) -> B2, its next step back to fully relative.
const formula = '=A1+$B2+C3';
const start = formula.indexOf('A1');
const end = formula.indexOf('$B2') + '$B2'.length;
const res = cycleReferenceLocks(formula, start, end)!;
expect(res.text).toBe('=$A$1+B2+C3');
});
it('a selection covering the whole formula adjusts all of it in one press', () => {
const res = cycleReferenceLocks('=A1+B2', 1, 7)!;
expect(res.text).toBe('=$A$1+$B$2');
});
it('re-cycling the same (now resized) selection keeps advancing every reference', () => {
let f = '=A1+B2';
let sel = cycleReferenceLocks(f, 1, 7)!; // -> $A$1+$B$2
f = sel.text;
sel = cycleReferenceLocks(f, sel.start, sel.end)!; // -> A$1+B$2
expect(sel.text).toBe('=A$1+B$2');
});
it('a selection touching no reference returns null', () => {
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
});
});
describe('currentIdentifierAtCaret -- function-name autocomplete detection', () => {
it('right after "=", right after an operator, right after "(" or ";"', () => {
expect(currentIdentifierAtCaret('=S', 2)).toEqual({ text: 'S', start: 1 });
expect(currentIdentifierAtCaret('=A1+S', 5)).toEqual({ text: 'S', start: 4 });
expect(currentIdentifierAtCaret('=SUM(A', 6)).toEqual({ text: 'A', start: 5 });
expect(currentIdentifierAtCaret('=SUM(1;A', 8)).toEqual({ text: 'A', start: 7 });
});
it('null outside a formula (no leading "=")', () => {
expect(currentIdentifierAtCaret('SUM', 3)).toBeNull();
});
it('null once the word looks like a cell reference (has a digit)', () => {
expect(currentIdentifierAtCaret('=SUM(A1', 7)).toBeNull();
expect(currentIdentifierAtCaret('=A1', 3)).toBeNull();
});
it('null right after an already-open, complete function call', () => {
expect(currentIdentifierAtCaret('=SUM(', 5)).toBeNull();
});
it('null once the caret has moved past the word (trailing whitespace)', () => {
expect(currentIdentifierAtCaret('=SUM ', 5)).toBeNull();
});
it('only looks at text up to the caret, ignoring what follows it', () => {
// caret between "SUM" and "X" in "SUMX" -- same result as if "X" weren't there
expect(currentIdentifierAtCaret('=SUMX', 4)).toEqual({ text: 'SUM', start: 1 });
});
it('null on a malformed prefix (unterminated string) rather than throwing', () => {
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
});
});
@@ -0,0 +1,506 @@
import { describe, it, expect } from 'vitest';
import { parseFormula, parseMacroBlock } from '../parser';
import {
buildFunctionIndex,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
expandMacroBlock,
expandUserFunctions,
type FunctionDef,
} from '../userFunctions';
import { Workbook, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
// same small A1 helper used by workbook.spec.ts
function sheet(
id: string,
name: string,
cells: Record<string, string>,
sheetName = 'Blatt1',
): TableModel {
const inputs = new Map<string, string>();
for (const [a1, raw] of Object.entries(cells)) {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
const row = Number(m[2]) - 1;
inputs.set(`${col},${row}`, raw);
}
return { id, name, sheetName, cols: 8, rows: 30, inputs };
}
const val = (wb: Workbook, sheetId: string, a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
};
const def = (
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
): FunctionDef => ({ kind: 'expression', ...partial });
describe('expandUserFunctions (AST-level)', () => {
it('substitutes parameters with the caller\'s argument subtrees', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const ast = parseFormula('=MARGE(A1; 5)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '-',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 5 },
});
});
it('an unknown name (built-in or not) is left untouched for evaluate() to turn into #NAME?', () => {
const index = buildFunctionIndex([]);
const ast = parseFormula('=FOO(1;2)');
expect(expandUserFunctions(ast, index)).toEqual(ast);
});
it('a user function may call a built-in, and its arguments are still expanded', () => {
const index = buildFunctionIndex([
def({ id: 'f1', name: 'TOTAL', params: ['a', 'b'], body: 'SUM(a;b;10)' }),
]);
const ast = parseFormula('=TOTAL(A1;A2)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'func',
name: 'SUM',
args: [
{ kind: 'cell', qualifier: null, col: 0, row: 0 },
{ kind: 'cell', qualifier: null, col: 0, row: 1 },
{ kind: 'number', value: 10 },
],
});
});
it('a user function may call another user function, params expanded inside-out', () => {
const index = buildFunctionIndex([
def({ id: 'inner', name: 'DOUBLE', params: ['x'], body: 'x * 2' }),
def({ id: 'outer', name: 'QUAD', params: ['x'], body: 'DOUBLE(DOUBLE(x))' }),
]);
const ast = parseFormula('=QUAD(A1)');
const expanded = expandUserFunctions(ast, index);
// DOUBLE(DOUBLE(A1)) -> (A1 * 2) * 2, fully inlined, no func nodes left
expect(expanded).toEqual({
kind: 'binary',
op: '*',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 2 },
},
right: { kind: 'number', value: 2 },
});
});
it('nested functions with colliding parameter names resolve to the correct scope', () => {
// OUTER(x) = INNER(x) + 1, INNER(x) = x * 100 -- both call their param "x"
const index = buildFunctionIndex([
def({ id: 'inner', name: 'INNER', params: ['x'], body: 'x * 100' }),
def({ id: 'outer', name: 'OUTER', params: ['x'], body: 'INNER(x) + 1' }),
]);
const ast = parseFormula('=OUTER(A1)');
const expanded = expandUserFunctions(ast, index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: {
kind: 'binary',
op: '*',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 100 },
},
right: { kind: 'number', value: 1 },
});
});
it('throws on arity mismatch', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
expect(() => expandUserFunctions(parseFormula('=MARGE(A1)'), index)).toThrow();
});
it('throws once expansion depth exceeds the recursion guard (defence in depth for a cycle)', () => {
// A calls B, B calls A -- should never be saved (store-level cycle check),
// but the engine must still degrade to an error, not a stack overflow.
const index = buildFunctionIndex([
def({ id: 'a', name: 'A', params: ['x'], body: 'B(x)' }),
def({ id: 'b', name: 'B', params: ['x'], body: 'A(x)' }),
]);
expect(() => expandUserFunctions(parseFormula('=A(1)'), index)).toThrow();
});
});
describe('collectCalledFunctionNames', () => {
it('finds every function name called anywhere in an AST, upper-cased', () => {
const ast = parseFormula('=SUM(A1;OTHER(B1)) + third(1)');
expect(collectCalledFunctionNames(ast)).toEqual(new Set(['SUM', 'OTHER', 'THIRD']));
});
});
describe('expandMacroBlock / collectCalledFunctionNamesInBlock: the new "action" statement arm', () => {
it('expandMacroBlock resolves a custom-function call nested inside an action argument', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))');
const expanded = expandMacroBlock(block, index);
const stmt = expanded.statements[0];
expect(stmt.kind).toBe('action');
if (stmt.kind !== 'action') throw new Error('unreachable');
// MARGE(10;3) was inlined (kind:'expression') into its (10 - 3) body --
// no more 'func' node calling MARGE survives in the expanded tree.
expect(collectCalledFunctionNames(stmt.args[1])).toEqual(new Set());
});
it('expandMacroBlock is a no-op fast path when no custom functions are defined', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; 1)');
expect(expandMacroBlock(block, buildFunctionIndex([]))).toBe(block); // same reference, not just equal
});
it('collectCalledFunctionNamesInBlock finds a function called inside an action\'s arguments', () => {
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3)) INSERTROW("Umsatz"; COUNT(Umsatz::A1:A3))');
expect(collectCalledFunctionNamesInBlock(block)).toEqual(new Set(['MARGE', 'COUNT']));
});
});
describe('Workbook integration: user-defined functions end-to-end', () => {
function fixture(functionDefs: FunctionDef[] = []): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '800',
B9: '=MARGE(B2;C2)',
});
return new Workbook([umsatz], functionDefs);
}
it('a call is evaluated using the function body', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((1200 - 800) / 1200, 10);
});
it('recalculates through the function body when a precedent changes', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
wb.setCell('s-umsatz', 1, 1, '2000'); // B2: 1200 -> 2000
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((2000 - 800) / 2000, 10);
});
it('a body referencing a cell that is *not* in the call arguments still creates a dependency edge', () => {
const wb = fixture();
wb.tables[0].inputs.set('4,0', '3'); // E1 = 3, a "rate" cell not passed as an argument
wb.tables[0].inputs.set('1,8', '=WITHRATE(B2)'); // B9 = WITHRATE(B2)
const functionDefs = [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })];
const wb2 = new Workbook(wb.tables, functionDefs);
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 3);
// changing E1 (never passed as an argument) must still propagate to B9
wb2.setCell('s-umsatz', 4, 0, '5');
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 5);
});
it('unknown function name (never defined) yields #NAME?, exactly like an unknown built-in', () => {
const wb = fixture(); // no functionDefs at all -- MARGE stays unresolved
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
});
it('wrong argument count yields #ERR', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost', 'extra'], body: 'revenue' })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('a built-in name can never be shadowed -- SUM keeps behaving like SUM', () => {
// even if a document somehow ended up with a "SUM" custom function
// (the store rejects this at save time), the engine still prefers the
// built-in -- buildFunctionIndex()/expandUserFunctions() never override it
// because functions.ts's own registry is consulted at evaluation time and
// wins whenever expandUserFunctions leaves a func node in place... so make
// sure the custom "SUM" is simply never invoked from a real formula that
// hits the built-in path first is out of scope here; this test instead
// documents that a *user* SUM does get inlined when explicitly defined,
// since the store -- not the engine -- is what forbids the collision.
const index = buildFunctionIndex([def({ id: 'x', name: 'SUM', params: ['a'], body: 'a + 1' })]);
const expanded = expandUserFunctions(parseFormula('=SUM(A1)'), index);
expect(expanded).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
right: { kind: 'number', value: 1 },
});
});
it('errors inside a function body propagate to dependents', () => {
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: 'revenue / 0' })]);
expect(isError(val(wb, 's-umsatz', 'B9'))).toBe(true);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#DIV/0!');
});
it('a parameter can be a range, as long as it lands in a function-call argument position', () => {
// SPANNE(bereich) = MAX(bereich) - MIN(bereich) -- "bereich" (ParamNode)
// gets substituted with the caller's RangeNode, splicing a normal
// MAX(Umsatz!B2:E2) / MIN(Umsatz!B2:E2) call into the expanded AST.
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '1450', D2: '1600', E2: '1720',
B9: '=SPANNE(B2:E2)',
});
const wb = new Workbook(
[umsatz],
[def({ id: 'f1', name: 'SPANNE', params: ['bereich'], body: 'MAX(bereich) - MIN(bereich)' })],
);
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1200);
// recalculates when a cell *inside* the range argument changes
wb.setCell('s-umsatz', 1, 1, '1000'); // B2: 1200 -> 1000
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1000);
});
it('a range parameter used outside a function-call argument position is #VALUE!, same as a bare range in an ordinary formula', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', C2: '1450',
B9: '=BAD(B2:C2)',
});
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'BAD', params: ['bereich'], body: 'bereich + 1' })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#VALUE!');
});
it('debugOrder places a function-body-only precedent before its dependent', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
B2: '1200', E1: '3',
B9: '=WITHRATE(B2)',
});
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })]);
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 4, 0)]); // dirty: E1
expect(circular).toEqual([]);
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
});
});
const productBlock = {
params: ['bereich'],
kind: 'block' as const,
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
};
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
expect(expanded.kind).toBe('blockcall');
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
expect(expanded.label).toBe('PRODUCT2');
expect(expanded.paramNames).toEqual(['bereich']);
expect(expanded.args).toEqual([
{
kind: 'range',
qualifier: null,
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
},
]);
});
it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
const index = buildFunctionIndex([
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
def({
id: 's1',
name: 'SUMDOUBLED',
params: ['bereich'],
kind: 'block',
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
}),
]);
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
const forStmt = expanded.block.statements[1];
if (forStmt.kind !== 'for') throw new Error('expected a for statement');
const assignInBody = forStmt.body.statements[0];
if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
// DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
// "DOUBLE" remains anywhere in the block
expect(assignInBody.value).toEqual({
kind: 'binary',
op: '+',
left: { kind: 'param', name: 'total' },
right: {
kind: 'binary',
op: '*',
left: { kind: 'param', name: 'x' },
right: { kind: 'number', value: 2 },
},
});
});
it('arity mismatch is still checked for block-kind functions', () => {
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
});
});
describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined functions', () => {
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
});
it('recalculates when a cell inside the passed range changes', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
});
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
const wb = new Workbook(
[umsatz],
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
);
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
});
it('a FOR body with multiple statements runs all of them, every iteration', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
const doubleSum = def({
id: 'f1',
name: 'DOUBLESUM',
params: ['bereich'],
kind: 'block',
body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
});
const wb = new Workbook([umsatz], [doubleSum]);
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
});
it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', {
A1: '2',
A2: '3',
B1: '10',
B2: '20',
B9: '=OUTERPROD(A1:A2; B1:B2)',
});
const outerProd = def({
id: 'f1',
name: 'OUTERPROD',
params: ['r1', 'r2'],
kind: 'block',
body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
});
const wb = new Workbook([umsatz], [outerProd]);
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
});
it('IF/ELSE branches on the real, current cell value', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
const sign2 = def({
id: 'f1',
name: 'SIGN2',
params: ['x'],
kind: 'block',
body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
});
const wb = new Workbook([umsatz], [sign2]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '-5');
expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
});
it('comparison operators work inside an IF condition', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
const eq2 = def({
id: 'f1',
name: 'EQ2',
params: ['a', 'b'],
kind: 'block',
body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [eq2]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('AND/OR compose a compound condition inside an IF, same as any other function call', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' });
const inRange = def({
id: 'f1',
name: 'INRANGE',
params: ['x'],
kind: 'block',
body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END',
});
const wb = new Workbook([umsatz], [inRange]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
});
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
// A3 is the first cell > 100; A4 must never affect the result
const umsatz = sheet('s-umsatz', 'Umsatz', {
A1: '1',
A2: '2',
A3: '999',
A4: '4',
B9: '=FIRSTOVER(A1:A4; 100)',
});
const firstOver = def({
id: 'f1',
name: 'FIRSTOVER',
params: ['bereich', 'schwelle'],
kind: 'block',
body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
});
const wb = new Workbook([umsatz], [firstOver]);
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
// changing the never-reached A4 doesn't change the (already-found) result
wb.setCell('s-umsatz', 0, 3, '99999');
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
});
it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
const branchy = def({
id: 'f1',
name: 'BRANCHY',
params: ['x'],
kind: 'block',
body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
});
const wb = new Workbook([umsatz], [branchy]);
expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
expect(val(wb, 's-umsatz', 'B9')).toBe(2);
});
it('exceeding the loop-step cap degrades to #ERR at the cell, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
const wb = new Workbook(
[umsatz],
[
def({
id: 'f1',
name: 'BAD',
params: ['x'],
kind: 'block',
body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
}),
],
);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
const wb = new Workbook([umsatz], [noReturn]);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
});
});
@@ -0,0 +1,447 @@
import { describe, it, expect } from 'vitest';
import { Workbook, literalValue, type TableModel } from '../workbook';
import { FormulaError, isError, keyOf } from '../types';
import { dateToSerial, timeSerial, todaySerial, nowSerial } from '../dates';
function sheet(
id: string,
name: string,
cells: Record<string, string>,
sheetName = 'Blatt1',
): TableModel {
const inputs = new Map<string, string>();
for (const [a1, raw] of Object.entries(cells)) {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
const row = Number(m[2]) - 1;
inputs.set(`${col},${row}`, raw);
}
return { id, name, sheetName, cols: 8, rows: 30, inputs };
}
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
function fixture(): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', {
A2: 'Nord', B2: '1200', C2: '1450', D2: '1600', E2: '1720',
A3: 'Süd', B3: '980', C3: '1100', D3: '1250', E3: '1310',
A4: 'Ost', B4: '1550', C4: '1480', D4: '1620', E4: '1700',
A5: 'West', B5: '1340', C5: '1390', D5: '1500', E5: '1580',
A6: 'Zentral', B6: '1105', C6: '1205', D6: '1298', E6: '1402',
B8: '=SUM(B2:B6)', B9: '=AVG(B2:B6)', B10: '=MIN(B2:B6)',
B11: '=MAX(B2:B6)', B12: '=COUNT(B2:B6)',
H2: '=SUM(B2:E2)', H3: '=SUM(B2:E2)/4', H4: '=E2-B2',
});
const kosten = sheet('s-kosten', 'Kosten', {
A2: 'Nord', B2: '800', C2: '820', D2: '900', E2: '880',
});
const uebersicht = sheet('s-ueber', 'Übersicht', {
B2: '=SUM(Umsatz!B2:E2)', C2: '=SUM(Kosten!B2:E2)',
D2: '=B2-C2', E2: '=D2/B2*100',
B8: '=SUM(B2:B6)', D8: '=SUM(D2:D6)',
});
return new Workbook([umsatz, kosten, uebersicht]);
}
const val = (wb: Workbook, sheetId: string, a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
};
describe('Workbook aggregates', () => {
const wb = fixture();
it('SUM/AVG/MIN/MAX/COUNT of Umsatz!B2:B6', () => {
expect(val(wb, 's-umsatz', 'B8')).toBe(6175);
expect(val(wb, 's-umsatz', 'B9')).toBe(1235);
expect(val(wb, 's-umsatz', 'B10')).toBe(980);
expect(val(wb, 's-umsatz', 'B11')).toBe(1550);
expect(val(wb, 's-umsatz', 'B12')).toBe(5);
});
it('row helpers', () => {
expect(val(wb, 's-umsatz', 'H2')).toBe(5970); // 1200+1450+1600+1720
expect(val(wb, 's-umsatz', 'H3')).toBe(1492.5);
expect(val(wb, 's-umsatz', 'H4')).toBe(520);
});
});
describe('Workbook cross-sheet', () => {
it('resolves Name!Range across sheets', () => {
const wb = fixture();
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
expect(val(wb, 's-ueber', 'C2')).toBe(3400);
expect(val(wb, 's-ueber', 'D2')).toBe(2570);
expect(val(wb, 's-ueber', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
});
it('editing Umsatz!B2 propagates to Übersicht B2/D2/E2 and the Gesamt row', () => {
const wb = fixture();
wb.setCell('s-umsatz', 1, 1, '2200'); // B2: 1200 -> 2200 (+1000)
expect(val(wb, 's-umsatz', 'B8')).toBe(7175);
expect(val(wb, 's-ueber', 'B2')).toBe(6970);
expect(val(wb, 's-ueber', 'D2')).toBe(3570);
expect(val(wb, 's-ueber', 'B8')).toBe(6970); // only row 2 populated in fixture
expect(val(wb, 's-ueber', 'D8')).toBe(3570);
});
it('is tolerant about sheet-name case and quoting', () => {
const wb = fixture();
wb.setCell('s-kosten', 0, 8, "='Kosten'!B2"); // A9 = 'Kosten'!B2
wb.setCell('s-kosten', 1, 8, '=kosten!b2'); // B9 lower-case
expect(val(wb, 's-kosten', 'A9')).toBe(800);
expect(val(wb, 's-kosten', 'B9')).toBe(800);
});
it('a table name with non-ASCII letters works as a bare, unquoted qualifier', () => {
// regression: "Übersicht" is the fixture's own table name, but nothing
// above ever refers *into* it by name (it only ever refers out) -- so
// this table has silently been unreferenceable from elsewhere the whole
// time (the lexer rejected "Ü" outright; see engine/lexer.ts).
const wb = fixture();
wb.setCell('s-umsatz', 6, 8, '=Übersicht!B2'); // G9, no quoting needed (no spaces)
expect(val(wb, 's-umsatz', 'G9')).toBe(5970);
expect(isError(val(wb, 's-umsatz', 'G9'))).toBe(false);
});
});
describe('Workbook error values', () => {
const wb = fixture();
it('classifies each error kind', () => {
wb.setCell('s-umsatz', 6, 20, '=1/0');
wb.setCell('s-umsatz', 6, 21, '=FOO(1)');
wb.setCell('s-umsatz', 6, 22, '=A1+');
wb.setCell('s-umsatz', 6, 23, '=Nix!A1');
expect((val(wb, 's-umsatz', 'G21') as FormulaError).code).toBe('#DIV/0!');
expect((val(wb, 's-umsatz', 'G22') as FormulaError).code).toBe('#NAME?');
expect((val(wb, 's-umsatz', 'G23') as FormulaError).code).toBe('#ERR');
expect((val(wb, 's-umsatz', 'G24') as FormulaError).code).toBe('#REF');
});
it('detects a direct circular reference', () => {
const wb2 = fixture();
wb2.setCell('s-umsatz', 0, 0, '=A1');
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
});
it('detects a cross-sheet circular reference', () => {
const wb2 = fixture();
wb2.setCell('s-umsatz', 0, 0, "='Kosten'!Z9");
wb2.setCell('s-kosten', 25, 8, '=Umsatz!A1');
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
expect((val(wb2, 's-kosten', 'Z9') as FormulaError).code).toBe('#CIRC');
});
it('errors propagate to dependents', () => {
const wb2 = fixture();
wb2.setCell('s-umsatz', 1, 1, '=1/0');
expect(isError(val(wb2, 's-umsatz', 'B8'))).toBe(true);
expect(isError(val(wb2, 's-ueber', 'B2'))).toBe(true);
});
});
describe('Workbook structural changes', () => {
it('deleting a referenced sheet turns dependents into #REF', () => {
const wb = fixture();
wb.tables = wb.tables.filter((t) => t.id !== 's-kosten');
wb.rebuild();
expect((val(wb, 's-ueber', 'C2') as FormulaError).code).toBe('#REF');
// unrelated cells still fine
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
});
});
describe('Workbook named ranges', () => {
function namedRangeFixture(): Workbook {
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' });
return new Workbook(
[umsatz],
[],
[{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
);
}
it('resolves a single-cell named range like an ordinary cell reference', () => {
const wb = namedRangeFixture();
expect(val(wb, 's-umsatz', 'B9')).toBe(101);
});
it('is a dependency -- recalculates when the referenced cell changes', () => {
const wb = namedRangeFixture();
wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500
expect(val(wb, 's-umsatz', 'B9')).toBe(501);
});
it('a multi-cell named range works as a range function argument', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' });
const wb = new Workbook(
[umsatz],
[],
[{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }],
);
expect(val(wb, 's-umsatz', 'B9')).toBe(300);
});
it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => {
const wb = namedRangeFixture();
wb.tables[0].name = 'Verkauf'; // rename the table Preis points at
wb.rebuild();
expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF
});
it('an unknown name is #NAME?', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' });
const wb = new Workbook([umsatz], [], []);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
});
it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => {
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' });
const wb = new Workbook(
[umsatz],
[],
[{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
);
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
});
});
describe('Workbook graph queries (graphPrecedents/graphDependents)', () => {
const coords = (a1: string) => {
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
return { col: m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1, row: Number(m[2]) - 1 };
};
it('graphPrecedents lists exactly the cells a formula reads, same-table', () => {
const wb = fixture();
const { col, row } = coords('D2'); // Übersicht!D2 = B2-C2
const precs = wb.graphPrecedents('s-ueber', col, row);
expect(precs).toHaveLength(2);
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('B2') });
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('C2') });
});
it('graphPrecedents follows a cross-table range reference to every cell in it', () => {
const wb = fixture();
const { col, row } = coords('B2'); // Übersicht!B2 = SUM(Umsatz!B2:E2)
const precs = wb.graphPrecedents('s-ueber', col, row);
expect(precs).toHaveLength(4);
for (const a1 of ['B2', 'C2', 'D2', 'E2']) {
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords(a1) });
}
});
it('graphDependents is the exact reverse of graphPrecedents', () => {
const wb = fixture();
const { col, row } = coords('B2'); // Übersicht!B2 -- read by D2 and (via D2) E2 directly reads B2 too
const deps = wb.graphDependents('s-ueber', col, row);
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('D2') });
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('E2') }); // E2 = D2/B2*100, reads B2 directly
});
it('a cell with no formula (or a literal) has no precedents', () => {
const wb = fixture();
const { col, row } = coords('B2'); // Umsatz!B2 is a plain literal, "1200"
expect(wb.graphPrecedents('s-umsatz', col, row)).toEqual([]);
});
it('a cell nothing reads has no dependents', () => {
const wb = fixture();
const { col, row } = coords('A2'); // Umsatz!A2 = "Nord", never referenced by a formula
expect(wb.graphDependents('s-umsatz', col, row)).toEqual([]);
});
it('stays accurate for cells on a circular reference (edges exist even though topoOrder drops them)', () => {
const a = sheet('s1', 'S1', { A1: '=B1', B1: '=A1' });
const wb = new Workbook([a]);
const a1 = coords('A1');
expect(wb.graphPrecedents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
expect(wb.graphDependents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
});
it('updates live after a cell is re-edited to reference something else', () => {
const wb = fixture();
const c1 = coords('C1');
wb.setCell('s-umsatz', c1.col, c1.row, '=B2');
expect(wb.graphPrecedents('s-umsatz', c1.col, c1.row)).toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
wb.setCell('s-umsatz', c1.col, c1.row, '=B3');
const precs = wb.graphPrecedents('s-umsatz', c1.col, c1.row);
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords('B3') });
expect(precs).not.toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
});
});
describe('Workbook recalculation order', () => {
it('evaluates precedents before dependents (chain across sheets)', () => {
const wb = fixture();
// chain: Umsatz!B2 -> Übersicht!B2 -> Übersicht!D2 -> Übersicht!E2
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 1, 1)]);
expect(circular).toEqual([]);
const pos = (k: string) => order.indexOf(k);
expect(pos(keyOf('s-ueber', 1, 1))).toBeGreaterThan(-1);
expect(pos(keyOf('s-ueber', 1, 1))).toBeLessThan(pos(keyOf('s-ueber', 3, 1)));
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
});
});
describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names unique per sheet only', () => {
/** Two sheets ("Analyse", "Zweitblatt") each hold their own table called
* "Umsatz" -- the same table NAME on two different sheets, only possible
* now that table-name uniqueness is per-sheet rather than document-wide. */
function multiSheetFixture(): Workbook {
const analyseUmsatz = sheet('s-a-umsatz', 'Umsatz', { B2: '100' }, 'Analyse');
const analyseOther = sheet(
's-a-other',
'Auswertung',
{
B2: '=Umsatz!B2', // ambiguous name, home sheet is Analyse -> Analyse's own Umsatz
B3: '=Analyse!Umsatz::B2', // explicit sheet qualifier
B4: '=Zweitblatt!Umsatz::B2', // explicit sheet qualifier, the OTHER sheet's Umsatz
},
'Analyse',
);
const zweitUmsatz = sheet('s-b-umsatz', 'Umsatz', { B2: '999' }, 'Zweitblatt');
const zweitOther = sheet(
's-b-other',
'Check',
{ B2: '=Umsatz!B2' }, // ambiguous name, home sheet is Zweitblatt -> Zweitblatt's own Umsatz
'Zweitblatt',
);
const drittOther = sheet(
's-c-other',
'Fremd',
{
B2: '=Umsatz!B2', // ambiguous, no same-sheet "Umsatz" on Dritt -> #REF
B3: '=Auswertung!B2', // "Auswertung" itself is NOT ambiguous (only on Analyse) -> resolves from anywhere
},
'Dritt',
);
return new Workbook([analyseUmsatz, analyseOther, zweitUmsatz, zweitOther, drittOther]);
}
it('an unqualified, otherwise-ambiguous name resolves to the table on the SAME sheet as the formula', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-a-other', 'B2')).toBe(100);
expect(val(wb, 's-b-other', 'B2')).toBe(999);
});
it('an explicit "Blatt!Tabelle::Zelle" always resolves to that sheet\'s table, regardless of home sheet', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-a-other', 'B3')).toBe(100);
expect(val(wb, 's-a-other', 'B4')).toBe(999);
});
it('an unqualified ambiguous name with no same-sheet match is #REF', () => {
const wb = multiSheetFixture();
expect((val(wb, 's-c-other', 'B2') as FormulaError).code).toBe('#REF');
});
it('a non-ambiguous (single-match) name still resolves without any sheet qualifier, from any sheet', () => {
const wb = multiSheetFixture();
expect(val(wb, 's-c-other', 'B3')).toBe(100); // Auswertung!B2, transitively Analyse's Umsatz!B2
});
it('recalculates correctly through a same-sheet-preferred resolution when the source cell changes', () => {
const wb = multiSheetFixture();
wb.setCell('s-a-umsatz', 1, 1, '250'); // Analyse's Umsatz B2: 100 -> 250
expect(val(wb, 's-a-other', 'B2')).toBe(250);
expect(val(wb, 's-a-other', 'B3')).toBe(250); // Auswertung!B2 chained through
expect(val(wb, 's-b-other', 'B2')).toBe(999); // Zweitblatt's own Umsatz, unaffected
});
it('an explicit sheet qualifier for an unknown sheet or table is #REF', () => {
const wb2 = new Workbook([
sheet('s-x', 'X', { B2: '=Nirgendwo!Umsatz::A1' }, 'Analyse'),
sheet('s-a-umsatz', 'Umsatz', { A1: '5' }, 'Analyse'),
]);
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
});
});
describe('literalValue: date/time literal recognition', () => {
it('turns a recognized date/time literal into its serial number', () => {
expect(literalValue('2026-09-02')).toBe(dateToSerial(2026, 9, 2));
expect(literalValue('02.09.2026')).toBe(dateToSerial(2026, 9, 2));
expect(literalValue('14:30')).toBe(timeSerial(14, 30, 0));
});
it('leaves ordinary numbers and text untouched', () => {
expect(literalValue('1234.5')).toBe(1234.5);
expect(literalValue('-3')).toBe(-3);
expect(literalValue('hello')).toBe('hello');
expect(literalValue('02/09/2026')).toBe('02/09/2026'); // slash dates: not recognized, stays text
});
it('empty input is null', () => {
expect(literalValue('')).toBeNull();
expect(literalValue(' ')).toBeNull();
});
});
describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => {
it('DATE/TIME build a serial usable in cell arithmetic', () => {
const wb = new Workbook([
sheet('s1', 'S1', {
A1: '=DATE(2026;9;2)',
A2: '=DATE(2026;9;1)',
A3: '=A1-A2', // date arithmetic: just numbers
B1: '=TIME(12;0;0)',
}),
]);
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2026, 9, 2));
expect(val(wb, 's1', 'A3')).toBe(1);
expect(val(wb, 's1', 'B1')).toBeCloseTo(0.5, 10);
});
it('DATE overflows like Excel, TIME wraps like Excel', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A1: '=DATE(2026;13;1)', B1: '=TIME(25;0;0)' }),
]);
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2027, 1, 1));
expect(val(wb, 's1', 'B1')).toBeCloseTo(timeSerial(25, 0, 0), 10);
});
it('DATE/TIME reject the wrong argument count or a non-numeric argument', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A1: '=DATE(2026;9)', A2: '=DATE(2026;9;"x")' }),
]);
expect(isError(val(wb, 's1', 'A1'))).toBe(true);
expect(isError(val(wb, 's1', 'A2'))).toBe(true);
});
it('TODAY/NOW take no arguments and return a plausible current serial', () => {
const wb = new Workbook([sheet('s1', 'S1', { A1: '=TODAY()', A2: '=NOW()', A3: '=TODAY(1)' })]);
expect(val(wb, 's1', 'A1')).toBe(todaySerial());
expect(val(wb, 's1', 'A2') as number).toBeCloseTo(nowSerial(), 2);
expect(isError(val(wb, 's1', 'A3'))).toBe(true);
});
});
describe('Workbook: XLOOKUP recalculation', () => {
it('rewires and recalculates when a cell inside either range changes', () => {
const wb = new Workbook([
sheet('s1', 'S1', {
A2: 'Apfel', B2: '1.5',
A3: 'Birne', B3: '2',
A4: 'Kirsche', B4: '5',
C1: '=XLOOKUP("Birne";A2:A4;B2:B4)',
}),
]);
expect(val(wb, 's1', 'C1')).toBe(2);
// a change to a cell only inside the RETURN range (not itself the match)
wb.setCell('s1', 1, 2, '20'); // B3
expect(val(wb, 's1', 'C1')).toBe(20);
// a change to a cell only inside the LOOKUP range moves the match
wb.setCell('s1', 0, 1, 'Birne'); // A2 -> now also "Birne", first match wins
expect(val(wb, 's1', 'C1')).toBe(1.5);
});
it('an unrelated cell change does not trigger recalculation', () => {
const wb = new Workbook([
sheet('s1', 'S1', { A2: 'Apfel', B2: '1.5', C1: '=XLOOKUP("Apfel";A2:A2;B2:B2)', D1: '1' }),
]);
expect(val(wb, 's1', 'C1')).toBe(1.5);
wb.setCell('s1', 3, 0, '999'); // D1, not referenced by C1 at all
expect(val(wb, 's1', 'C1')).toBe(1.5);
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* Dates & times as plain numbers -- "serial days" since a fixed epoch,
* exactly like every other spreadsheet (Excel, Google Sheets, Numbers): the
* integer part is the calendar day, the fractional part is the time of day
* (0 = midnight, 0.5 = noon). Epoch = 1899-12-30 (the Google Sheets / Excel
* convention, WITHOUT Excel's historical 1900-leap-year bug), so serial 1 =
* 1899-12-31, serial 2 = 1900-01-01. This is the whole reason date
* arithmetic (`=faelligkeit - rechnungsdatum`, `=rechnungsdatum + 30`) just
* works with the existing `+`/`-` operators -- no engine change needed
* there at all.
*
* All arithmetic goes through UTC fields (`Date.UTC` / `getUTC*`) so a
* serial round-trips exactly regardless of the browser's local timezone.
*/
import { FormulaError, isError, type CellValue } from './types';
const EPOCH_MS = Date.UTC(1899, 11, 30);
const MS_PER_DAY = 86_400_000;
/** `DATE()`'s building block -- plain `Date.UTC`, overflow allowed on
* purpose (`dateToSerial(2026, 13, 1)` = 2027-01-01), matching Excel's
* `DATE()`. Contrast with the stricter, overflow-rejecting builder used by
* {@link parseDateTimeLiteral} below for *recognizing typed input*. */
export function dateToSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number {
return (Date.UTC(y, mo - 1, d, h, mi, s) - EPOCH_MS) / MS_PER_DAY;
}
/** `TIME()`'s building block -- wraps like Excel's TIME (`TIME(25,0,0)` is
* the 1:00 fraction of the *next* day, i.e. `0.041666...`). */
export function timeSerial(h: number, mi: number, s: number): number {
const total = (((h * 3600 + mi * 60 + s) % 86400) + 86400) % 86400;
return total / 86400;
}
/** Serial -> UTC `Date`, for display formatting. */
export function serialToUtcDate(serial: number): Date {
return new Date(EPOCH_MS + Math.round(serial * MS_PER_DAY));
}
/** A serial's calendar and clock fields. The time is rounded to the whole
* second first, so floating-point noise (`TIME(14;30;0)` summed from parts)
* never turns 14:30:00 into 14:29:59. `day` is the integer serial of the
* calendar day; `weekday` follows ISO 8601 (Monday = 1 ... Sunday = 7). */
export interface SerialParts {
day: number;
year: number;
month: number;
date: number;
weekday: number;
hour: number;
minute: number;
second: number;
}
export function serialParts(serial: number): SerialParts {
const totalSec = Math.round(serial * 86400);
const day = Math.floor(totalSec / 86400);
const sec = totalSec - day * 86400;
const d = new Date(EPOCH_MS + day * MS_PER_DAY);
return {
day,
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
date: d.getUTCDate(),
weekday: ((d.getUTCDay() + 6) % 7) + 1,
hour: Math.floor(sec / 3600),
minute: Math.floor((sec % 3600) / 60),
second: sec % 60,
};
}
/** ISO 8601 week number (weeks start Monday; week 1 is the one containing
* the year's first Thursday) of a serial's calendar day. */
export function isoWeekNumber(serial: number): number {
const { day, weekday } = serialParts(serial);
const thursday = day - weekday + 4; // the Thursday of this ISO week decides its year
const { year } = serialParts(thursday);
const jan1 = Math.round(dateToSerial(year, 1, 1));
return Math.floor((thursday - jan1) / 7) + 1;
}
/** Last calendar day of the month `months` after the serial's month. */
export function endOfMonthSerial(serial: number, months: number): number {
const { year, month } = serialParts(serial);
return Math.round(dateToSerial(year, month + months + 1, 0));
}
/** Same day `months` later, clamped to that month's last day (31.01. + 1
* month = 28./29.02.), like Excel's EDATE. */
export function addMonthsSerial(serial: number, months: number): number {
const { year, month, date } = serialParts(serial);
const last = serialParts(endOfMonthSerial(serial, months)).date;
return Math.round(dateToSerial(year, month + months, Math.min(date, last)));
}
/** Today's *local* calendar date (a date is a calendar concept, not a
* timezone-relative instant), as an integer serial (no time-of-day part). */
export function todaySerial(): number {
const n = new Date();
return Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()));
}
/** Local date + time, as a serial with a fractional part.
*
* Known limitation (documented in both READMEs): this engine recalculates
* incrementally, only when a precedent cell changes -- there's no timer or
* background work. A cell holding `=TODAY()`/`=NOW()` is NOT "volatile"
* the way Excel's is: it refreshes the next time *that cell itself* is
* re-edited or recalculated, not spontaneously when the clock ticks over. */
export function nowSerial(): number {
const n = new Date();
return dateToSerial(
n.getFullYear(),
n.getMonth() + 1,
n.getDate(),
n.getHours(),
n.getMinutes(),
n.getSeconds() + n.getMilliseconds() / 1000,
);
}
/** `'duration'` is a time span typed with 24 or more hours (`37:30`) --
* shown as elapsed hours instead of wrapping to a time of day. */
export type DateLiteralKind = 'date' | 'time' | 'datetime' | 'duration';
const ISO_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const DOT_DATETIME_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
const DOT_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
const DURATION_RE = /^(\d{1,6}):(\d{2})(?::(\d{2}))?$/;
/** 2-digit year -> 4-digit, the usual spreadsheet convention: 00-29 -> 20xx,
* 30-99 -> 19xx. */
function expandYear(yStr: string): number {
if (yStr.length === 4) return Number(yStr);
const yy = Number(yStr);
return yy <= 29 ? 2000 + yy : 1900 + yy;
}
/** Strict date builder for *recognizing typed input* -- unlike `dateToSerial`
* (used by the `DATE()` formula function), an out-of-range or rolled-over
* component (`31.04.2026`, `30.02.2026`, hour `25`) means "this text isn't
* actually a date" rather than "roll over to the next month": leaving it as
* plain text beats silently guessing. Builds the candidate via `Date.UTC`,
* then verifies it by reading the fields back. */
function strictDateSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number | null {
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return null;
const ms = Date.UTC(y, mo - 1, d, h, mi, s);
const check = new Date(ms);
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) {
return null;
}
return (ms - EPOCH_MS) / MS_PER_DAY;
}
/**
* Recognize a raw cell literal as a date, time, or date+time -- used both to
* turn typed input into its serial number ({@link workbook.ts}'s
* `literalValue`) and to decide whether to auto-apply a matching display
* format on entry (`store/document.ts`'s `setCellInput`).
*
* Deliberately locale-independent and conservative: only ISO dates
* (`2026-09-02`) and dot-separated day.month.year dates (`02.09.2026`,
* `02.09.26`) are recognized, plus `HH:MM[:SS]` time (24+ hours: a
* duration) and date + time combined
* with a space or `T`. Slash-separated dates (`02/09/2026`) are
* intentionally NOT recognized: day-first vs. month-first is ambiguous
* across this app's supported locales (en-US is month-first, every other
* supported locale is day-first) -- guessing wrong would silently corrupt
* the entered date, which is worse than leaving it as plain text.
*/
export function parseDateTimeLiteral(
raw: string,
): { kind: DateLiteralKind; serial: number } | null {
const t = raw.trim();
let m = ISO_DATETIME_RE.exec(t);
if (m) {
const serial = strictDateSerial(+m[1], +m[2], +m[3], +m[4], +m[5], m[6] ? +m[6] : 0);
return serial === null ? null : { kind: 'datetime', serial };
}
m = ISO_DATE_RE.exec(t);
if (m) {
const serial = strictDateSerial(+m[1], +m[2], +m[3]);
return serial === null ? null : { kind: 'date', serial };
}
m = DOT_DATETIME_RE.exec(t);
if (m) {
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1], +m[4], +m[5], m[6] ? +m[6] : 0);
return serial === null ? null : { kind: 'datetime', serial };
}
m = DOT_DATE_RE.exec(t);
if (m) {
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1]);
return serial === null ? null : { kind: 'date', serial };
}
m = TIME_RE.exec(t);
if (m) {
const h = +m[1];
const mi = +m[2];
const s = m[3] ? +m[3] : 0;
if (mi > 59 || s > 59) return null;
if (h <= 23) return { kind: 'time', serial: timeSerial(h, mi, s) };
}
// 24 hours or more can't be a time of day -- it's an elapsed time
m = DURATION_RE.exec(t);
if (m) {
const h = +m[1];
const mi = +m[2];
const s = m[3] ? +m[3] : 0;
if (mi > 59 || s > 59) return null;
return { kind: 'duration', serial: (h * 3600 + mi * 60 + s) / 86400 };
}
return null;
}
/** Coerce a function argument to a number the way `evaluator.ts`'s
* `toNumber()` does for arithmetic (null -> 0, numeric string cast,
* anything else -> `#VALUE!`) -- duplicated here rather than imported
* because `functions.ts` (and this module, which it uses) must not import
* from `evaluator.ts` (evaluator.ts imports functions.ts, not the other
* way around). Used by `DATE`/`TIME`'s argument handling in functions.ts. */
export function strictNum(v: CellValue): number | FormulaError {
if (isError(v)) return v;
if (v === null) return 0;
if (typeof v === 'number') return v;
const trimmed = v.trim();
if (trimmed === '') return 0;
const n = Number(trimmed);
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
}
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
/**
* Built-in functions: a plain table { NAME -> fn(values) }.
*
* By the time a function runs, the evaluator has already:
* - resolved every range argument into a flat list of cell values,
* - flattened scalar arguments into that same list,
* - checked for errors among the values (an error short-circuits and is
* returned instead of calling the function).
*
* So a function only ever sees `(number | string | null)[]`. Each function
* decides for itself how to treat text and empty cells. Per spec:
* - empty cell -> ignored by every aggregate, never counted by COUNT
* - text -> ignored by the numeric aggregates
*/
import { FormulaError, isError, type CellValue } from './types';
import {
dateToSerial,
timeSerial,
todaySerial,
nowSerial,
strictNum,
serialParts,
isoWeekNumber,
endOfMonthSerial,
addMonthsSerial,
parseDateTimeLiteral,
type SerialParts,
} from './dates';
export type FunctionFn = (values: CellValue[]) => CellValue;
/** Keep only real numbers (finite). */
function nums(values: CellValue[]): number[] {
const out: number[] = [];
for (const v of values) {
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
}
return out;
}
/** The one truthy convention used everywhere in this engine a condition is
* needed (IF's condition -- both the formula function and its real
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
* exact same rule instead of duplicating it. */
export function isTruthy(v: CellValue): boolean {
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
}
/** Check the argument count and coerce every argument to a number (see
* `strictNum`) -- the shared prologue of the numeric date functions. An
* optional argument left out (or an empty `;;` slot) comes back as
* `undefined`. */
function numArgs(
name: string,
values: CellValue[],
min: number,
max: number,
): [number, ...(number | undefined)[]] | FormulaError {
if (values.length < min || values.length > max) {
const range = min === max ? `${min}` : `${min}–${max}`;
return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`);
}
const out: Array<number | undefined> = [];
for (let i = 0; i < values.length; i++) {
if (i >= min && values[i] === null) {
out.push(undefined);
continue;
}
const n = strictNum(values[i]);
if (isError(n)) return n;
out.push(n);
}
return out as [number, ...(number | undefined)[]];
}
/** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */
function part(name: string, pick: (p: SerialParts) => number): FunctionFn {
return (values) => {
const a = numArgs(name, values, 1, 1);
if (isError(a)) return a;
return pick(serialParts(a[0]));
};
}
function holidaySet(values: CellValue[]): Set<number> {
const out = new Set<number>();
for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day);
return out;
}
function isWorkday(day: number, holidays: Set<number>): boolean {
return serialParts(day).weekday <= 5 && !holidays.has(day);
}
/** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or
* a number (already a serial). */
function textDateArg(name: string, values: CellValue[]): number | FormulaError {
if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`);
const v = values[0];
if (typeof v === 'number') return v;
const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null;
if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`);
return hit.serial;
}
export const FUNCTIONS: Record<string, FunctionFn> = {
SUM(values) {
return nums(values).reduce((a, b) => a + b, 0);
},
AVG(values) {
const n = nums(values);
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
return n.reduce((a, b) => a + b, 0) / n.length;
},
MIN(values) {
const n = nums(values);
return n.length ? Math.min(...n) : 0;
},
MAX(values) {
const n = nums(values);
return n.length ? Math.max(...n) : 0;
},
COUNT(values) {
return nums(values).length;
},
COUNT2(values) {
return values.length;
},
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
// as the `&` operator (toText() in evaluator.ts): a number prints plain
// (no locale formatting), an empty cell contributes ''.
CONCAT(values) {
return values.map((v) => (v === null ? '' : String(v))).join('');
},
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
// division when B1=0). This entry only exists so IF is a registered
// built-in for hasFunction()'s sake (the store's custom-function name
// check then correctly refuses to let a user shadow it) and as a
// defensive, best-effort fallback if this were ever reached directly
// (values are already-eagerly-computed here, so it can't short-circuit --
// see evaluator.ts's evalIf() for the real implementation).
IF(values) {
if (values.length !== 2 && values.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const [cond, thenValue, elseValue] = values;
if (isError(cond)) return cond;
if (isTruthy(cond)) return thenValue;
return values.length === 3 ? elseValue : 0;
},
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
// argument is evaluated regardless of the others (so e.g.
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
// argument flattens into one truthy check per cell, same as Excel.
AND(values) {
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
return values.every(isTruthy) ? 1 : 0;
},
OR(values) {
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
return values.some(isTruthy) ? 1 : 0;
},
NOT(values) {
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
return isTruthy(values[0]) ? 0 : 1;
},
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
// same as everywhere else in this engine; a non-numeric text argument is
// `#VALUE!`.
DATE(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
}
const y = strictNum(values[0]);
if (isError(y)) return y;
const mo = strictNum(values[1]);
if (isError(mo)) return mo;
const d = strictNum(values[2]);
if (isError(d)) return d;
return dateToSerial(y, mo, d);
},
TIME(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
}
const h = strictNum(values[0]);
if (isError(h)) return h;
const mi = strictNum(values[1]);
if (isError(mi)) return mi;
const s = strictNum(values[2]);
if (isError(s)) return s;
return timeSerial(h, mi, s);
},
// Not "volatile": this engine only recalculates on precedent change, so a
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
// or recalculated, not spontaneously when the clock ticks over (documented
// limitation, see README.md's "Bekannte Grenzen").
TODAY(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
return todaySerial();
},
NOW(values) {
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
return nowSerial();
},
// ---- date/time parts & calendar arithmetic ----------------------------
// All take date serials (see dates.ts). Where Excel has a US-centric
// default, the ISO 8601 convention is the default here: weeks start on
// Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks.
YEAR: part('YEAR', (p) => p.year),
MONTH: part('MONTH', (p) => p.month),
DAY: part('DAY', (p) => p.date),
HOUR: part('HOUR', (p) => p.hour),
MINUTE: part('MINUTE', (p) => p.minute),
SECOND: part('SECOND', (p) => p.second),
// WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7,
// 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ...
// Sunday 6.
WEEKDAY(values) {
const a = numArgs('WEEKDAY', values, 1, 2);
if (isError(a)) return a;
const iso = serialParts(a[0]).weekday;
switch (a[1] ?? 2) {
case 1:
return (iso % 7) + 1;
case 2:
return iso;
case 3:
return iso - 1;
default:
return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein');
}
},
ISOWEEKNUM(values) {
const a = numArgs('ISOWEEKNUM', values, 1, 1);
if (isError(a)) return a;
return isoWeekNumber(a[0]);
},
// WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel);
// Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the
// week containing 1 January as week 1 (Excel's US-style numbering).
WEEKNUM(values) {
const a = numArgs('WEEKNUM', values, 1, 2);
if (isError(a)) return a;
const type = a[1] ?? 21;
if (type === 21) return isoWeekNumber(a[0]);
if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein');
const p = serialParts(a[0]);
const jan1 = Math.round(dateToSerial(p.year, 1, 1));
const jan1Iso = serialParts(jan1).weekday; // 1 = Monday
const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start
return Math.floor((p.day - jan1 + offset) / 7) + 1;
},
EDATE(values) {
const a = numArgs('EDATE', values, 2, 2);
if (isError(a)) return a;
return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0));
},
EOMONTH(values) {
const a = numArgs('EOMONTH', values, 2, 2);
if (isError(a)) return a;
return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0));
},
// DAYS(Ende; Start) -- whole calendar days between two dates.
DAYS(values) {
const a = numArgs('DAYS', values, 2, 2);
if (isError(a)) return a;
return serialParts(a[0]).day - serialParts(a[1] ?? 0).day;
},
// DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/
// days, "MD"/"YM"/"YD" the remainder ignoring months+years/years.
DATEDIF(values) {
if (values.length !== 3) {
return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : '';
const s = serialParts(s0);
const e = serialParts(e0);
if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende');
let months = (e.year - s.year) * 12 + (e.month - s.month);
if (e.date < s.date) months -= 1;
switch (unit) {
case 'D':
return e.day - s.day;
case 'M':
return months;
case 'Y':
return Math.floor(months / 12);
case 'YM':
return months % 12;
case 'MD':
return e.day - addMonthsSerial(s.day, months);
case 'YD': {
const years = Math.floor(months / 12);
return e.day - addMonthsSerial(s.day, years * 12);
}
default:
return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein');
}
},
// NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the
// span, both ends included, minus any listed holiday on a weekday.
NETWORKDAYS(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const e0 = strictNum(values[1]);
if (isError(e0)) return e0;
const holidays = holidaySet(values.slice(2));
const a = serialParts(s0).day;
const b = serialParts(e0).day;
const [lo, hi] = a <= b ? [a, b] : [b, a];
if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß');
let n = 0;
for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++;
return a <= b ? n : -n;
},
// WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days
// (Monday-Friday, not a holiday) after (or, negative, before) Start.
WORKDAY(values) {
if (values.length < 2) {
return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])');
}
const s0 = strictNum(values[0]);
if (isError(s0)) return s0;
const n0 = strictNum(values[1]);
if (isError(n0)) return n0;
let left = Math.trunc(Math.abs(n0));
if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage');
const step = n0 < 0 ? -1 : 1;
const holidays = holidaySet(values.slice(2));
let d = serialParts(s0).day;
while (left > 0) {
d += step;
if (isWorkday(d, holidays)) left--;
}
return d;
},
// DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part
// of a text in any format a cell entry is recognized in (ISO, 02.09.2026,
// 14:30, …). A number (a cell already holding a date) passes through.
DATEVALUE(values) {
const v = textDateArg('DATEVALUE', values);
if (isError(v)) return v;
return serialParts(v).day;
},
TIMEVALUE(values) {
const v = textDateArg('TIMEVALUE', values);
if (isError(v)) return v;
const p = serialParts(v);
return (p.hour * 3600 + p.minute * 60 + p.second) / 86400;
},
// XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one:
// 1. WennNichtGefunden must only be evaluated when actually needed
// (short-circuit, same reasoning as IF's branches).
// 2. By the time a function gets here, EVERY argument -- including
// Suchbereich and Rückgabebereich, both ranges -- has already been
// flattened into ONE combined array (see the module comment above).
// That destroys the boundary between the two ranges, so there is no
// way to even attempt a best-effort fallback here: unlike IF's
// 2-3 scalar arguments, XLOOKUP's array arguments can't be recovered
// from a flat `values` list of unknown split point. This entry exists
// purely so XLOOKUP is a registered built-in (hasFunction() /
// name-reservation) -- see evaluator.ts's evalXLookup() for the real
// implementation. Reaching this in practice would be an engine bug.
XLOOKUP() {
return new FormulaError('#ERR', 'XLOOKUP must be evaluated by evaluator.ts, not called directly');
},
};
// AVERAGE is an alias of AVG.
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
/** One-line signature hint per built-in, for the formula-autocomplete
* dropdown (see store/document.ts's `functionSuggestions()` and
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
* so a future new built-in without a matching entry here fails loudly
* instead of just silently missing from the dropdown. */
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
{ name: 'NOT', hint: 'NOT(Bedingung)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
{ name: 'TODAY', hint: 'TODAY()' },
{ name: 'NOW', hint: 'NOW()' },
{ name: 'YEAR', hint: 'YEAR(Datum)' },
{ name: 'MONTH', hint: 'MONTH(Datum)' },
{ name: 'DAY', hint: 'DAY(Datum)' },
{ name: 'HOUR', hint: 'HOUR(Zeit)' },
{ name: 'MINUTE', hint: 'MINUTE(Zeit)' },
{ name: 'SECOND', hint: 'SECOND(Zeit)' },
{ name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' },
{ name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' },
{ name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' },
{ name: 'EDATE', hint: 'EDATE(Datum; Monate)' },
{ name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' },
{ name: 'DAYS', hint: 'DAYS(Ende; Start)' },
{ name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' },
{ name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' },
{ name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' },
{ name: 'DATEVALUE', hint: 'DATEVALUE(Text)' },
{ name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' },
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
];
export function hasFunction(name: string): boolean {
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
}
export function getFunction(name: string): FunctionFn | undefined {
return FUNCTIONS[name.toUpperCase()];
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Dependency graph over *all* sheets.
*
* Nodes are cell keys (see `keyOf` in ./types). An edge P -> C means
* "cell C reads cell P" (P is a precedent of C, C is a dependent of P).
*
* The graph offers two things the recalculation driver needs:
* 1. `affected(dirty)` -- every cell that must be revisited after `dirty`
* changed (the dirty cells plus their transitive dependents).
* 2. `topoOrder(nodes)` -- a safe evaluation order for a set of nodes, plus
* the subset that sits on a dependency cycle (`#CIRC`).
*
* Circular references -- including cross-sheet ones -- are found by an iterative
* DFS with white/grey/black colouring. When a back-edge to a grey node is seen,
* every node currently on the DFS stack from that node onwards is flagged
* circular. Flagged nodes are dropped from the returned order; the driver writes
* `#CIRC` into them directly and their dependents pick that up through normal
* error propagation.
*/
export class DependencyGraph {
/** precedents: key -> set of cells it reads */
private readonly precedents = new Map<string, Set<string>>();
/** dependents: key -> set of cells that read it */
private readonly dependents = new Map<string, Set<string>>();
/** Replace the precedent set of `cell` (called after (re)parsing a formula). */
setPrecedents(cell: string, precedents: Iterable<string>): void {
// unlink old edges
const old = this.precedents.get(cell);
if (old) {
for (const p of old) this.dependents.get(p)?.delete(cell);
}
// Keep a self-reference if present: `=A1` inside A1 must surface as #CIRC.
const next = new Set(precedents);
this.precedents.set(cell, next);
for (const p of next) {
let d = this.dependents.get(p);
if (!d) this.dependents.set(p, (d = new Set()));
d.add(cell);
}
}
/** Forget a cell entirely (e.g. it became a literal or was cleared). */
clear(cell: string): void {
const old = this.precedents.get(cell);
if (old) for (const p of old) this.dependents.get(p)?.delete(cell);
this.precedents.delete(cell);
// keep `dependents.get(cell)` -- other cells may still point here (=> #REF/0),
// that entry is harmless and gets cleaned as those cells are re-parsed.
}
/** Drop every edge (used on structural rebuilds). */
reset(): void {
this.precedents.clear();
this.dependents.clear();
}
getPrecedents(cell: string): Set<string> {
return this.precedents.get(cell) ?? new Set();
}
getDependents(cell: string): Set<string> {
return this.dependents.get(cell) ?? new Set();
}
/** Dirty cells + all their transitive dependents. */
affected(dirty: Iterable<string>): Set<string> {
const seen = new Set<string>();
const stack = [...dirty];
while (stack.length) {
const k = stack.pop()!;
if (seen.has(k)) continue;
seen.add(k);
const deps = this.dependents.get(k);
if (deps) for (const d of deps) if (!seen.has(d)) stack.push(d);
}
return seen;
}
/**
* Topologically sort `nodes` using precedent edges that stay inside `nodes`.
* Returns the evaluation order (precedents first) and the set of nodes that
* lie on a cycle.
*/
topoOrder(nodes: Set<string>): { order: string[]; circular: Set<string> } {
const WHITE = 0;
const GREY = 1;
const BLACK = 2;
const color = new Map<string, number>();
const order: string[] = [];
const circular = new Set<string>();
for (const n of nodes) color.set(n, WHITE);
for (const root of nodes) {
if (color.get(root) !== WHITE) continue;
// iterative DFS; frame = { node, iterator over its in-scope precedents }
const stack: Array<{ node: string; iter: Iterator<string> }> = [];
const pathIndex = new Map<string, number>(); // node -> position on stack
const push = (node: string) => {
color.set(node, GREY);
pathIndex.set(node, stack.length);
const precs = [...this.getPrecedents(node)].filter((p) => nodes.has(p));
stack.push({ node, iter: precs[Symbol.iterator]() });
};
push(root);
while (stack.length) {
const frame = stack[stack.length - 1];
const step = frame.iter.next();
if (step.done) {
color.set(frame.node, BLACK);
pathIndex.delete(frame.node);
order.push(frame.node);
stack.pop();
continue;
}
const p = step.value;
const c = color.get(p);
if (c === WHITE) {
push(p);
} else if (c === GREY) {
// back-edge -> cycle. Flag everything from p to the current frame.
const from = pathIndex.get(p)!;
for (let k = from; k < stack.length; k++) circular.add(stack[k].node);
}
// BLACK -> already settled, ignore
}
}
// A node that depends on a circular node is not itself circular, but it must
// be evaluated *after* the driver writes #CIRC. Keeping it in `order` is
// fine; drop only the truly circular nodes.
return { order: order.filter((n) => !circular.has(n)), circular };
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Public surface of the formula engine.
*
* Nothing in `./` imports Vue, the DOM or any spreadsheet library. See
* ./README.md for the grammar (EBNF), the evaluation model and the error model.
*/
export { tokenize, LexError, type Token, type TokenType } from './lexer';
export {
parseFormula,
parseFunctionBlock,
parseMacroBlock,
collectRefs,
collectRefsInBlock,
ParseError,
type Node,
type CellNode,
type RangeNode,
type NamedRefNode,
type BlockNode,
type ActionNode,
} from './parser';
export { evaluate, runMacroActions } from './evaluator';
export { DependencyGraph } from './graph';
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
export {
colToNumber,
numberToCol,
parseA1,
toA1,
looksLikeCell,
expandRange,
rangeCells,
wholeLineAt,
parseLinePart,
formatLinePart,
WHOLE_LINE_END,
cycleReferenceLocks,
currentIdentifierAtCaret,
} from './refs';
export {
Workbook,
literalValue,
type TableModel,
} from './workbook';
export {
dateToSerial,
timeSerial,
serialToUtcDate,
todaySerial,
nowSerial,
parseDateTimeLiteral,
type DateLiteralKind,
} from './dates';
export {
buildFunctionIndex,
expandUserFunctions,
expandMacroBlock,
collectCalledFunctionNames,
collectCalledFunctionNamesInBlock,
declaredBlockNames,
findInvalidForSource,
findInvalidActionTarget,
blockAlwaysReturns,
type FunctionDef,
} from './userFunctions';
export {
FormulaError,
isError,
keyOf,
parseKey,
ERROR_CODES,
type ErrorCode,
type CellValue,
type CellRef,
type Scalar,
type EngineHost,
type MacroWriteHost,
type MacroNetworkHost,
type NamedRangeDef,
} from './types';
+226
View File
@@ -0,0 +1,226 @@
/**
* Tokenizer for the formula language.
*
* The lexer is deliberately dumb: it splits the input into a flat token stream
* and leaves *all* structural decisions to the parser. In particular it does not
* try to tell a cell reference ("A1") from a function name ("SUM") or a sheet
* name ("Umsatz") -- they are all `IDENT` tokens and the parser disambiguates
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
*/
import { ERROR_CODES } from './types';
export type TokenType =
| 'NUMBER'
| 'STRING'
| 'IDENT' // bare word: A1, SUM, Region, Q1 ...
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
| 'ERRORLIT' // a literal error code, e.g. "#REF" or "#DIV/0!" -- see ERROR_CODES
| 'PLUS'
| 'MINUS'
| 'STAR'
| 'SLASH'
| 'CARET'
| 'AMP' // "&" -- text concatenation
| 'LPAREN'
| 'RPAREN'
| 'SEMICOLON'
| 'COLON'
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
| 'BANG'
| 'EQUALS' // "=" -- assignment (block body) or equality comparison
| 'NEQ' // "<>"
| 'LT' // "<"
| 'GT' // ">"
| 'LTE' // "<="
| 'GTE' // ">="
| 'EOF';
export interface Token {
type: TokenType;
/** Raw text of the token (unquoted for STRING/QUOTED). */
value: string;
/** Character offset in the source, useful for error messages. */
pos: number;
}
export class LexError extends Error {}
const SINGLE: Record<string, TokenType> = {
'+': 'PLUS',
'-': 'MINUS',
'*': 'STAR',
'/': 'SLASH',
'^': 'CARET',
'&': 'AMP',
'(': 'LPAREN',
')': 'RPAREN',
';': 'SEMICOLON',
':': 'COLON',
'!': 'BANG',
'=': 'EQUALS',
'<': 'LT',
'>': 'GT',
};
function isDigit(ch: string): boolean {
return ch >= '0' && ch <= '9';
}
/** Any Unicode letter, not just ASCII -- a table/sheet name (used unquoted
* as a formula qualifier whenever it has no spaces) routinely contains
* "ä"/"ö"/"ü"/"ß" or other accented letters in this German-first app;
* rejecting those at the lexer level made every such name effectively
* unusable as a bare qualifier (`Übersicht::A1` used to throw outright). */
const IS_LETTER = /\p{L}/u;
function isWordStart(ch: string): boolean {
// '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but
// the lexer doesn't distinguish those from any other bare word (see the
// module doc) -- it's just part of the word here, and the parser is the one
// that validates/interprets it as a locked reference axis.
return IS_LETTER.test(ch) || ch === '_' || ch === '$';
}
function isWordPart(ch: string): boolean {
return isWordStart(ch) || isDigit(ch);
}
/** Longest-first, so a prefix of another code (none collide today, but this
* stays correct if one ever did) never wins the match by accident. */
const ERROR_CODES_BY_LENGTH = [...ERROR_CODES].sort((a, b) => b.length - a.length);
export function tokenize(src: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const n = src.length;
while (i < n) {
const ch = src[i];
// whitespace
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
i++;
continue;
}
// number: 12 12.5 .5 1e3 1.2E-3
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
const start = i;
while (i < n && isDigit(src[i])) i++;
if (src[i] === '.') {
i++;
while (i < n && isDigit(src[i])) i++;
}
if (src[i] === 'e' || src[i] === 'E') {
let j = i + 1;
if (src[j] === '+' || src[j] === '-') j++;
if (isDigit(src[j])) {
i = j;
while (i < n && isDigit(src[i])) i++;
}
}
tokens.push({ type: 'NUMBER', value: src.slice(start, i), pos: start });
continue;
}
// double-quoted string literal
if (ch === '"') {
const start = i;
i++;
let out = '';
while (i < n && src[i] !== '"') {
if (src[i] === '\\' && i + 1 < n) {
out += src[i + 1];
i += 2;
} else {
out += src[i++];
}
}
if (src[i] !== '"') throw new LexError(`Unterminated string at ${start}`);
i++; // closing quote
tokens.push({ type: 'STRING', value: out, pos: start });
continue;
}
// single-quoted sheet name: 'Kosten Q3' ('' -> literal quote)
if (ch === "'") {
const start = i;
i++;
let out = '';
while (i < n) {
if (src[i] === "'" && src[i + 1] === "'") {
out += "'";
i += 2;
} else if (src[i] === "'") {
break;
} else {
out += src[i++];
}
}
if (src[i] !== "'") throw new LexError(`Unterminated sheet name at ${start}`);
i++; // closing quote
tokens.push({ type: 'QUOTED', value: out, pos: start });
continue;
}
// bare word: identifiers, function names, cell refs
if (isWordStart(ch)) {
const start = i;
i++;
while (i < n && isWordPart(src[i])) i++;
tokens.push({ type: 'IDENT', value: src.slice(start, i), pos: start });
continue;
}
// "::" table qualifier -- must be checked before the single ":"
if (ch === ':' && src[i + 1] === ':') {
tokens.push({ type: 'DBLCOLON', value: '::', pos: i });
i += 2;
continue;
}
// two-character comparison operators -- must be checked before the
// single-character "<"/">" fallback below
if (ch === '<' && src[i + 1] === '=') {
tokens.push({ type: 'LTE', value: '<=', pos: i });
i += 2;
continue;
}
if (ch === '>' && src[i + 1] === '=') {
tokens.push({ type: 'GTE', value: '>=', pos: i });
i += 2;
continue;
}
if (ch === '<' && src[i + 1] === '>') {
tokens.push({ type: 'NEQ', value: '<>', pos: i });
i += 2;
continue;
}
// an error-code literal, e.g. "#REF" or "#DIV/0!" -- lets a formula
// literally contain/produce one, same as typing it in Excel; also how a
// structural row/col delete marks a now-broken reference in formula text
// (see store/document.ts's row/col delete). Recognized as one token
// regardless of what follows (a subsequent "!"/"?" is already consumed
// as part of the matched code, never re-tokenized as BANG etc.).
if (ch === '#') {
const code = ERROR_CODES_BY_LENGTH.find((c) => src.startsWith(c, i));
if (code) {
tokens.push({ type: 'ERRORLIT', value: code, pos: i });
i += code.length;
continue;
}
}
// single-character punctuation / operators
const t = SINGLE[ch];
if (t) {
tokens.push({ type: t, value: ch, pos: i });
i++;
continue;
}
throw new LexError(`Unexpected character '${ch}' at ${i}`);
}
tokens.push({ type: 'EOF', value: '', pos: n });
return tokens;
}
+880
View File
@@ -0,0 +1,880 @@
/**
* Recursive-descent parser: token stream -> AST.
*
* ---------------------------------------------------------------------------
* GRAMMAR (EBNF)
* ---------------------------------------------------------------------------
* formula = "=" expression ;
*
* expression = comparison ;
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
* (* at most one comparison,
* not chainable like
* "a < b < c"; mainly
* useful in a "block"
* function body's IF
* condition, but valid
* in any formula *)
* concat = additive { "&" additive } ; (* left assoc; text
* concatenation, binds
* looser than "+ -" but
* tighter than a
* comparison -- same
* slot Excel gives "&" *)
* additive = term { ("+" | "-") term } ; (* left assoc *)
* term = unary { ("*" | "/") unary } ; (* left assoc *)
* unary = ("+" | "-") unary
* | power ;
* power = primary [ "^" unary ] ; (* right assoc, and
* binds tighter than
* unary minus so that
* -2^2 = -(2^2) = -4 *)
* primary = NUMBER
* | STRING
* | ERRORLIT
* | "(" expression ")"
* | funcall
* | reference ;
*
* funcall = IDENT "(" [ arg { ";" arg } ] ")" ;
* arg = expression | (* empty *) ; (* an empty slot
* between two ";"
* (or "(...;)" right
* before ")") is an
* OmittedNode -- see
* its doc comment;
* "skip this optional
* argument", e.g.
* XLOOKUP(x;a;b;;-1) *)
*
* reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
* Numbers-style "::" are
* interchangeable *)
* sheetQualifier = (IDENT | QUOTED) "!" ; (* sheet name -- only
* recognised when it is
* immediately followed by
* a full "table::" --
* i.e. only in the 3-part
* form "Blatt!Tabelle::A1".
* A bare "X!Y" with no
* "::" after Y keeps its
* original meaning: X is
* the TABLE qualifier
* (backward-compatible --
* see reference() below). *)
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
* ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
* "#VALUE!" | "#N/A" ; (* a literal error code,
* typed directly or
* produced by a
* structural row/col
* delete *)
*
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
* ---------------------------------------------------------------------------
*
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
* when parseFormula() is called with a `paramNames` list (used to parse a
* user-defined function's body; see src/engine/userFunctions.ts). Without
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
* IDENT that doesn't look like a cell address is instead a *named-range*
* reference (`NamedRefNode`, see below): the parser has no opinion on
* whether the name actually exists (same as it never validates a table
* qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation
* time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell
* identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is
* always unqualified, so a qualifier always means "this must be a real
* cell".
* ---------------------------------------------------------------------------
*/
import { tokenize, type Token, type TokenType, LexError } from './lexer';
import { looksLikeCell, parseA1, wholeLineAt, WHOLE_LINE_END } from './refs';
import type { ErrorCode } from './types';
export type Node =
| NumberNode
| StringNode
| ErrorLiteralNode
| BinaryNode
| UnaryNode
| FuncNode
| CellNode
| RangeNode
| NamedRefNode
| ParamNode
| BlockCallNode
| OmittedNode
| JsonLiteralNode;
export interface NumberNode {
kind: 'number';
value: number;
}
export interface StringNode {
kind: 'string';
value: string;
}
/** A literal error code typed directly into a formula, e.g. `=#REF!+1` or
* `=IF(A1=0;#N/A;A1)` -- same as typing one in Excel. Evaluates directly to
* that `FormulaError`, unconditionally (it's not the *result* of anything,
* just a value like a number or string literal). Also how a structural
* row/col delete represents a now-broken reference in stored formula text
* (see store/document.ts). */
export interface ErrorLiteralNode {
kind: 'errorliteral';
code: ErrorCode;
}
export interface BinaryNode {
kind: 'binary';
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
left: Node;
right: Node;
}
export interface UnaryNode {
kind: 'unary';
op: '+' | '-';
operand: Node;
}
export interface FuncNode {
kind: 'func';
name: string;
args: Node[];
}
/** Single cell reference, optionally qualified with a table name. */
export interface CellNode {
kind: 'cell';
/** raw table name as written ("Umsatz"), or null for "this table" */
qualifier: string | null;
/** raw sheet name as written ("Analyse") -- only set for the explicit
* "Blatt!Tabelle::Zelle" 3-part form; absent for every plain
* "Tabelle!Zelle" / "Tabelle::Zelle" reference (resolved without regard
* to sheet, same as before this existed). */
sheetQualifier?: string;
col: number;
row: number;
}
/** Rectangular range reference, optionally qualified with a table name. */
export interface RangeNode {
kind: 'range';
qualifier: string | null;
/** see {@link CellNode.sheetQualifier}. */
sheetQualifier?: string;
start: { col: number; row: number };
end: { col: number; row: number };
/** A whole-column (`B:B`, `A:C`) or whole-row (`1:1`, `2:5`) range: the
* other axis runs from 0 to `WHOLE_LINE_END` (refs.ts), clamped to the
* table's size wherever cells are read (`rangeCells`). */
whole?: 'col' | 'row';
}
/** A bare, unqualified name that isn't a param and doesn't look like a cell
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
* instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷
* Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't
* know the document's named ranges (same as it never validates a table
* qualifier) -- resolved live, by name, at evaluation/dependency-collection
* time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */
export interface NamedRefNode {
kind: 'namedref';
name: string;
}
/** A reference to a user-defined function's own parameter -- only produced
* when parseFormula() is given a `paramNames` list (parsing a function
* body); never appears in an ordinary cell formula's AST. Resolved away by
* userFunctions.ts's expandUserFunctions() before evaluation ever sees it. */
export interface ParamNode {
kind: 'param';
name: string;
}
/** An empty argument slot between two `;` (or before the closing `)`), e.g.
* the middle `;;` in `XLOOKUP(x;a;b;;-1)`, skipping the optional
* `WennNichtGefunden` argument to reach `Vergleichsmodus` -- Excel's own
* "leave this optional argument out" idiom. Evaluates to `null` (empty
* cell), same as any other missing value in this engine. A function that
* needs to tell "explicitly omitted" apart from "evaluated to null" (e.g.
* XLOOKUP's optional trailing arguments) checks `arg.kind === 'omitted'`
* on the raw argument `Node` directly, before ever evaluating it. */
export interface OmittedNode {
kind: 'omitted';
}
/** A JSON value flowing through a macro's `env` (a `GET`/`POST` response, a
* `JSONGET` result, or one element while a `FOR` iterates a JSON array) --
* see evaluator.ts's `execStatementAsync`/`jsonGetPath`. Never produced by
* this parser (there is no JSON-literal syntax in the language) -- only
* the macro interpreter ever constructs one, wraps it in `literalNodeOf`-
* style env binding, and hands it back through `substitute()` like any
* other bound name. `evalScalar`'s `case 'json'` is where it collapses to
* an ordinary `CellValue` if used in plain scalar position without first
* drilling into it via `JSONGET`. */
export interface JsonLiteralNode {
kind: 'json';
value: unknown;
}
// ---------------------------------------------------------------------------
// "Block" function bodies (`kind: 'block'` custom functions) -- a small
// statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
// ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
// a block is parsed by parseFunctionBlock() (a distinct entry point from
// parseFormula()), and -- unlike Phase 1's single-expression bodies, which
// are fully inlined at parse time -- a block is only *partly* resolved at
// parse time (nested calls to other functions) and then *interpreted* at
// evaluation time by evaluator.ts, since a conditional/early RETURN depends
// on real cell values that aren't known until then. `BlockCallNode` (further
// below) is the one piece of this that *does* end up embedded in a normal
// cell AST and so *is* part of `Node`. See src/engine/README.md's
// "Schleifen" section for the full design.
// ---------------------------------------------------------------------------
/** `name = value` -- a local-variable assignment. */
export interface AssignNode {
kind: 'assign';
name: string;
value: Node;
}
/** `RETURN value` -- may appear anywhere in a block (top level or nested in
* a FOR/IF), not just at the end; ends the function's execution immediately
* with this value once reached. */
export interface ReturnNode {
kind: 'return';
value: Node;
}
/** `FOR varName IN source DO body END` -- source must resolve, once
* parameters are substituted with the caller's real arguments, to a single
* cell or a range (checked by userFunctions.ts at save time, enforced by
* the interpreter at call time); `body` runs once per cell, in order. */
export interface ForNode {
kind: 'for';
varName: string;
source: Node;
body: BlockNode;
}
/** `IF cond THEN then [ELSE else] END`. May appear in any block, including
* nested inside a FOR's body or another IF's branch. */
export interface IfNode {
kind: 'ifstmt';
cond: Node;
then: BlockNode;
else: BlockNode | null;
}
/** `NAME(arg; arg; ...)` at statement position -- a call into the macro
* write-action vocabulary (`SETVALUE`, `INSERTROW`, `ADDTABLE`, ...; see
* engine/types.ts's `MacroWriteHost` and evaluator.ts's `execStatement`).
* Only ever produced when the parser's `allowWrites` flag is on, which is
* only true for `parseMacroBlock()` -- so this can *never* appear in a
* `kind: 'block'` custom function's body (always parsed with
* `allowWrites: false`), meaning a custom function embedded inside a
* macro's expressions can compute a value but can never itself write. */
export interface ActionNode {
kind: 'action';
name: string;
args: Node[];
}
export type Statement = AssignNode | ForNode | IfNode | ReturnNode | ActionNode;
/** A sequence of statements -- a whole function body, or the body of a
* FOR/IF. Every name a statement introduces (assignment target or loop
* variable) stays visible for the *rest* of the execution, including after
* its own FOR/IF ends (with its last value) -- flat scope, no nested block
* scoping. */
export interface BlockNode {
kind: 'block';
statements: Statement[];
}
/** The one block-related node that *is* part of `Node`: embedded into a
* normal cell AST wherever a `kind: 'block'` custom function is called
* (userFunctions.ts#expand(), replacing what a `kind: 'expression'` call
* would have inlined as a plain expression). `block` has already had every
* *other* function call it contains resolved (built-ins untouched, other
* custom functions inlined/turned into their own BlockCallNode) -- only
* this function's *own* parameters, referenced via ParamNode inside
* `block`, remain to be bound, which evaluator.ts's interpreter does at
* evaluation time using `args`/`paramNames`. */
export interface BlockCallNode {
kind: 'blockcall';
block: BlockNode;
args: Node[];
paramNames: string[];
/** the function's own name, purely for a clearer "ended without RETURN"
* error message. */
label: string;
}
export class ParseError extends Error {}
class Parser {
private i = 0;
/** Case-insensitively matched (compared upper-cased), like function names
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
* parseBlock() below: as each statement introduces a new local/loop-var
* name, it's added here so *later* statements/the RETURN can reference it
* -- a plain expression body (parseFormula()'s normal path) never mutates
* this, so its behaviour is completely unaffected. */
private readonly paramNames: Set<string>;
/** Whether a bare, unqualified, non-cell, non-param identifier falls back
* to a `NamedRefNode` (an ordinary top-level formula) instead of a hard
* `ParseError` (parsing a function body) -- see reference()'s named-range
* branch and parseFormula()'s doc comment for why function bodies opt
* out of the fallback. */
private readonly namedRefFallback: boolean;
/** Whether `NAME(args)` at statement position parses as an `ActionNode`
* (a macro write-action call) instead of falling through to
* `parseAssignment()` (which then fails, since assignment requires "="
* right after the name). Only `parseMacroBlock()` turns this on -- see
* `ActionNode`'s doc comment for why that containment matters. */
private readonly allowWrites: boolean;
constructor(
private readonly toks: Token[],
paramNames: readonly string[] = [],
namedRefFallback = true,
allowWrites = false,
) {
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
this.namedRefFallback = namedRefFallback;
this.allowWrites = allowWrites;
}
private peek(): Token {
return this.toks[this.i];
}
private next(): Token {
return this.toks[this.i++];
}
private at(t: TokenType): boolean {
return this.toks[this.i].type === t;
}
private expect(t: TokenType): Token {
if (!this.at(t)) {
throw new ParseError(`Expected ${t} but got ${this.peek().type} at ${this.peek().pos}`);
}
return this.next();
}
parse(): Node {
const node = this.expression();
if (!this.at('EOF')) {
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
}
return node;
}
/** Entry point for a "block" function body: a plain statement sequence
* (`RETURN` is just one of the statement kinds now, usable anywhere --
* see parseFunctionBlock() below). */
parseBlock(): BlockNode {
const block = this.parseStatementBlock([]);
if (!this.at('EOF')) {
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
}
return block;
}
/** Parses statements until the current token is EOF or one of `stopWords`
* (a keyword that ends the enclosing construct -- "END"/"ELSE" -- left
* *unconsumed* for the caller to check/consume itself). */
private parseStatementBlock(stopWords: readonly string[]): BlockNode {
const statements: Statement[] = [];
while (!this.at('EOF') && !stopWords.some((w) => this.isKeyword(w))) {
statements.push(this.parseStatement());
}
return { kind: 'block', statements };
}
private parseStatement(): Statement {
if (this.isKeyword('FOR')) return this.parseForLoop();
if (this.isKeyword('IF')) return this.parseIfStmt();
if (this.isKeyword('RETURN')) return this.parseReturnStmt();
// "NAME(" at statement start, only inside a macro body -- unambiguous
// vs. assignment, which requires "=" (a different token type) right
// after the name.
if (this.allowWrites && this.at('IDENT') && this.toks[this.i + 1]?.type === 'LPAREN') {
return this.parseAction();
}
return this.parseAssignment();
}
private parseAction(): ActionNode {
const nameTok = this.next(); // IDENT, guaranteed by the caller's lookahead
this.next(); // consume "("
const args = this.parseArgList();
this.expect('RPAREN');
return { kind: 'action', name: nameTok.value, args };
}
/** `[ arg { ";" arg } ]` -- shared by a function-call expression
* (`primary()`) and a statement-position action call (`parseAction()`).
* An empty slot (leading/middle/trailing `;`, right up against `;` or
* the caller's closing `)`) parses as an `OmittedNode`, same as a
* function call's own argument list -- the caller consumes the closing
* `)` itself. */
private parseArgList(): Node[] {
const args: Node[] = [];
const argOrOmitted = (): Node =>
this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression();
if (!this.at('RPAREN')) {
args.push(argOrOmitted());
while (this.at('SEMICOLON')) {
this.next();
args.push(argOrOmitted());
}
}
return args;
}
private parseAssignment(): AssignNode {
if (!this.at('IDENT')) {
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
}
const nameTok = this.next();
if (!this.at('EQUALS')) {
throw new ParseError(`Expected '=' after '${nameTok.value}' at ${this.peek().pos}`);
}
this.next();
const value = this.expression();
this.paramNames.add(nameTok.value.toUpperCase()); // visible from here on (flat scope)
return { kind: 'assign', name: nameTok.value, value };
}
private parseReturnStmt(): ReturnNode {
this.next(); // consume RETURN
const value = this.expression();
return { kind: 'return', value };
}
private parseForLoop(): ForNode {
this.next(); // consume FOR
if (!this.at('IDENT')) {
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
}
const varTok = this.next();
this.expectKeyword('IN', `FOR ${varTok.value}`);
const source = this.expression();
this.expectKeyword('DO', 'the FOR source');
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
const body = this.parseStatementBlock(['END']);
this.expectKeyword('END', 'the FOR body');
return { kind: 'for', varName: varTok.value, source, body };
}
private parseIfStmt(): IfNode {
this.next(); // consume IF
const cond = this.expression();
this.expectKeyword('THEN', 'the IF condition');
const then = this.parseStatementBlock(['ELSE', 'END']);
let elseBlock: BlockNode | null = null;
if (this.isKeyword('ELSE')) {
this.next();
elseBlock = this.parseStatementBlock(['END']);
}
this.expectKeyword('END', 'the IF body');
return { kind: 'ifstmt', cond, then, else: elseBlock };
}
/** True iff the current token is an IDENT spelling exactly `word`
* (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/RETURN are
* recognised, the same way function names are matched elsewhere (no
* reserved-word lexer tokens). */
private isKeyword(word: string): boolean {
return this.at('IDENT') && this.peek().value.toUpperCase() === word;
}
/** Consumes `word` as a keyword, or throws a ParseError naming what came
* before it (for a clearer message than a generic "expected IDENT"). */
private expectKeyword(word: string, afterWhat: string): void {
if (!this.isKeyword(word)) {
throw new ParseError(`Expected ${word} after ${afterWhat} at ${this.peek().pos}`);
}
this.next();
}
/** Public entry point for every expression context (function args,
* parens, assignment RHS, ...) -- wraps `additive` with an optional
* single comparison, the loosest-binding operator. */
private expression(): Node {
const left = this.concat();
const opType = this.comparisonOpType();
if (!opType) return left;
this.next();
return { kind: 'binary', op: opType, left, right: this.concat() };
}
private comparisonOpType(): BinaryNode['op'] | null {
switch (this.peek().type) {
case 'EQUALS':
return '=';
case 'NEQ':
return '<>';
case 'GT':
return '>';
case 'LT':
return '<';
case 'GTE':
return '>=';
case 'LTE':
return '<=';
default:
return null;
}
}
/** `&` -- text concatenation, left-associative. Binds looser than `+ -`
* (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter
* than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not
* `A1&(B1="ab")`) -- same slot Excel gives `&`. */
private concat(): Node {
let left = this.additive();
while (this.at('AMP')) {
this.next();
left = { kind: 'binary', op: '&', left, right: this.additive() };
}
return left;
}
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
* `expression` itself gained the comparison wrapper above it. */
private additive(): Node {
let left = this.term();
while (this.at('PLUS') || this.at('MINUS')) {
const op = this.next().type === 'PLUS' ? '+' : '-';
left = { kind: 'binary', op, left, right: this.term() };
}
return left;
}
private term(): Node {
let left = this.unary();
while (this.at('STAR') || this.at('SLASH')) {
const op = this.next().type === 'STAR' ? '*' : '/';
left = { kind: 'binary', op, left, right: this.unary() };
}
return left;
}
private unary(): Node {
if (this.at('PLUS') || this.at('MINUS')) {
const op = this.next().type === 'PLUS' ? '+' : '-';
return { kind: 'unary', op, operand: this.unary() };
}
return this.power();
}
private power(): Node {
const base = this.primary();
if (this.at('CARET')) {
this.next();
// right operand is `unary` -> right-associative and allows -exponent
return { kind: 'binary', op: '^', left: base, right: this.unary() };
}
return base;
}
private primary(): Node {
const t = this.peek();
if (t.type === 'NUMBER') {
// `1:1` / `2:5` -- a whole-row reference, not a number
if (wholeLineAt(this.toks, this.i)) return this.reference(null, null);
this.next();
return { kind: 'number', value: Number(t.value) };
}
if (t.type === 'STRING') {
this.next();
return { kind: 'string', value: t.value };
}
if (t.type === 'ERRORLIT') {
this.next();
return { kind: 'errorliteral', code: t.value as ErrorCode };
}
if (t.type === 'LPAREN') {
this.next();
const inner = this.expression();
this.expect('RPAREN');
return inner;
}
if (t.type === 'QUOTED') {
// must be a qualified reference -- afterFirstQualifierToken() figures
// out whether it's a table qualifier ('Kosten Q3'!B2) or a sheet
// qualifier starting a 3-part ('Analyse'!Umsatz::B2) reference.
this.next();
return this.afterFirstQualifierToken(t.value);
}
if (t.type === 'IDENT') {
this.next();
// function call?
if (this.at('LPAREN')) {
this.next();
const args = this.parseArgList();
this.expect('RPAREN');
return { kind: 'func', name: t.value, args };
}
// qualified reference? IDENT ("!" | "::") ...
if (this.at('BANG') || this.at('DBLCOLON')) {
return this.afterFirstQualifierToken(t.value);
}
// otherwise it must be a local cell reference
return this.reference(null, null, t);
}
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
}
/**
* Called right after consuming the first IDENT/QUOTED of what is *some*
* kind of qualified reference (current token is known to be "!" or "::").
* Disambiguates the two qualifier grammars:
* - "X::..." or a bare "X!..." (not followed by another name + "::") is
* the original, single-level TABLE qualifier -- unchanged from before
* this existed, so every formula written before the sheet qualifier
* was added still parses exactly the same.
* - "X!Y::..." -- when "!" is immediately followed by another
* IDENT/QUOTED and *that* is followed by "::" -- is the 3-part form:
* X is the SHEET, Y is the TABLE.
*/
private afterFirstQualifierToken(first: string): Node {
if (this.at('BANG') && this.looksLikeSheetThenTable()) {
this.next(); // consume "!"
const tableTok = this.next(); // IDENT or QUOTED, guaranteed by the lookahead above
this.next(); // consume "::"
return this.reference(tableTok.value, first);
}
if (this.at('BANG') || this.at('DBLCOLON')) {
this.next();
return this.reference(first, null);
}
throw new ParseError(`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`);
}
/** True iff, with the current token being "!", the shape ahead is
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
* followed by a table qualifier, not a bare (old-style) table qualifier
* of its own. Pure lookahead, consumes nothing. */
private looksLikeSheetThenTable(): boolean {
const n1 = this.toks[this.i + 1];
const n2 = this.toks[this.i + 2];
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
}
/**
* Parse the cell/range part of a reference. `firstTok` (when given) is an
* IDENT already consumed by the caller for the *local* case.
*/
private reference(qualifier: string | null, sheetQualifier: string | null, firstTok?: Token): Node {
// whole column `B:B` / whole row `1:1` -- checked first: `B` alone would
// otherwise be a named-range candidate and `1` a number
const line = wholeLineAt(this.toks, firstTok ? this.i - 1 : this.i);
if (line) {
if (!firstTok) this.next();
this.next(); // ":"
this.next(); // second part
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
return line.axis === 'col'
? {
kind: 'range',
qualifier,
...sheetField,
start: { col: line.start, row: 0 },
end: { col: line.end, row: WHOLE_LINE_END },
whole: 'col',
}
: {
kind: 'range',
qualifier,
...sheetField,
start: { col: 0, row: line.start },
end: { col: WHOLE_LINE_END, row: line.end },
whole: 'row',
};
}
const a = firstTok ?? this.expectCellIdent();
if (!firstTok && a.type !== 'IDENT') {
throw new ParseError(`Expected cell reference at ${a.pos}`);
}
// an unqualified name matching a declared parameter -- only reachable
// when parseFormula() was given a paramNames list; a param is always a
// scalar, so it's never part of a qualifier or a ":"-range, only ever
// this single bare token.
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
return { kind: 'param', name: a.value };
}
// an unqualified, non-cell-shaped bare name -- a named-range candidate
// (see NamedRefNode's doc comment), but only for an ordinary top-level
// formula (namedRefFallback); a function body keeps the strict old
// behaviour so e.g. a local used before its own declaring statement
// still fails immediately at save time instead of silently becoming a
// named-range lookup (or worse, one that happens to resolve because a
// same-named named range exists elsewhere in the document) -- a caller
// can still *pass* a named range as an argument, it just can't be typed
// bare inside the body itself. Never a range-start either way: "Name:B2"
// isn't supported, so this doesn't fall through to the ":"-range check.
if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) {
return { kind: 'namedref', name: a.value };
}
if (!looksLikeCell(a.value)) {
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
}
const start = parseA1(a.value);
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
if (this.at('COLON')) {
this.next();
const b = this.expectCellIdent();
if (!looksLikeCell(b.value)) {
throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`);
}
const end = parseA1(b.value);
return { kind: 'range', qualifier, ...sheetField, start, end };
}
return { kind: 'cell', qualifier, ...sheetField, col: start.col, row: start.row };
}
private expectCellIdent(): Token {
if (!this.at('IDENT')) {
throw new ParseError(`Expected cell reference but got ${this.peek().type} at ${this.peek().pos}`);
}
return this.next();
}
}
/**
* Parse a raw formula. The leading "=" is optional (callers usually strip it).
* Throws {@link ParseError} / {@link LexError} on malformed input; the store
* turns those into the `#ERR` cell value.
*
* `paramNames`, when given, is a user-defined function's parameter list --
* bare identifiers matching one of them parse as a {@link ParamNode} instead
* of requiring cell-address shape. Omit it (the default) for every ordinary
* cell formula; behaviour is then identical to before this existed, *except*
* that a bare, unqualified, non-cell identifier now parses as a
* {@link NamedRefNode} (a named-range candidate) instead of a ParseError --
* see NamedRefNode's doc comment. That fallback only applies when
* `paramNames` is omitted entirely (`undefined`) -- passing it, even as an
* empty array, means "this is a function body", where the fallback is
* deliberately off (see the Parser class's `namedRefFallback` field).
*/
export function parseFormula(src: string, paramNames?: readonly string[]): Node {
const body = src.startsWith('=') ? src.slice(1) : src;
let toks: Token[];
try {
toks = tokenize(body);
} catch (e) {
if (e instanceof LexError) throw new ParseError(e.message);
throw e;
}
return new Parser(toks, paramNames, paramNames === undefined).parse();
}
/**
* Parse a "block" function body -- the statement grammar (assignments,
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
* `kind: 'block'` custom function (see src/engine/userFunctions.ts,
* evaluator.ts's interpreter, and CustomFunctionsModal.vue). The leading
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
* {@link LexError} on malformed input, same conventions as parseFormula().
*/
export function parseFunctionBlock(src: string, paramNames: readonly string[]): BlockNode {
const body = src.startsWith('=') ? src.slice(1) : src;
let toks: Token[];
try {
toks = tokenize(body);
} catch (e) {
if (e instanceof LexError) throw new ParseError(e.message);
throw e;
}
return new Parser(toks, paramNames, false).parseBlock();
}
/**
* Parse a macro body -- the same statement grammar as `parseFunctionBlock`
* (assignments, `FOR`/`IF`/`RETURN`), plus `NAME(args)` write-action calls
* (`ActionNode`; see its doc comment), which only this entry point enables.
* A macro takes no parameters (triggered by a click, not called with
* arguments) and gets no named-range fallback (same reasoning as a function
* body: a bare identifier used before its own declaring statement should
* fail immediately, not silently resolve to a same-named document-wide
* named range) -- so `paramNames`/`namedRefFallback` are fixed at `[]`/
* `false`, only `allowWrites` differs from `parseFunctionBlock`.
*/
export function parseMacroBlock(src: string): BlockNode {
const body = src.startsWith('=') ? src.slice(1) : src;
let toks: Token[];
try {
toks = tokenize(body);
} catch (e) {
if (e instanceof LexError) throw new ParseError(e.message);
throw e;
}
return new Parser(toks, [], false, true).parseBlock();
}
/** Walk an AST and collect every cell/range/named-range reference node it
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
* every expression reachable inside its `block` -- conservatively: a
* reference inside a branch/loop that might not execute at runtime still
* counts as a dependency, same as an ordinary `IF`-style formula in any
* spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this
* function is host-agnostic (doesn't know the document's named ranges),
* same as it never resolves a table qualifier either; the caller
* (`Workbook.precedentsOf`) is the one that turns it into concrete cell
* keys via `resolveNamedRange()`. */
export function collectRefs(
node: Node,
out: Array<CellNode | RangeNode | NamedRefNode> = [],
): Array<CellNode | RangeNode | NamedRefNode> {
switch (node.kind) {
case 'cell':
case 'range':
case 'namedref':
out.push(node);
break;
case 'binary':
collectRefs(node.left, out);
collectRefs(node.right, out);
break;
case 'unary':
collectRefs(node.operand, out);
break;
case 'func':
node.args.forEach((a) => collectRefs(a, out));
break;
case 'blockcall':
node.args.forEach((a) => collectRefs(a, out));
collectRefsInBlock(node.block, out);
break;
case 'json':
break; // a JSON value carries no cell/range/named-range refs of its own
}
return out;
}
/** Exported for the macro save-time validator (store/document.ts), which
* walks every `CellNode`/`RangeNode` this finds to reject an unqualified
* reference -- a macro has no "home table" a bare `A1` could mean, unlike
* a formula cell (see store/document.ts's `validateMacro`). */
export function collectRefsInBlock(
block: BlockNode,
out: Array<CellNode | RangeNode | NamedRefNode> = [],
): Array<CellNode | RangeNode | NamedRefNode> {
for (const stmt of block.statements) {
switch (stmt.kind) {
case 'assign':
collectRefs(stmt.value, out);
break;
case 'return':
collectRefs(stmt.value, out);
break;
case 'for':
collectRefs(stmt.source, out);
collectRefsInBlock(stmt.body, out);
break;
case 'ifstmt':
collectRefs(stmt.cond, out);
collectRefsInBlock(stmt.then, out);
if (stmt.else) collectRefsInBlock(stmt.else, out);
break;
case 'action':
stmt.args.forEach((a) => collectRefs(a, out));
break;
}
}
return out;
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
*
* All coordinates the engine uses internally are 0-based. The A1 notation
* exposed to the user is 1-based for rows and letter-based for columns
* (A=0, B=1, ..., Z=25, AA=26, ...).
*/
import { tokenize, type Token } from './lexer';
const CELL_RE = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/;
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
export function colToNumber(letters: string): number {
let n = 0;
const up = letters.toUpperCase();
for (let i = 0; i < up.length; i++) {
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
}
return n - 1;
}
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
export function numberToCol(n: number): string {
let s = '';
let x = n + 1;
while (x > 0) {
const rem = (x - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
x = Math.floor((x - 1) / 26);
}
return s;
}
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
export function looksLikeCell(text: string): boolean {
return CELL_RE.test(text);
}
/**
* Parse "A1" / "$A1" / "A$1" / "$A$1" -> 0-based coords plus which axes are
* `$`-locked (absolute). A locked axis is exempt from the reference shift a
* copy/fill applies (see `shiftFormula` in the store); an unlocked one is
* "relative" and always shifts with it. Throws for malformed input.
*/
export function parseA1(
text: string,
): { col: number; row: number; colAbs: boolean; rowAbs: boolean } {
const m = CELL_RE.exec(text);
if (!m) throw new Error(`Bad cell reference: ${text}`);
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
}
/** Build "A1" from 0-based coordinates; `colAbs`/`rowAbs` add the `$` lock markers. */
export function toA1(col: number, row: number, colAbs = false, rowAbs = false): string {
return `${colAbs ? '$' : ''}${numberToCol(col)}${rowAbs ? '$' : ''}${row + 1}`;
}
/**
* Excel/Numbers-style F4: cycle every cell reference through
* relative -> absolute -> row-locked -> column-locked -> relative, each
* from its own current state.
*
* With a collapsed selection (`selStart === selEnd`, the common case -- just
* a caret), this toggles the single reference at/adjacent to it. With a real
* text selection, it toggles *every* reference the selection overlaps at
* once -- select a stretch of formula text spanning several references and
* F4 adjusts them all in one press, same as Excel.
*
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
* selection re-spanning the (possibly resized) references, so a repeated F4
* keeps cycling the same ones; `null` when none were found.
*/
export function cycleReferenceLocks(
formula: string,
selStart: number,
selEnd: number,
): { text: string; start: number; end: number } | null {
if (!formula.startsWith('=')) return null;
const body = formula.slice(1);
const lo = Math.min(selStart, selEnd) - 1;
const hi = Math.max(selStart, selEnd) - 1;
const collapsed = lo === hi;
let toks: Token[];
try {
toks = tokenize(body);
} catch {
return null;
}
const targets: Token[] = [];
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
const next = toks[i + 1];
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
const tEnd = t.pos + t.value.length;
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
if (hit) targets.push(t);
}
if (!targets.length) return null;
let out = '';
let last = 0;
let start = -1;
let end = -1;
for (const tok of targets) {
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
const rewritten = toA1(col, row, nColAbs, nRowAbs);
out += body.slice(last, tok.pos) + rewritten;
if (start === -1) start = out.length - rewritten.length;
end = out.length;
last = tok.pos + tok.value.length;
}
out += body.slice(last);
// a collapsed caret in -> a collapsed caret out (after the one toggled
// reference), same as before; a real selection in keeps spanning every
// reference it touched, so a repeated F4 keeps cycling all of them.
return collapsed
? { text: '=' + out, start: 1 + end, end: 1 + end }
: { text: '=' + out, start: 1 + start, end: 1 + end };
}
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */
function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
if (!colAbs && !rowAbs) return [true, true];
if (colAbs && rowAbs) return [false, true];
if (!colAbs && rowAbs) return [true, false];
return [false, false]; // colAbs && !rowAbs
}
/**
* If the caret sits inside (anywhere within, or right at the end of) a bare
* identifier being typed in a formula -- not yet a resolved cell reference,
* not the start of an already-open function call -- return the identifier's
* text *up to the caret* and its start offset in `formula` (which still
* includes the leading "="). Drives function-name autocomplete; `null`
* means "nothing to suggest here" (not a formula at all, a cell reference
* like "A1", the caret sitting in whitespace or right after a completed
* token rather than still touching it, ...).
*
* Same shape as {@link cycleReferenceLocks} above: strip the leading "=",
* tokenize, and inspect the token(s) around the caret -- except here only
* the prefix up to the caret is tokenized (nothing after it is ever looked
* at, same as how autocomplete works in most editors), so the "identifier"
* found is always exactly what's to the *left* of the caret, regardless of
* whether more of the same word continues to its right.
*/
export function currentIdentifierAtCaret(
formula: string,
caret: number,
): { text: string; start: number } | null {
if (!formula.startsWith('=')) return null;
const pos = caret - 1; // caret position relative to body = formula.slice(1)
if (pos < 0) return null;
let toks: Token[];
try {
toks = tokenize(formula.slice(1, caret));
} catch {
return null;
}
// tokenize() appends a trailing EOF marker -- skip it to get the last
// *real* token.
const real = toks.filter((t) => t.type !== 'EOF');
const last = real[real.length - 1];
if (!last || last.type !== 'IDENT') return null;
// the token must reach all the way to the caret -- if it doesn't, the
// caret has moved past it (trailing whitespace, e.g. "=SUM |") and
// there's no word actively being typed right at the caret any more.
if (last.pos + last.value.length !== pos) return null;
if (looksLikeCell(last.value)) return null; // looks like a cell reference, not a function name
return { text: last.value, start: last.pos + 1 };
}
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
/** Stand-in for "the last row/column" in a whole-column/whole-row range's
* open end. Every consumer clamps it to the actual table size (see
* {@link rangeCells}); a consumer that merely clamps to its table (the
* Name Box selection) gets the right answer for free. */
export const WHOLE_LINE_END = 9_999_998;
export type LineAxis = 'col' | 'row';
const COL_PART_RE = /^(\$?)([A-Za-z]{1,3})$/;
const ROW_PART_RE = /^(\$?)([0-9]{1,7})$/;
/** One side of a whole-line reference: `"B"`/`"$B"` (a column) or
* `"3"`/`"$3"` (a 1-based row) -> its 0-based index, or `null`. */
export function parseLinePart(text: string): { axis: LineAxis; index: number; abs: boolean } | null {
let m = COL_PART_RE.exec(text);
if (m) return { axis: 'col', index: colToNumber(m[2]), abs: m[1] === '$' };
m = ROW_PART_RE.exec(text);
if (m && Number(m[2]) >= 1) return { axis: 'row', index: Number(m[2]) - 1, abs: m[1] === '$' };
return null;
}
export function formatLinePart(axis: LineAxis, index: number, abs: boolean): string {
return (abs ? '$' : '') + (axis === 'col' ? numberToCol(index) : String(index + 1));
}
/** If `toks[i]`, `toks[i+1]`, `toks[i+2]` spell a whole-line range (`B:B`,
* `A:C`, `1:1`, `$2:$5` -- both sides the same axis), return its parts.
* Shared by the parser and every token-level formula rewriter (copy,
* row/column insert/delete, table rename), so they all agree on the shape. */
export function wholeLineAt(
toks: Token[],
i: number,
): { axis: LineAxis; a: Token; b: Token; start: number; end: number; startAbs: boolean; endAbs: boolean } | null {
const a = toks[i];
const colon = toks[i + 1];
const b = toks[i + 2];
if (!a || !colon || !b || colon.type !== 'COLON') return null;
if ((a.type !== 'IDENT' && a.type !== 'NUMBER') || (b.type !== 'IDENT' && b.type !== 'NUMBER')) return null;
const pa = parseLinePart(a.value);
const pb = parseLinePart(b.value);
if (!pa || !pb || pa.axis !== pb.axis) return null;
return { axis: pa.axis, a, b, start: pa.index, end: pb.index, startAbs: pa.abs, endAbs: pb.abs };
}
/** The cells a range covers, with a whole-column/whole-row range's open
* end clamped to its table's current size (`size` null = unknown table ->
* no cells). An ordinary range is just {@link expandRange}. */
export function rangeCells(
r: { start: { col: number; row: number }; end: { col: number; row: number }; whole?: LineAxis },
size: { cols: number; rows: number } | null,
): Array<{ col: number; row: number }> {
if (!r.whole) return expandRange(r.start, r.end);
if (!size) return [];
const clamp = (v: number, n: number) => Math.min(v, n - 1);
const start = { col: Math.min(r.start.col, r.end.col), row: Math.min(r.start.row, r.end.row) };
const end = { col: Math.max(r.start.col, r.end.col), row: Math.max(r.start.row, r.end.row) };
// a whole line entirely beyond the table covers nothing
if (r.whole === 'col' && start.col >= size.cols) return [];
if (r.whole === 'row' && start.row >= size.rows) return [];
return expandRange(start, { col: clamp(end.col, size.cols), row: clamp(end.row, size.rows) });
}
/**
* Expand a rectangular range (given by two corners, any order) into the list of
* cell coordinates it covers, row-major.
*/
export function expandRange(
a: { col: number; row: number },
b: { col: number; row: number },
): Array<{ col: number; row: number }> {
const c0 = Math.min(a.col, b.col);
const c1 = Math.max(a.col, b.col);
const r0 = Math.min(a.row, b.row);
const r1 = Math.max(a.row, b.row);
const out: Array<{ col: number; row: number }> = [];
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
out.push({ col: c, row: r });
}
}
return out;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Shared types for the formula engine.
*
* The engine is intentionally framework-agnostic: it knows nothing about Vue,
* the store or the DOM. The host application implements {@link EngineHost} to
* expose the current document to the evaluator.
*/
/** All error values the engine can produce. They propagate to dependent cells. */
export type ErrorCode =
| '#REF' // invalid / deleted reference or unknown sheet name
| '#DIV/0!' // division by zero
| '#NAME?' // unknown function
| '#ERR' // parse error
| '#CIRC' // circular reference
| '#VALUE!' // wrong value type in arithmetic (documented extension)
| '#N/A'; // XLOOKUP: no matching entry and no if_not_found given (documented extension)
export const ERROR_CODES: ErrorCode[] = [
'#REF',
'#DIV/0!',
'#NAME?',
'#ERR',
'#CIRC',
'#VALUE!',
'#N/A',
];
/** A formula error. Instances are immutable and comparable by `code`. */
export class FormulaError {
constructor(
public readonly code: ErrorCode,
public readonly detail?: string,
) {}
toString(): string {
return this.code;
}
}
export function isError(v: unknown): v is FormulaError {
return v instanceof FormulaError;
}
/** A plain scalar a cell can hold once evaluated. `null` means "empty cell". */
export type Scalar = number | string | null;
/** The fully evaluated value of a cell. */
export type CellValue = Scalar | FormulaError;
/** 0-based cell coordinate inside a single table. */
export interface Coord {
col: number;
row: number;
}
/** A cell address that also carries the id of the table it lives on. */
export interface CellRef extends Coord {
tableId: string;
}
/** A document-defined name resolving to a fixed cell/range on a specific
* table -- fed into {@link Workbook} (see workbook.ts) so it can implement
* {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is
* no expression/formula of its own, just fixed coordinates -- so there is
* nothing that could cyclically reference itself. `tableId` (not a table
* *name*) so a named range survives its target table being renamed; the
* host resolves it to that table's *current* name on lookup, the same
* qualifier a formula's own `Tabelle!Zelle` reference would carry. */
export interface NamedRangeDef {
id: string;
name: string;
tableId: string;
start: Coord;
end: Coord;
}
/** Stable string key for a cell across the whole document. */
export function keyOf(tableId: string, col: number, row: number): string {
return `${tableId}␟${col}␟${row}`;
}
export function parseKey(key: string): CellRef {
const [tableId, col, row] = key.split('␟');
return { tableId, col: Number(col), row: Number(row) };
}
/**
* The document as seen by the engine. Implemented by the Vue store.
*
* The addressable unit is a **table** (a table object on a Numbers-style
* canvas). Table names are only unique *per sheet* -- two different sheets
* may each hold a table called "Umsatz". An unqualified table reference
* (`Umsatz!B2` / `Umsatz::B2`, no sheet given) resolves to whichever table
* named "Umsatz" lives on the *same sheet* as the formula doing the
* referencing, falling back to the single document-wide match when the name
* isn't ambiguous there. The explicit 3-part form (`Analyse!Umsatz::B2`)
* disambiguates when it would otherwise be genuinely ambiguous (name used on
* two *other* sheets, neither of them the formula's own).
*/
export interface EngineHost {
/** Id of the table the formula currently being evaluated lives on. */
homeTableId: string;
/**
* Resolve a table *name* (as written in a formula) to its id, optionally
* narrowed to a specific *sheet* name (the "Blatt!" part of a 3-part
* `Blatt!Tabelle::Zelle` reference). Matching is case-insensitive and
* tolerant of surrounding whitespace.
*
* - `sheetName` given: only a table with that name **on that sheet**
* matches; otherwise `null` (including when the sheet itself is unknown).
* - `sheetName` omitted/`null`: matches a table by name anywhere in the
* document. Exactly one match resolves as before. Multiple matches (the
* name exists on more than one sheet) prefer a table on the same sheet
* as {@link homeTableId}; if that still doesn't narrow it to one, `null`
* (`#REF` -- the formula needs an explicit sheet qualifier).
*/
resolveTableName(name: string, sheetName?: string | null): string | null;
/**
* Resolve a *named range* (as written, unqualified, in a formula -- e.g.
* `Umsatz_Q1`) to the table-qualified cell/range it currently stands for.
* Matching is case-insensitive and tolerant of surrounding whitespace,
* same as {@link resolveTableName}. `null` when the name is unknown (or
* its target table has since been deleted) -- the caller turns that into
* `#NAME?`, the same error class an unknown function name gets.
*
* Resolved by *name*, live, every time -- never baked into the AST -- so a
* named range survives its target table being renamed, the same way a
* `Tabelle!Zelle` reference's table name is looked up fresh on every
* evaluation rather than resolved once at parse time.
*/
resolveNamedRange(
name: string,
): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null;
/** Dimensions of a table, used for range clamping and #REF detection. */
tableSize(tableId: string): { cols: number; rows: number } | null;
/**
* Already-computed value of another cell. The recalculation driver guarantees
* precedents are evaluated first (topological order), so this never triggers
* a nested evaluation.
*/
getComputed(tableId: string, col: number, row: number): CellValue;
}
/**
* The document's WRITE surface, as seen by a running macro (see
* evaluator.ts's `runMacroActions`/`execAction` and engine/README.md's
* "Makros" section). Deliberately separate from {@link EngineHost} -- that
* interface stays pure/read-only, implemented by `Workbook` alone, used by
* every ordinary formula evaluation; `MacroWriteHost` is only ever
* implemented by the store (`document.ts#runMacro`), only ever passed to
* the interpreter for an explicitly-triggered macro run, and never reaches
* the passive recalculation pass at all -- a macro's write is
* indistinguishable, from the engine's point of view, from the same edit
* made by hand through the UI.
*
* Every table/sheet argument below is a plain qualifier STRING (e.g.
* `"Umsatz"` or `"Blatt2!Kosten"`, split on the first `!`) rather than a
* resolved id or a dedicated reference AST node -- resolved fresh, by the
* implementation, exactly like a formula's own `Tabelle!Zelle` qualifier
* is resolved fresh on every read (never cached), so a macro that renames
* or adds a table earlier in its own run can address it by its new name
* later in the same run.
*/
export interface MacroWriteHost {
/** Write the same raw cell text (auto-detected as a formula if it starts
* with "=", exactly like typing it manually) into every given cell --
* one cell for a `SETVALUE` on a single reference, several for a range
* target (broadcasting the same value, Excel's Ctrl+Enter semantics). */
setValue(cells: CellRef[], text: string): void;
insertRow(tableRef: string, at: number): void;
deleteRow(tableRef: string, at: number): void;
insertCol(tableRef: string, at: number): void;
deleteCol(tableRef: string, at: number): void;
/** `sheetRef` is a plain sheet name (not table-qualified). */
addTable(sheetRef: string, name: string): void;
renameTable(tableRef: string, newName: string): void;
deleteTable(tableRef: string): void;
addSheet(name: string): void;
renameSheet(sheetRef: string, newName: string): void;
deleteSheet(sheetRef: string): void;
}
/**
* The document's NETWORK surface, as seen by a running macro's `GET`/`POST`
* assignment form (`x = GET(url)` / `x = POST(url; body)`; see evaluator.
* ts's `execStatementAsync`). Kept separate from {@link MacroWriteHost} for
* the same reason that one is kept separate from {@link EngineHost}: this
* engine module stays framework/DOM-agnostic (no `fetch`, no browser APIs
* anywhere in `src/engine/`) -- the store (`document.ts#runMacro`) is the
* only implementer, and it's the one place a network confirmation prompt
* and the actual `fetch()` call belong.
*
* A single `request()` method (not separate `get`/`post` methods) because
* both share the exact same "confirm this host once, then fetch and parse
* JSON" flow in the implementation -- only the HTTP method and whether a
* body is sent differ.
*/
export interface MacroNetworkHost {
/** Resolves with the parsed JSON response body, or rejects with a plain
* `Error` (network failure, a non-2xx status, a response that isn't
* valid JSON, or the user declining a host-confirmation prompt) --
* caught the same way as any other macro action failure, aborting the
* run and surfacing the message as the result's `.message`. */
request(method: 'GET' | 'POST', url: string, body?: string): Promise<unknown>;
}
+434
View File
@@ -0,0 +1,434 @@
/**
* User-defined functions: a document-level library of named, parameterised
* formulas (`MARGE(revenue; cost) = (revenue - cost) / revenue`) callable
* from any cell like a built-in (`=MARGE(Umsatz!B2; Umsatz!C2)`).
*
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
* which has no room for a "body formula with its own cell references and a
* parameter environment". Rather than rework the evaluator, a `kind:
* 'expression'` call (a single-formula body) is *inlined* into the caller's
* AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
* is replaced by a (recursively expanded) copy of the function body, with
* every `ParamNode` swapped for the caller's own argument subtree. After
* that, the stored AST looks exactly as if the user had written the
* expanded formula by hand -- `collectRefs`/`precedentsOf`/`evaluate` all
* work completely unmodified, and correctly pick up cell references that
* live *only* inside the function body, not just the ones passed as
* arguments.
*
* A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
* `parseFunctionBlock`) is different: this module only resolves *nested*
* function calls inside it (`expandBlock`, below) -- still at parse time,
* still the same "inside-out" inlining -- and wraps the result in a
* `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
* branching, an early RETURN) is only resolved at evaluation time, by a
* small interpreter in `evaluator.ts`, since it genuinely depends on real
* cell values. See src/engine/README.md's "Schleifen" section for the full
* design and why that split is necessary.
*/
import {
parseFormula,
parseFunctionBlock,
type ActionNode,
type BlockNode,
type ForNode,
type Node,
} from './parser';
export interface FunctionDef {
id: string;
/** document-unique, matched case-insensitively (like built-in names). */
name: string;
/** parameter names, in call order. */
params: string[];
/** raw formula text, no leading "=" -- a single expression for
* `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
* IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
body: string;
/** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
* "code"). Every function predating this field is 'expression'. */
kind: 'expression' | 'block';
}
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
* rejected at definition time (see the store's cycle check), this is only
* a last-resort safety net so a bug there degrades to a cell error rather
* than a stack overflow. */
const MAX_EXPANSION_DEPTH = 32;
export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, FunctionDef> {
return new Map(defs.map((d) => [d.name.toUpperCase(), d]));
}
/**
* Replace every call to a user-defined function in `ast` with either a
* (recursively expanded, parameter-substituted) copy of its body
* (`kind: 'expression'`), or a `BlockCallNode` wrapping its (nested-calls-
* resolved) block for evaluator.ts's interpreter to run (`kind: 'block'`).
* Calls to built-in or genuinely unknown names are left untouched --
* `evaluate()`'s existing `#NAME?` path already covers "unknown function",
* built-in or not.
*
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
* just structural problems: wrong argument count, or a definition cycle
* `MAX_EXPANSION_DEPTH` didn't catch at save time) on failure; the caller
* (`Workbook.parseCell`) wraps this the same way it already wraps
* `parseFormula` errors, turning it into a `#ERR` cell value.
*/
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
if (index.size === 0) return ast; // fast path: no user functions defined at all
return expand(ast, index, new Map(), new Map(), 0);
}
/**
* {@link expandUserFunctions}, for a macro body (`parseMacroBlock()`'s
* output) instead of an ordinary formula/function AST -- resolves any
* custom-function call reachable from a macro's expressions (e.g.
* `SETVALUE(Umsatz::A1; MARGE(Umsatz::B1; Umsatz::C1))`) the exact same way
* a formula would, via the already-existing `expandBlock`. The store calls
* this once per `runMacro()` before handing the block to
* evaluator.ts's `runMacroActions` -- a nested custom-function call can
* therefore compute a value inside a macro, but (since it's always parsed
* with `allowWrites: false`) can never itself contain an `ActionNode`, so
* it can never perform a write.
*/
export function expandMacroBlock(block: BlockNode, index: ReadonlyMap<string, FunctionDef>): BlockNode {
if (index.size === 0) return block; // same fast path as expandUserFunctions
return expandBlock(block, index, new Map(), new Map(), 0);
}
function expand(
node: Node,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
blockCache: Map<string, BlockNode>,
depth: number,
): Node {
switch (node.kind) {
case 'number':
case 'string':
case 'errorliteral':
case 'cell':
case 'range':
case 'namedref':
case 'param':
case 'omitted':
case 'json':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
case 'binary':
return {
...node,
left: expand(node.left, index, bodyCache, blockCache, depth),
right: expand(node.right, index, bodyCache, blockCache, depth),
};
case 'blockcall':
// never present in freshly-parsed input -- only expand() itself ever
// produces one (see the 'func' case below) -- but the type checker
// doesn't know that, so: a defensive leaf, nothing to do.
return node;
case 'func': {
// expand the arguments regardless of whether this call is itself a
// user function -- a built-in like SUM(...) may still take a
// user-function call as one of its arguments.
const args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
const def = index.get(node.name.toUpperCase());
if (!def) return { ...node, args };
if (depth >= MAX_EXPANSION_DEPTH) {
throw new Error(`'${def.name}' expansion too deep (likely a definition cycle)`);
}
if (args.length !== def.params.length) {
throw new Error(
`'${def.name}' expects ${def.params.length} argument(s), got ${args.length}`,
);
}
if (def.kind === 'block') {
let block = blockCache.get(def.id);
if (!block) {
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
blockCache.set(def.id, block);
}
// resolve any *other* function calls the block contains (built-ins
// untouched, other user functions inlined/turned into their own
// BlockCallNode) -- same "inside-out" principle as the
// expression-kind path below. This function's *own* parameters
// stay as ParamNode inside `block`; evaluator.ts's interpreter
// binds them from `args` at evaluation time, since a FOR loop's
// iteration count and an IF's branch both depend on real cell
// values that aren't known yet here.
const expandedBlock = expandBlock(block, index, bodyCache, blockCache, depth + 1);
return { kind: 'blockcall', block: expandedBlock, args, paramNames: def.params, label: def.name };
}
let body = bodyCache.get(def.id);
if (!body) {
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
bodyCache.set(def.id, body);
}
// expand any calls *inside* the body first (it may call other user
// functions -- possibly reusing the same parameter names as this one;
// resolving inside-out like this keeps each function's own parameters
// scoped correctly no matter what the caller's happen to be named),
// then substitute this function's own parameters last.
const expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
return substitute(expandedBody, bound);
}
}
}
/**
* Replace every `ParamNode` in `node` matching a name in `bound` with its
* bound subtree. Exported for evaluator.ts's block interpreter, which reuses
* this exact mechanism to resolve names (parameters *and* local variables --
* it doesn't distinguish the two, both are just entries in its environment
* map) against the current execution state before evaluating an expression.
*/
export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
switch (node.kind) {
case 'param':
// always found in practice (arity is checked before this runs, and
// parameter names are exactly what the body was parsed against) --
// the fallback just leaves an unresolved param for evaluate()'s own
// defensive #ERR case rather than assuming it can't happen.
return bound.get(node.name.toUpperCase()) ?? node;
case 'number':
case 'string':
case 'errorliteral':
case 'cell':
case 'range':
case 'namedref':
case 'omitted':
case 'json':
return node;
case 'unary':
return { ...node, operand: substitute(node.operand, bound) };
case 'binary':
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
case 'func':
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
case 'blockcall':
// only `.args` can still reference an *outer* scope's parameter (e.g.
// this call sits inside another function's own body, referencing
// *that* function's parameter) -- `.block` itself only refers to this
// call's *own* parameters, a separate scope bound later, at
// evaluation time, so it's left untouched here.
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
}
}
/** {@link expand}, run over every leaf expression a `BlockNode` contains
* (each statement's value/condition/source, recursing into nested FOR/IF
* bodies) instead of over a single `Node` -- resolves nested function
* calls throughout a block-kind function's body before it's wrapped in a
* `BlockCallNode`. */
function expandBlock(
block: BlockNode,
index: ReadonlyMap<string, FunctionDef>,
bodyCache: Map<string, Node>,
blockCache: Map<string, BlockNode>,
depth: number,
): BlockNode {
return {
kind: 'block',
statements: block.statements.map((stmt) => {
switch (stmt.kind) {
case 'assign':
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
case 'return':
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
case 'for':
return {
...stmt,
source: expand(stmt.source, index, bodyCache, blockCache, depth),
body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
};
case 'ifstmt':
return {
...stmt,
cond: expand(stmt.cond, index, bodyCache, blockCache, depth),
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
};
case 'action':
return { ...stmt, args: stmt.args.map((a) => expand(a, index, bodyCache, blockCache, depth)) };
}
}),
};
}
/**
* Every distinct function name called anywhere in `ast` (built-in or not --
* callers filter against their own registry). Used by the store to build a
* definition-time call graph between user functions for cycle detection,
* without a second AST-walking implementation alongside `collectRefs`.
*/
export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set()): Set<string> {
switch (ast.kind) {
case 'func':
out.add(ast.name.toUpperCase());
ast.args.forEach((a) => collectCalledFunctionNames(a, out));
break;
case 'binary':
collectCalledFunctionNames(ast.left, out);
collectCalledFunctionNames(ast.right, out);
break;
case 'unary':
collectCalledFunctionNames(ast.operand, out);
break;
default:
break; // number/string/cell/range/param/blockcall -- no nested calls
}
return out;
}
/** Every name a block declares -- an assignment target or a loop variable --
* in the order first declared, recursing into nested FOR/IF bodies. Used
* by the store to validate these local names the same way it validates
* parameters (not cell-shaped, no collision with a parameter, not a
* reserved word); the engine itself has no opinion on naming policy or
* German error text, so it only exposes the raw names here. The *same*
* name legitimately reappears (an accumulator re-assigned inside a loop,
* or across two separate loops) -- that's not the store's concern either,
* it's intentional under the flat-scope design (see parser.ts). */
export function declaredBlockNames(block: BlockNode, out: string[] = []): string[] {
for (const stmt of block.statements) {
switch (stmt.kind) {
case 'assign':
out.push(stmt.name);
break;
case 'for':
out.push(stmt.varName);
declaredBlockNames(stmt.body, out);
break;
case 'ifstmt':
declaredBlockNames(stmt.then, out);
if (stmt.else) declaredBlockNames(stmt.else, out);
break;
case 'return':
case 'action':
break;
}
}
return out;
}
/** The first FOR loop, anywhere in the block (including nested inside
* another FOR/IF), whose source isn't (syntactically) a parameter, a
* single cell, a range, or a named-range reference -- i.e. can never
* resolve to a concrete list of cells no matter what the caller passes.
* Checked at save time so this is caught immediately, not just on the
* first real call; the evaluator's
* interpreter re-checks the same thing on the *substituted* source at call
* time regardless (needed either way, since a parameter's shape isn't
* known until then) as the actual enforcement. */
export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'for') {
if (!isReferenceShaped(stmt.source) && !isJsonGetCall(stmt.source)) return stmt;
const nested = findInvalidForSource(stmt.body);
if (nested) return nested;
} else if (stmt.kind === 'ifstmt') {
const nested = findInvalidForSource(stmt.then) ?? (stmt.else && findInvalidForSource(stmt.else));
if (nested) return nested;
}
}
return null;
}
/** Shared by {@link findInvalidForSource} and {@link findInvalidActionTarget}:
* can this node, once parameters/locals are substituted at call time,
* possibly resolve to a cell/range address at all? (A computed value --
* `2+2`, a function call, ... -- never can, no matter what's substituted
* in.) */
function isReferenceShaped(node: Node): boolean {
return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref';
}
/** A `FOR` source may also be a `JSONGET(...)` call -- once substituted and
* evaluated it may resolve to a JSON array (see evaluator.ts's
* `execStatementAsync` `'for'` case), a possibility `isReferenceShaped`
* above doesn't cover since it's a genuinely new FOR-source *kind*, not a
* reference. Not shared with `findInvalidActionTarget`'s SETVALUE-target
* check -- a JSONGET result is a VALUE, never a place to write to, so a
* write target must stay reference-shaped only. */
function isJsonGetCall(node: Node): boolean {
return node.kind === 'func' && node.name.toUpperCase() === 'JSONGET';
}
/** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE`
* target (its first argument) instead of a FOR source -- same reasoning,
* checked at save time by store/document.ts#validateMacro. */
export function findInvalidActionTarget(block: BlockNode): ActionNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'action') {
if (stmt.name.toUpperCase() === 'SETVALUE' && stmt.args.length > 0 && !isReferenceShaped(stmt.args[0])) {
return stmt;
}
} else if (stmt.kind === 'for') {
const nested = findInvalidActionTarget(stmt.body);
if (nested) return nested;
} else if (stmt.kind === 'ifstmt') {
const nested = findInvalidActionTarget(stmt.then) ?? (stmt.else && findInvalidActionTarget(stmt.else));
if (nested) return nested;
}
}
return null;
}
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
* over every leaf expression a `BlockNode` contains (each statement's
* value/condition/source, recursing into nested FOR/IF bodies) and merges
* the results -- `collectCalledFunctionNames` itself needs no changes,
* since a block is never part of the `Node` union it switches on. */
export function collectCalledFunctionNamesInBlock(
block: BlockNode,
out: Set<string> = new Set(),
): Set<string> {
for (const stmt of block.statements) {
switch (stmt.kind) {
case 'assign':
case 'return':
collectCalledFunctionNames(stmt.value, out);
break;
case 'for':
collectCalledFunctionNames(stmt.source, out);
collectCalledFunctionNamesInBlock(stmt.body, out);
break;
case 'ifstmt':
collectCalledFunctionNames(stmt.cond, out);
collectCalledFunctionNamesInBlock(stmt.then, out);
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
break;
case 'action':
stmt.args.forEach((a) => collectCalledFunctionNames(a, out));
break;
}
}
return out;
}
/**
* Conservative static check: does *every* execution path through `block`
* reach a `return`? True if the block contains a `return` statement
* (statements after it are unreachable but harmless), or an `IF` with an
* `ELSE` where *both* branches always return. A `FOR` loop is never enough
* on its own -- even if its body always returns when it runs, the loop
* might cover zero cells at call time, which this static check can't see
* (the interpreter's own "ended without RETURN" -> `#ERR` is the actual,
* always-correct enforcement; this is only an early, save-time warning).
*/
export function blockAlwaysReturns(block: BlockNode): boolean {
return block.statements.some((stmt) => {
if (stmt.kind === 'return') return true;
if (stmt.kind === 'ifstmt' && stmt.else) {
return blockAlwaysReturns(stmt.then) && blockAlwaysReturns(stmt.else);
}
return false;
});
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Framework-agnostic spreadsheet model that ties the engine modules together.
*
* The addressable unit is a **table** (a table object on a Numbers-style
* canvas). A `Workbook` owns a flat list of tables and, across all of them:
* - the raw text of every non-empty cell,
* - the parsed AST of every formula cell (or an `#ERR` marker),
* - the computed value of every cell,
* - the document-wide {@link DependencyGraph}.
*
* It implements {@link EngineHost} so the evaluator can read neighbouring cell
* values through it. The Vue store wraps an instance of this class in `reactive`
* and mirrors its computed values into a reactive map, which makes every formula
* cell behave like a `computed` value in the UI.
*
* Recalculation is incremental: `setCell` reparses one cell, updates its graph
* edges and recomputes only the affected sub-graph in topological order.
* Structural edits (rename / delete table, add rows, ...) call `rebuild()` for a
* full, still-cheap pass.
*/
import { DependencyGraph } from './graph';
import { collectRefs, parseFormula, type Node } from './parser';
import { rangeCells } from './refs';
import { parseDateTimeLiteral } from './dates';
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
import {
FormulaError,
isError,
keyOf,
parseKey,
type CellRef,
type CellValue,
type EngineHost,
type NamedRangeDef,
} from './types';
import { evaluate } from './evaluator';
export interface TableModel {
id: string;
/** table name, used as the qualifier in formulas -- unique *per sheet*
* only; the same name may occur on more than one sheet (see
* `sheetName` and {@link EngineHost.resolveTableName}). */
name: string;
/** name of the sheet this table lives on. */
sheetName: string;
cols: number;
rows: number;
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
inputs: Map<string, string>;
}
const NUMBER_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
function localKey(col: number, row: number): string {
return `${col},${row}`;
}
/** Interpret a raw literal (non-formula) cell input. A recognized date/time
* (see dates.ts's parseDateTimeLiteral) becomes its serial number -- dates
* are just numbers in this engine, so `+`/`-`/comparisons work on them for
* free. Checked before NUMBER_RE; the two never collide (dates use `-`/`.`
* multi-part separators or `:`, which NUMBER_RE's single-decimal-point
* shape never matches). */
export function literalValue(raw: string): CellValue {
const t = raw.trim();
if (t === '') return null;
const dt = parseDateTimeLiteral(t);
if (dt) return dt.serial;
if (NUMBER_RE.test(t)) return Number(t);
return raw;
}
export class Workbook implements EngineHost {
tables: TableModel[];
/** transiently set to the home table while a formula is being evaluated */
homeTableId = '';
private readonly graph = new DependencyGraph();
/** cellKey -> parsed formula AST, or an error marker for un-parsable input */
private readonly asts = new Map<string, Node | FormulaError>();
/** cellKey -> computed value */
private readonly values = new Map<string, CellValue>();
/** user-defined function library for this document, name.toUpperCase() -> def. */
private readonly functionIndex: Map<string, FunctionDef>;
/** document-wide named ranges (see NamedRangeDef's doc comment). */
private readonly namedRanges: NamedRangeDef[];
constructor(
tables: TableModel[],
functionDefs: FunctionDef[] = [],
namedRanges: NamedRangeDef[] = [],
) {
this.tables = tables;
this.functionIndex = buildFunctionIndex(functionDefs);
this.namedRanges = namedRanges;
this.rebuild();
}
// ---- EngineHost ---------------------------------------------------------
resolveTableName(name: string, sheetName?: string | null): string | null {
const needle = name.trim().toLowerCase();
const candidates = this.tables.filter((t) => t.name.trim().toLowerCase() === needle);
if (candidates.length === 0) return null;
if (sheetName != null) {
const sheetNeedle = sheetName.trim().toLowerCase();
const hit = candidates.find((t) => t.sheetName.trim().toLowerCase() === sheetNeedle);
return hit ? hit.id : null;
}
if (candidates.length === 1) return candidates[0].id;
// ambiguous (same name on more than one sheet) -- prefer a table on the
// same sheet as the formula doing the referencing, if there is one
const home = this.tableById(this.homeTableId);
if (home) {
const sameSheet = candidates.find((t) => t.sheetName === home.sheetName);
if (sameSheet) return sameSheet.id;
}
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
}
resolveNamedRange(
name: string,
): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null {
const needle = name.trim().toLowerCase();
const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle);
if (!def) return null;
const table = this.tableById(def.tableId);
if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename
return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end };
}
tableSize(tableId: string): { cols: number; rows: number } | null {
const t = this.tableById(tableId);
return t ? { cols: t.cols, rows: t.rows } : null;
}
getComputed(tableId: string, col: number, row: number): CellValue {
if (col < 0 || row < 0) return new FormulaError('#REF');
const v = this.values.get(keyOf(tableId, col, row));
return v === undefined ? null : v;
}
// ---- reads ------------------------------------------------------------
tableById(id: string): TableModel | undefined {
return this.tables.find((t) => t.id === id);
}
getInput(tableId: string, col: number, row: number): string {
return this.tableById(tableId)?.inputs.get(localKey(col, row)) ?? '';
}
/** Public computed-value read used by the store / UI. */
cellValue(tableId: string, col: number, row: number): CellValue {
const v = this.values.get(keyOf(tableId, col, row));
return v === undefined ? null : v;
}
/** Direct precedents of a cell -- the cells its own formula reads (a
* formula's `#REF`/named-range/etc. resolution already baked into the
* graph edges, see `precedentsOf()` above). Public wrapper around the
* internal `DependencyGraph`, for UI features (the dependency-graph
* view) that need to *query* the graph -- evaluation itself never calls
* this, it only calls `setPrecedents`/`affected`/`topoOrder`. */
graphPrecedents(tableId: string, col: number, row: number): CellRef[] {
return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey);
}
/** Direct dependents of a cell -- every cell whose formula reads *this*
* one. See graphPrecedents() above. */
graphDependents(tableId: string, col: number, row: number): CellRef[] {
return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey);
}
// ---- writes ---------------------------------------------------------
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
setCell(tableId: string, col: number, row: number, raw: string): void {
const table = this.tableById(tableId);
if (!table) return;
const lk = localKey(col, row);
if (raw === '') table.inputs.delete(lk);
else table.inputs.set(lk, raw);
const key = keyOf(tableId, col, row);
this.parseCell(key, tableId, raw);
this.recalc([key]);
}
/** Reparse every formula, rebuild the graph, recompute everything. */
rebuild(): void {
this.graph.reset();
this.asts.clear();
const all: string[] = [];
for (const table of this.tables) {
for (const [lk, raw] of table.inputs) {
const [c, r] = lk.split(',').map(Number);
const key = keyOf(table.id, c, r);
all.push(key);
this.parseCell(key, table.id, raw);
}
}
this.recalcAll(all);
}
// ---- internals ------------------------------------------------------
/** Parse one cell's raw input and (re)wire its graph edges. */
private parseCell(key: string, tableId: string, raw: string): void {
if (!raw.startsWith('=')) {
this.asts.delete(key);
this.graph.clear(key);
return;
}
let ast: Node;
try {
ast = parseFormula(raw);
ast = expandUserFunctions(ast, this.functionIndex);
} catch (e) {
// a bad formula is a cell-level error, never a crash
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
this.graph.setPrecedents(key, []);
return;
}
this.asts.set(key, ast);
this.graph.setPrecedents(key, this.precedentsOf(ast, tableId));
}
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
private precedentsOf(ast: Node, tableId: string): string[] {
// resolveTableName()'s "prefer the home sheet" ambiguity fallback reads
// this.homeTableId -- set it here too (not just in evaluateCell) so graph
// edges are wired using the *same* resolution an evaluation would use.
this.homeTableId = tableId;
const out: string[] = [];
for (const ref of collectRefs(ast)) {
// a named range isn't itself a qualifier/coords pair -- resolve it to
// one first (same shape as a 'cell'/'range' ref), then fall through to
// the identical edge-wiring below.
const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref;
if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME?
const target =
resolved.qualifier == null
? tableId
: this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null);
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
if ('col' in resolved) {
out.push(keyOf(target, resolved.col, resolved.row));
} else {
for (const c of rangeCells(resolved, this.tableSize(target))) {
out.push(keyOf(target, c.col, c.row));
}
}
}
return out;
}
/** Incremental recalculation starting from `dirty`. */
private recalc(dirty: string[]): void {
this.evaluateSet(this.graph.affected(dirty));
}
/** Full recalculation of the given (complete) cell set. */
private recalcAll(all: string[]): void {
this.values.clear();
this.evaluateSet(new Set(all));
}
private evaluateSet(affected: Set<string>): void {
const { order, circular } = this.graph.topoOrder(affected);
for (const key of circular) this.values.set(key, new FormulaError('#CIRC'));
for (const key of order) this.values.set(key, this.evaluateCell(key));
}
private evaluateCell(key: string): CellValue {
const ast = this.asts.get(key);
if (ast === undefined) {
const { tableId, col, row } = parseKey(key);
return literalValue(this.getInput(tableId, col, row));
}
if (isError(ast)) return ast;
this.homeTableId = parseKey(key).tableId;
try {
return evaluate(ast, this);
} catch {
return new FormulaError('#ERR', 'evaluation failed');
}
}
/** Recompute order for a hand-picked dirty set -- exposed for tests. */
debugOrder(dirty: string[]): { order: string[]; circular: string[] } {
const { order, circular } = this.graph.topoOrder(this.graph.affected(dirty));
return { order, circular: [...circular] };
}
}