diff --git a/README.md b/README.md index 56b9305..59f94e7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt: ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 298 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 318 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -402,11 +402,17 @@ vier Kombinationen. **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), `MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat; -Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()` — plus alle über -**„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe -„Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch -verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`), -Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`. +Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert; +Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; +[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten +benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), +die sich für den Aufrufer identisch verhalten. Argumente gemischt: +Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. +`=SUM(Umsatz!B2:E2; 100; A1)`. Ein optionales Argument darf übersprungen +werden, indem die Stelle einfach leer bleibt (`XLOOKUP(x; a; b; ; -1)` +lässt „WennNichtGefunden" aus, um „Vergleichsmodus" zu erreichen) — wie in +Excel; eine leere Stelle zählt wie eine leere Zelle (`0` in Arithmetik, +von `COUNT` nicht mitgezählt). `IF` ist die einzige Funktion, die ihre Argumente **nicht** vorab auswertet: nur die Bedingung wird immer berechnet, danach ausschließlich der tatsächlich @@ -441,16 +447,34 @@ ohne Zusatzaufwand: `=fällig - rechnungsdatum` zählt Tage, `=rechnungsdatum `NOW()` liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse bekommen **kein** automatisches Zellformat (wie bei jeder anderen Formel auch) — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen. + `TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein + vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()` + aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von + selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten). Das Zahlenformat **„Datum (ISO)"** zeigt ein Datum immer als `JJJJ-MM-TT` (`2026-09-02`) — unabhängig von der aktiven Locale (Blatt/Dokument oder Zelle), anders als „Datum", das der jeweiligen Locale folgt (`02.09.2026` bei de-DE, `09/02/2026` bei en-US). Nützlich für export-nahe oder sortierbare Datumsspalten. - `TODAY`/`NOW` sind **nicht volatil**: diese Engine rechnet rein - vorgänger-getrieben neu, ohne Timer — eine Zelle mit `=TODAY()` - aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von - selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten). + +**XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; +[Vergleichsmodus]; [Suchmodus])**: sucht `Suchwert` in `Suchbereich` und +liefert die Zelle an derselben Position in `Rückgabebereich`. Text wird +case-insensitiv und getrimmt verglichen (`"birne"` findet „Birne"); +Zahl und Text gelten nie als gleich (keine automatische Typumwandlung). +`Suchbereich`/`Rückgabebereich` müssen gleich viele Zellen haben, sonst +`#VALUE!`. Kein Treffer und kein `WennNichtGefunden` angegeben → `#N/A` +(*dokumentierte Erweiterung*, wie schon `#VALUE!`); `WennNichtGefunden` +wird — wie bei `IF` — nur ausgewertet, wenn tatsächlich kein Treffer +gefunden wurde, ein Fehler darin schlägt bei einem Treffer also nie durch. +`Vergleichsmodus`: `0` (Standard) exakt, `-1`/`1` exakt oder nächstkleinerer/ +-größerer **numerischer** Wert (kein Näherungsvergleich auf Text). Excels +Platzhalter-Modus (`2`) ist nicht unterstützt. `Suchmodus`: `1` (Standard) +erster Treffer zuerst, `-1` letzter Treffer zuerst; Excels binäre +Suchmodi (`2`/`-2`, setzen einen sortierten Bereich voraus) sind nicht +unterstützt — bei den Tabellengrößen dieser Studie ohne Vorteil. Beispiel: +`=XLOOKUP(B7; A2:A6; C2:C6; "unbekannt")`. **Fehlermodell** (propagiert an abhängige Zellen): `#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein @@ -462,8 +486,9 @@ oben), `#DIV/0!`, Funktion, oder bei „Mit Schleife"-Code eine ungültige FOR-Quelle, mehr als 10.000 Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN`), `#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!` -(Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere -Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen +(Typfehler in Arithmetik — *dokumentierte Erweiterung*), `#N/A` (`XLOOKUP` +ohne Treffer und ohne `WennNichtGefunden` — *dokumentierte Erweiterung*). +Referenz auf leere Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er wird schon beim **Speichern** der Funktion im UI abgelehnt (kein neuer Fehlercode nötig). @@ -568,7 +593,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 298 Tests: +`npm run test` — Vitest, 318 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, @@ -583,6 +608,19 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung, `IF` (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als Bedingung, Fehler in der Bedingung). +- **Leere Argumentstelle** (`;;`) — parst als eigener Knoten, verhält sich + überall wie eine leere Zelle (`SUM(1;;3)` = 4, von `COUNT` nicht + mitgezählt); am Anfang, in der Mitte und am Ende einer Argumentliste, + inkl. `f()` ganz ohne Argumente. +- **XLOOKUP** — exakter Text-/Zahl-Treffer (case-insensitiv, kein Zahl-Text- + Crossmatch), `#N/A` ohne/mit `WennNichtGefunden`, Kurzschluss-Beweis für + `WennNichtGefunden`, `Suchmodus` 1/-1 bei Duplikaten, `Vergleichsmodus` + -1/1 (exakter Treffer gewinnt immer, nächstkleiner/-größer, kein + qualifizierender Kandidat), abgelehnte Modi (`2`, `-2`), ungleich große + Bereiche → `#VALUE!`, falsche Argumentanzahl, Einzelzellen statt Bereich, + übersprungenes optionales Argument (`;;`) erreicht ein späteres. + Workbook-Ebene: Neuberechnung bei Änderung einer Zelle in *entweder* + Bereich, keine Neuberechnung bei unbeteiligten Zellen. - **Datum & Zeit** — `dates.ts`: `dateToSerial`/`timeSerial`-Rundtrip gegen bekannte Werte, Überlauf-Verhalten, `parseDateTimeLiteral` für jede erkannte Form (ISO/Punkt, mit/ohne Uhrzeit) sowie Ablehnung von @@ -738,6 +776,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, automatisches Zellformat — wie jedes andere Formelergebnis auch. `TODAY()`/ `NOW()` sind nicht volatil: sie aktualisieren sich erst bei der nächsten Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit. +- **XLOOKUP** bewusst ohne: Näherungsvergleich (`Vergleichsmodus` -1/1) auf + Text (nur numerisch), Platzhalter-Vergleich (`Vergleichsmodus 2`), binäre + Suchmodi (`Suchmodus 2`/`-2`, würden einen sortierten Bereich voraussetzen, + den diese Engine nicht prüfen kann). Ein übersprungenes optionales + Argument (`;;`) ist eine allgemeine Grammatik-Erweiterung, nicht + XLOOKUP-spezifisch — funktioniert in jeder Funktion. - **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne: Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und diff --git a/src/engine/README.md b/src/engine/README.md index 8ca5807..8fd550e 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -19,7 +19,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten. | `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`). | -| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` ist hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte, kurzschließende Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. | +| `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. | @@ -374,6 +374,61 @@ eine Zahl ist, funktionieren `+`/`-`/Vergleiche darauf automatisch — keine 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. @@ -396,7 +451,8 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf. | `#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). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* | +| `#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. diff --git a/src/engine/__tests__/evaluator.spec.ts b/src/engine/__tests__/evaluator.spec.ts index 3f55bda..0f055f9 100644 --- a/src/engine/__tests__/evaluator.spec.ts +++ b/src/engine/__tests__/evaluator.spec.ts @@ -85,6 +85,11 @@ describe('evaluator functions', () => { 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', () => { @@ -125,6 +130,89 @@ describe('evaluator IF', () => { }); }); +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 }, diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index 9818d7d..3daf67d 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -32,6 +32,8 @@ function sexpr(n: Node): string { // 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'; } } const parse = (s: string) => sexpr(parseFormula(s)); @@ -118,6 +120,15 @@ describe('parser references', () => { 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 sheet qualifier -- "Blatt!Tabelle::Zelle"', () => { diff --git a/src/engine/__tests__/workbook.spec.ts b/src/engine/__tests__/workbook.spec.ts index a4108c6..fbdcb81 100644 --- a/src/engine/__tests__/workbook.spec.ts +++ b/src/engine/__tests__/workbook.spec.ts @@ -283,3 +283,34 @@ describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => { 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); + }); +}); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 8bb2d97..e402cc6 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -105,7 +105,14 @@ function evalScalar(node: Node, host: EngineHost): CellValue { // (see functions.ts's FUNCTIONS.IF comment): only the taken branch may // be computed, so e.g. IF(B1=0; 0; A1/B1) never divides by zero when // B1=0. Special-cased here, before the generic eager-args dispatch. - if (node.name.toUpperCase() === 'IF') return evalIf(node, host); + const upperName = node.name.toUpperCase(); + if (upperName === 'IF') return evalIf(node, host); + // XLOOKUP needs its two range arguments kept separate (not flattened + // into one combined list like every other function gets, see + // functions.ts's FUNCTIONS.XLOOKUP comment) and its if_not_found + // argument evaluated lazily -- both require handling here, before the + // generic eager/flattening dispatch below. + if (upperName === 'XLOOKUP') return evalXLookup(node, host); const fn = getFunction(node.name); if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`); @@ -130,6 +137,14 @@ function evalScalar(node: Node, host: EngineHost): CellValue { case 'blockcall': return evaluateBlockCall(node, host); + + case 'omitted': + // an empty argument slot (`XLOOKUP(x;a;b;;-1)`'s skipped 4th + // argument) evaluates like any other missing value -- same as an + // empty cell. A function that needs to distinguish "omitted" from + // "evaluates to null" checks the raw arg Node's kind directly before + // ever calling evalScalar on it (see evalXLookup below). + return null; } } @@ -153,6 +168,135 @@ function evalIf(node: FuncNode, host: EngineHost): CellValue { return node.args.length === 3 ? evalScalar(node.args[2], host) : 0; } +/** Exact-match equality for XLOOKUP -- deliberately its own thing, not the + * `=` operator (which coerces both sides through `toNumber()` and so can + * never match on text, XLOOKUP's single most common real-world use: "find + * the row where this name/ID matches"). Numbers compare numerically, text + * compares case-insensitively with surrounding whitespace ignored (matches + * this engine's existing case-insensitive conventions -- function/table + * names, `!`/`::` qualifiers). A number is never treated as equal to text + * (no cross-type coercion -- predictable, no "5" == 5 surprises); two + * empty cells are equal to each other. */ +function xlookupEquals(a: CellValue, b: CellValue): boolean { + if (isError(a) || isError(b)) return false; + if (a === null || b === null) return a === b; + if (typeof a === 'number' && typeof b === 'number') return a === b; + if (typeof a === 'string' && typeof b === 'string') { + return a.trim().toLowerCase() === b.trim().toLowerCase(); + } + return false; +} + +/** + * XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; + * [Vergleichsmodus]; [Suchmodus]) -- see functions.ts's FUNCTIONS.XLOOKUP + * comment for why this has to live here instead. `Suchbereich` and + * `Rückgabebereich` are evaluated separately via `evalArg` (which already + * returns one argument's values as their own array -- it's only `case + * 'func'`'s generic dispatch above that flattens every argument together) + * and must come out the same length. + * + * `Vergleichsmodus`: `0` (default) exact match; `-1` exact match or next + * *smaller*; `1` exact match or next *larger* (both numeric-only -- a + * nearest match against text isn't supported, documented scope limit, + * see README.md). `2` (Excel's wildcard mode) is not supported. + * `Suchmodus`: `1` (default) first-to-last; `-1` last-to-first. Excel's + * binary-search modes (`2`/`-2`) are not supported -- pointless at this + * app's table sizes and would require an assumed-sorted precondition this + * engine can't verify. + * + * No match and no `WennNichtGefunden` given -> `#N/A` (a documented + * extension to this engine's original error set, exactly the same + * reasoning `#VALUE!` was added under). `WennNichtGefunden` is evaluated + * lazily -- only when actually needed, same short-circuit principle as + * `IF`'s branches -- so an error inside it never surfaces on a successful + * match. + */ +function evalXLookup(node: FuncNode, host: EngineHost): CellValue { + const args = node.args; + if (args.length < 3 || args.length > 6) { + return new FormulaError('#ERR', 'XLOOKUP expects 3 to 6 arguments'); + } + // An optional argument counts as "given" only if the slot both exists AND + // wasn't left empty (`XLOOKUP(x;a;b;;-1)`'s skipped 4th argument, an + // OmittedNode -- see parser.ts). `args.length` alone can't tell "not + // provided at all" apart from "explicitly skipped to reach a later one". + const given = (i: number) => i < args.length && args[i].kind !== 'omitted'; + + const lookupValue = evalScalar(args[0], host); + if (isError(lookupValue)) return lookupValue; + + const lookupArr = evalArg(args[1], host); + if (isError(lookupArr)) return lookupArr; + const returnArr = evalArg(args[2], host); + if (isError(returnArr)) return returnArr; + if (lookupArr.length !== returnArr.length) { + return new FormulaError( + '#VALUE!', + 'XLOOKUP: Suchbereich und Rückgabebereich müssen gleich viele Zellen haben', + ); + } + for (const v of lookupArr) if (isError(v)) return v; + for (const v of returnArr) if (isError(v)) return v; + + let matchMode = 0; + if (given(4)) { + const mm = toNumber(evalScalar(args[4], host)); + if (isError(mm)) return mm; + if (mm !== -1 && mm !== 0 && mm !== 1) { + return new FormulaError( + '#ERR', + 'XLOOKUP: Vergleichsmodus muss -1, 0 oder 1 sein (2 wird nicht unterstützt)', + ); + } + matchMode = mm; + } + + let searchMode = 1; + if (given(5)) { + const sm = toNumber(evalScalar(args[5], host)); + if (isError(sm)) return sm; + if (sm !== 1 && sm !== -1) { + return new FormulaError( + '#ERR', + 'XLOOKUP: Suchmodus muss 1 oder -1 sein (2/-2 werden nicht unterstützt)', + ); + } + searchMode = sm; + } + + const n = lookupArr.length; + const order = searchMode === -1 ? [...Array(n).keys()].reverse() : [...Array(n).keys()]; + + if (matchMode === 0) { + for (const i of order) { + if (xlookupEquals(lookupValue, lookupArr[i])) return returnArr[i]; + } + } else if (typeof lookupValue === 'number') { + // Nearest-match modes: numeric only. An exact match still wins outright + // wherever it's found; otherwise track the closest qualifying candidate. + let bestIdx = -1; + let bestVal = 0; + for (const i of order) { + const cand = lookupArr[i]; + if (xlookupEquals(lookupValue, cand)) return returnArr[i]; + if (typeof cand !== 'number') continue; + const qualifies = matchMode === -1 ? cand < lookupValue : cand > lookupValue; + if (!qualifies) continue; + const better = + bestIdx === -1 || (matchMode === -1 ? cand > bestVal : cand < bestVal); + if (better) { + bestIdx = i; + bestVal = cand; + } + } + if (bestIdx !== -1) return returnArr[bestIdx]; + } + + if (given(3)) return evalScalar(args[3], host); + return new FormulaError('#N/A', 'XLOOKUP: kein Treffer'); +} + // --------------------------------------------------------------------------- // `kind: 'block'` custom functions: a small tree-walking interpreter for a // BlockCallNode's `block` (see parser.ts's grammar comment and diff --git a/src/engine/functions.ts b/src/engine/functions.ts index 31a5aee..5999b58 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -115,6 +115,25 @@ export const FUNCTIONS: Record = { if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente'); return nowSerial(); }, + + // 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. diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 471c3bb..36eab52 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -29,7 +29,16 @@ * | funcall * | reference ; * - * funcall = IDENT "(" [ expression { ";" expression } ] ")" ; + * 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 @@ -73,7 +82,8 @@ export type Node = | CellNode | RangeNode | ParamNode - | BlockCallNode; + | BlockCallNode + | OmittedNode; export interface NumberNode { kind: 'number'; @@ -129,6 +139,17 @@ 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'; +} // --------------------------------------------------------------------------- // "Block" function bodies (`kind: 'block'` custom functions) -- a small @@ -437,11 +458,16 @@ class Parser { if (this.at('LPAREN')) { this.next(); const args: Node[] = []; + // an argument slot with nothing in it (leading/middle/trailing `;`, + // right up against `;` or `)`) parses as an OmittedNode instead of + // failing -- see OmittedNode's doc comment for why. + const argOrOmitted = (): Node => + this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression(); if (!this.at('RPAREN')) { - args.push(this.expression()); + args.push(argOrOmitted()); while (this.at('SEMICOLON')) { this.next(); - args.push(this.expression()); + args.push(argOrOmitted()); } } this.expect('RPAREN'); diff --git a/src/engine/types.ts b/src/engine/types.ts index 7ea08e1..c821c6e 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -13,7 +13,8 @@ export type ErrorCode = | '#NAME?' // unknown function | '#ERR' // parse error | '#CIRC' // circular reference - | '#VALUE!'; // wrong value type in arithmetic (documented extension) + | '#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', @@ -22,6 +23,7 @@ export const ERROR_CODES: ErrorCode[] = [ '#ERR', '#CIRC', '#VALUE!', + '#N/A', ]; /** A formula error. Instances are immutable and comparable by `code`. */ diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts index 4ad0fff..a09ffc4 100644 --- a/src/engine/userFunctions.ts +++ b/src/engine/userFunctions.ts @@ -94,6 +94,7 @@ function expand( case 'cell': case 'range': case 'param': + case 'omitted': return node; // leaves -- nothing to expand case 'unary': return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) }; @@ -179,6 +180,7 @@ export function substitute(node: Node, bound: ReadonlyMap): Node { case 'string': case 'cell': case 'range': + case 'omitted': return node; case 'unary': return { ...node, operand: substitute(node.operand, bound) }; diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 102cc96..93078dc 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -770,6 +770,13 @@ describe('custom functions (document-level library)', () => { expect(s.doc.customFunctions).toHaveLength(0); }); + it('rejects a name colliding with the built-in XLOOKUP function (case-insensitive)', () => { + const s = fresh(); + const result = s.addCustomFunction('xlookup', ['x'], 'x') as { error: string }; + expect(result.error).toBeTruthy(); + expect(s.doc.customFunctions).toHaveLength(0); + }); + it('rejects a name colliding with another custom function (case-insensitive)', () => { const s = fresh(); s.addCustomFunction('Marge', ['x'], 'x');