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:
+58
-2
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"', () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`. */
|
||||
|
||||
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user