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
+145 -1
View File
@@ -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