XLOOKUP als Formel, plus überspringbare optionale Argumente (;;)

Neue eingebaute Funktion XLOOKUP(Suchwert; Suchbereich; Rückgabebereich;
[WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]).

- Grammatik-Erweiterung (allgemein, nicht XLOOKUP-spezifisch): eine leere
  Argumentstelle zwischen zwei ";" (oder vor der schließenden ")") parst
  jetzt als neuer OmittedNode statt eines Parse-Fehlers -- Excels
  "dieses optionale Argument auslassen"-Idiom, z. B.
  XLOOKUP(x;a;b;;-1). Wertet überall wie eine leere Zelle aus (null);
  expand()/substitute() (userFunctions.ts) behandeln ihn als Blatt,
  collectRefs() ignoriert ihn stillschweigend.
- evaluator.ts: XLOOKUP wird -- wie IF -- vor dem generischen eager/
  flatten-Dispatch abgefangen (evalXLookup()), aus zwei Gründen: (1)
  WennNichtGefunden muss lazy ausgewertet werden (Kurzschluss wie bei
  IF), (2) Suchbereich/Rückgabebereich dürfen nicht in eine gemeinsame
  flache Werteliste geraten -- der generische Pfad würde die Grenze
  zwischen beiden Bereichen zerstören. Eigene xlookupEquals()
  (case-insensitiv/getrimmt für Text, kein Zahl-Text-Crossmatch) statt
  des `=`-Operators, der über toNumber() koerziert und nie auf Text
  träfe. Vergleichsmodus -1/1 (exakt/nächstkleiner/-größer, numerisch),
  Suchmodus 1/-1 (Richtung); Excels Platzhalter- und Binärsuch-Modi
  bewusst nicht unterstützt.
- functions.ts: Platzhalter-Eintrag für Namensreservierung (analog IF).
- types.ts: neuer Fehlercode #N/A (kein Treffer, kein
  WennNichtGefunden) -- dokumentierte Erweiterung, wie zuvor #VALUE!.
- collectRefs() brauchte keine Änderung -- sein generischer case 'func'
  läuft schon konservativ durch alle Argumente.

Tests: 22 neue (parser.spec.ts: OmittedNode leading/middle/trailing;
evaluator.spec.ts: SUM(1;;3)-Smoke plus 15 XLOOKUP-Fälle; workbook.spec.ts:
Neuberechnung bei Änderung in Such- ODER Rückgabebereich, keine bei
unbeteiligten Zellen; document.spec.ts: Namenskollision). 298 -> 318
Tests, alle grün. vue-tsc/eslint/build sauber. Live per Playwright
verifiziert (Treffer, #N/A, WennNichtGefunden, Kurzschluss-Beweis,
übersprungenes Argument für Vergleichsmodus, Neuberechnung nach Umbenennen
einer Suchbereich-Zelle inkl. nachträglich ausgelöstem #DIV/0! im vorher
nicht ausgewerteten WennNichtGefunden-Zweig).

READMEs aktualisiert (Funktionen, Fehlermodell, neue Abschnitte "Leere
Argumentstelle" und "Nachschlagen (XLOOKUP)" in engine/README.md,
Bekannte Grenzen, Testzahl; EBNF-Grammatik in parser.ts kommentiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-03 08:21:11 +02:00
co-authored by Claude Sonnet 5
parent af01aa5e53
commit 552f1cfae8
11 changed files with 451 additions and 21 deletions
+58 -2
View File
@@ -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.