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:
+145
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user