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>
149 lines
5.7 KiB
TypeScript
149 lines
5.7 KiB
TypeScript
/**
|
|
* Built-in functions: a plain table { NAME -> fn(values) }.
|
|
*
|
|
* By the time a function runs, the evaluator has already:
|
|
* - resolved every range argument into a flat list of cell values,
|
|
* - flattened scalar arguments into that same list,
|
|
* - checked for errors among the values (an error short-circuits and is
|
|
* returned instead of calling the function).
|
|
*
|
|
* So a function only ever sees `(number | string | null)[]`. Each function
|
|
* decides for itself how to treat text and empty cells. Per spec:
|
|
* - empty cell -> ignored by every aggregate, never counted by COUNT
|
|
* - text -> ignored by the numeric aggregates
|
|
*/
|
|
|
|
import { FormulaError, isError, type CellValue } from './types';
|
|
import { dateToSerial, timeSerial, todaySerial, nowSerial, strictNum } from './dates';
|
|
|
|
export type FunctionFn = (values: CellValue[]) => CellValue;
|
|
|
|
/** Keep only real numbers (finite). */
|
|
function nums(values: CellValue[]): number[] {
|
|
const out: number[] = [];
|
|
for (const v of values) {
|
|
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const FUNCTIONS: Record<string, FunctionFn> = {
|
|
SUM(values) {
|
|
return nums(values).reduce((a, b) => a + b, 0);
|
|
},
|
|
|
|
AVG(values) {
|
|
const n = nums(values);
|
|
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
|
|
return n.reduce((a, b) => a + b, 0) / n.length;
|
|
},
|
|
|
|
MIN(values) {
|
|
const n = nums(values);
|
|
return n.length ? Math.min(...n) : 0;
|
|
},
|
|
|
|
MAX(values) {
|
|
const n = nums(values);
|
|
return n.length ? Math.max(...n) : 0;
|
|
},
|
|
|
|
COUNT(values) {
|
|
return nums(values).length;
|
|
},
|
|
|
|
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
|
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
|
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
|
// division when B1=0). This entry only exists so IF is a registered
|
|
// built-in for hasFunction()'s sake (the store's custom-function name
|
|
// check then correctly refuses to let a user shadow it) and as a
|
|
// defensive, best-effort fallback if this were ever reached directly
|
|
// (values are already-eagerly-computed here, so it can't short-circuit --
|
|
// see evaluator.ts's evalIf() for the real implementation).
|
|
IF(values) {
|
|
if (values.length !== 2 && values.length !== 3) {
|
|
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
|
|
}
|
|
const [cond, thenValue, elseValue] = values;
|
|
if (isError(cond)) return cond;
|
|
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== '';
|
|
if (truthy) return thenValue;
|
|
return values.length === 3 ? elseValue : 0;
|
|
},
|
|
|
|
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
|
|
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
|
|
// same as everywhere else in this engine; a non-numeric text argument is
|
|
// `#VALUE!`.
|
|
DATE(values) {
|
|
if (values.length !== 3) {
|
|
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
|
|
}
|
|
const y = strictNum(values[0]);
|
|
if (isError(y)) return y;
|
|
const mo = strictNum(values[1]);
|
|
if (isError(mo)) return mo;
|
|
const d = strictNum(values[2]);
|
|
if (isError(d)) return d;
|
|
return dateToSerial(y, mo, d);
|
|
},
|
|
|
|
TIME(values) {
|
|
if (values.length !== 3) {
|
|
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
|
|
}
|
|
const h = strictNum(values[0]);
|
|
if (isError(h)) return h;
|
|
const mi = strictNum(values[1]);
|
|
if (isError(mi)) return mi;
|
|
const s = strictNum(values[2]);
|
|
if (isError(s)) return s;
|
|
return timeSerial(h, mi, s);
|
|
},
|
|
|
|
// Not "volatile": this engine only recalculates on precedent change, so a
|
|
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
|
|
// or recalculated, not spontaneously when the clock ticks over (documented
|
|
// limitation, see README.md's "Bekannte Grenzen").
|
|
TODAY(values) {
|
|
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
|
|
return todaySerial();
|
|
},
|
|
|
|
NOW(values) {
|
|
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.
|
|
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
|
|
|
export function hasFunction(name: string): boolean {
|
|
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
|
}
|
|
|
|
export function getFunction(name: string): FunctionFn | undefined {
|
|
return FUNCTIONS[name.toUpperCase()];
|
|
}
|