/** * 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, serialParts, isoWeekNumber, endOfMonthSerial, addMonthsSerial, parseDateTimeLiteral, type SerialParts, } 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; } /** The one truthy convention used everywhere in this engine a condition is * needed (IF's condition -- both the formula function and its real * short-circuit implementation in evaluator.ts's evalIf() --, a "Mit * Schleife" block's IF statement, and AND/OR/NOT below): a number is * truthy unless it's exactly `0`; text is truthy unless empty; an empty * cell (`null`) is never truthy. Exported so evaluator.ts can reuse the * exact same rule instead of duplicating it. */ export function isTruthy(v: CellValue): boolean { return typeof v === 'number' ? v !== 0 : v !== null && v !== ''; } /** Check the argument count and coerce every argument to a number (see * `strictNum`) -- the shared prologue of the numeric date functions. An * optional argument left out (or an empty `;;` slot) comes back as * `undefined`. */ function numArgs( name: string, values: CellValue[], min: number, max: number, ): [number, ...(number | undefined)[]] | FormulaError { if (values.length < min || values.length > max) { const range = min === max ? `${min}` : `${min}–${max}`; return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`); } const out: Array = []; for (let i = 0; i < values.length; i++) { if (i >= min && values[i] === null) { out.push(undefined); continue; } const n = strictNum(values[i]); if (isError(n)) return n; out.push(n); } return out as [number, ...(number | undefined)[]]; } /** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */ function part(name: string, pick: (p: SerialParts) => number): FunctionFn { return (values) => { const a = numArgs(name, values, 1, 1); if (isError(a)) return a; return pick(serialParts(a[0])); }; } function holidaySet(values: CellValue[]): Set { const out = new Set(); for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day); return out; } function isWorkday(day: number, holidays: Set): boolean { return serialParts(day).weekday <= 5 && !holidays.has(day); } /** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or * a number (already a serial). */ function textDateArg(name: string, values: CellValue[]): number | FormulaError { if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`); const v = values[0]; if (typeof v === 'number') return v; const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null; if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`); return hit.serial; } 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; }, COUNT2(values) { return values.length; }, // Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering // as the `&` operator (toText() in evaluator.ts): a number prints plain // (no locale formatting), an empty cell contributes ''. CONCAT(values) { return values.map((v) => (v === null ? '' : String(v))).join(''); }, // 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; if (isTruthy(cond)) return thenValue; return values.length === 3 ? elseValue : 0; }, // Ordinary (non-short-circuit) functions -- like real Excel, EVERY // argument is evaluated regardless of the others (so e.g. // =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) -- // no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range // argument flattens into one truthy check per cell, same as Excel. AND(values) { if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument'); return values.every(isTruthy) ? 1 : 0; }, OR(values) { if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument'); return values.some(isTruthy) ? 1 : 0; }, NOT(values) { if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument'); return isTruthy(values[0]) ? 0 : 1; }, // 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(); }, // ---- date/time parts & calendar arithmetic ---------------------------- // All take date serials (see dates.ts). Where Excel has a US-centric // default, the ISO 8601 convention is the default here: weeks start on // Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks. YEAR: part('YEAR', (p) => p.year), MONTH: part('MONTH', (p) => p.month), DAY: part('DAY', (p) => p.date), HOUR: part('HOUR', (p) => p.hour), MINUTE: part('MINUTE', (p) => p.minute), SECOND: part('SECOND', (p) => p.second), // WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7, // 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ... // Sunday 6. WEEKDAY(values) { const a = numArgs('WEEKDAY', values, 1, 2); if (isError(a)) return a; const iso = serialParts(a[0]).weekday; switch (a[1] ?? 2) { case 1: return (iso % 7) + 1; case 2: return iso; case 3: return iso - 1; default: return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein'); } }, ISOWEEKNUM(values) { const a = numArgs('ISOWEEKNUM', values, 1, 1); if (isError(a)) return a; return isoWeekNumber(a[0]); }, // WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel); // Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the // week containing 1 January as week 1 (Excel's US-style numbering). WEEKNUM(values) { const a = numArgs('WEEKNUM', values, 1, 2); if (isError(a)) return a; const type = a[1] ?? 21; if (type === 21) return isoWeekNumber(a[0]); if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein'); const p = serialParts(a[0]); const jan1 = Math.round(dateToSerial(p.year, 1, 1)); const jan1Iso = serialParts(jan1).weekday; // 1 = Monday const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start return Math.floor((p.day - jan1 + offset) / 7) + 1; }, EDATE(values) { const a = numArgs('EDATE', values, 2, 2); if (isError(a)) return a; return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0)); }, EOMONTH(values) { const a = numArgs('EOMONTH', values, 2, 2); if (isError(a)) return a; return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0)); }, // DAYS(Ende; Start) -- whole calendar days between two dates. DAYS(values) { const a = numArgs('DAYS', values, 2, 2); if (isError(a)) return a; return serialParts(a[0]).day - serialParts(a[1] ?? 0).day; }, // DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/ // days, "MD"/"YM"/"YD" the remainder ignoring months+years/years. DATEDIF(values) { if (values.length !== 3) { return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)'); } const s0 = strictNum(values[0]); if (isError(s0)) return s0; const e0 = strictNum(values[1]); if (isError(e0)) return e0; const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : ''; const s = serialParts(s0); const e = serialParts(e0); if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende'); let months = (e.year - s.year) * 12 + (e.month - s.month); if (e.date < s.date) months -= 1; switch (unit) { case 'D': return e.day - s.day; case 'M': return months; case 'Y': return Math.floor(months / 12); case 'YM': return months % 12; case 'MD': return e.day - addMonthsSerial(s.day, months); case 'YD': { const years = Math.floor(months / 12); return e.day - addMonthsSerial(s.day, years * 12); } default: return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein'); } }, // NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the // span, both ends included, minus any listed holiday on a weekday. NETWORKDAYS(values) { if (values.length < 2) { return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])'); } const s0 = strictNum(values[0]); if (isError(s0)) return s0; const e0 = strictNum(values[1]); if (isError(e0)) return e0; const holidays = holidaySet(values.slice(2)); const a = serialParts(s0).day; const b = serialParts(e0).day; const [lo, hi] = a <= b ? [a, b] : [b, a]; if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß'); let n = 0; for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++; return a <= b ? n : -n; }, // WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days // (Monday-Friday, not a holiday) after (or, negative, before) Start. WORKDAY(values) { if (values.length < 2) { return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])'); } const s0 = strictNum(values[0]); if (isError(s0)) return s0; const n0 = strictNum(values[1]); if (isError(n0)) return n0; let left = Math.trunc(Math.abs(n0)); if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage'); const step = n0 < 0 ? -1 : 1; const holidays = holidaySet(values.slice(2)); let d = serialParts(s0).day; while (left > 0) { d += step; if (isWorkday(d, holidays)) left--; } return d; }, // DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part // of a text in any format a cell entry is recognized in (ISO, 02.09.2026, // 14:30, …). A number (a cell already holding a date) passes through. DATEVALUE(values) { const v = textDateArg('DATEVALUE', values); if (isError(v)) return v; return serialParts(v).day; }, TIMEVALUE(values) { const v = textDateArg('TIMEVALUE', values); if (isError(v)) return v; const p = serialParts(v); return (p.hour * 3600 + p.minute * 60 + p.second) / 86400; }, // 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; // CONCATENATE is Excel's classic name for the same thing as CONCAT. FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT; /** One-line signature hint per built-in, for the formula-autocomplete * dropdown (see store/document.ts's `functionSuggestions()` and * src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept * honest by a test asserting its names exactly match `FUNCTIONS`'s keys, * so a future new built-in without a matching entry here fails loudly * instead of just silently missing from the dropdown. */ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [ { name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' }, { name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' }, { name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' }, { name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' }, { name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' }, { name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' }, { name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' }, { name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' }, { name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' }, { name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' }, { name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' }, { name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' }, { name: 'NOT', hint: 'NOT(Bedingung)' }, { name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' }, { name: 'TIME', hint: 'TIME(Std; Min; Sek)' }, { name: 'TODAY', hint: 'TODAY()' }, { name: 'NOW', hint: 'NOW()' }, { name: 'YEAR', hint: 'YEAR(Datum)' }, { name: 'MONTH', hint: 'MONTH(Datum)' }, { name: 'DAY', hint: 'DAY(Datum)' }, { name: 'HOUR', hint: 'HOUR(Zeit)' }, { name: 'MINUTE', hint: 'MINUTE(Zeit)' }, { name: 'SECOND', hint: 'SECOND(Zeit)' }, { name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' }, { name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' }, { name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' }, { name: 'EDATE', hint: 'EDATE(Datum; Monate)' }, { name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' }, { name: 'DAYS', hint: 'DAYS(Ende; Start)' }, { name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' }, { name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' }, { name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' }, { name: 'DATEVALUE', hint: 'DATEVALUE(Text)' }, { name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' }, { name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' }, ]; 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()]; }