- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss- Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle Argumente eager aus). Dabei die bisher dreifach duplizierte Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt. Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen. - Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu genau diesem Fehler aus. - Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle, und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen), wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt, wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/ Ausschneiden), keine AST-Neuserialisierung. 406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
219 lines
8.9 KiB
TypeScript
219 lines
8.9 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;
|
|
}
|
|
|
|
/** 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 !== '';
|
|
}
|
|
|
|
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;
|
|
},
|
|
|
|
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();
|
|
},
|
|
|
|
// 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: '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()];
|
|
}
|