Files
numolus/src/engine/functions.ts
T
wkhandClaude Sonnet 5 552f1cfae8 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>
2026-09-03 08:21:11 +02:00

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()];
}