/** * 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 = { 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()]; }