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.
+88
View File
@@ -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 },
+11
View File
@@ -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"', () => {
+31
View File
@@ -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);
});
});
+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
+19
View File
@@ -115,6 +115,25 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
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.
+30 -4
View File
@@ -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');
+3 -1
View File
@@ -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`. */
+2
View File
@@ -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<string, Node>): Node {
case 'string':
case 'cell':
case 'range':
case 'omitted':
return node;
case 'unary':
return { ...node, operand: substitute(node.operand, bound) };