diff --git a/README.md b/README.md index d7b86e3..a3c60e5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt: ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 257 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 265 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -399,11 +399,22 @@ darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die vier Kombinationen. **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), -`MIN`, `MAX`, `COUNT` — plus alle über **„𝑓 Funktionen"** angelegten -benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), -die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen, -Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. -`=SUM(Umsatz!B2:E2; 100; A1)`. +`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)` — plus alle über +**„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe +„Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch +verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`), +Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`. + +`IF` ist die einzige Funktion, die ihre Argumente **nicht** vorab auswertet: +nur die Bedingung wird immer berechnet, danach ausschließlich der tatsächlich +genommene Zweig — der andere Zweig wird gar nicht ausgewertet, ein Fehler +darin (z. B. `=IF(B1=0; 0; A1/B1)`, wenn `B1` `0` ist) schlägt also nie +durch. Die Bedingung nutzt dieselbe Zahl-Semantik wie die Vergleichsoperatoren +`= <> > < >= <=` (Ergebnis `1`/`0`, keinen eigenen Wahrheitstyp): jede +Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B. +`=IF(A1>=100; "groß"; "klein")`. Ohne Sonst-Zweig liefert eine falsche +Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name +kollidiert mit der eingebauten Funktion. **Fehlermodell** (propagiert an abhängige Zellen): `#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein @@ -521,7 +532,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 257 Tests: +`npm run test` — Vitest, 265 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, @@ -533,7 +544,9 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die wie eine Zelle aussehen (`AB12!A1`), unangetastet. - **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias, - Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung. + Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung, + `IF` (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als + Bedingung, Fehler in der Bedingung). - **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus. - **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt diff --git a/src/engine/README.md b/src/engine/README.md index 16e0468..e44e773 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -19,7 +19,7 @@ genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten. | `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. | | `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). | | `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`). | -| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. Eingebaute Namen sind nie überschreibbar. | +| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. `IF` ist hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte, kurzschließende Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. | | `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. | | `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. | | `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. | @@ -300,6 +300,20 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung. - `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`. - `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt. - `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`. +- **`IF(Bedingung; Dann; Sonst?)` ist die einzige Funktion, die nicht eager + ausgewertet wird.** Jede andere Funktion sieht in `functions.ts` bereits + fertig berechnete `CellValue`s (der Evaluator wertet vorher jedes Argument + aus und short-circuited beim ersten Fehler); `IF` wird stattdessen schon in + `evalScalar`s `case 'func':` abgefangen, bevor `functions.ts` je erreicht + wird (`evalIf()`): nur die Bedingung wird immer ausgewertet, danach + ausschließlich der genommene Zweig – der andere Zweig wird gar nicht erst + evaluiert, ein Fehler darin schlägt also nie durch (klassisch: + `=IF(B1=0; 0; A1/B1)`). Ohne Sonst-Zweig liefert eine falsche Bedingung + `0`. `collectRefs()` bleibt für `IF` bewusst generisch/konservativ (läuft + durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe + Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein + benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem + eingebauten `FUNCTIONS.IF`-Eintrag). ## Neuberechnung diff --git a/src/engine/__tests__/evaluator.spec.ts b/src/engine/__tests__/evaluator.spec.ts index 02b21de..3f55bda 100644 --- a/src/engine/__tests__/evaluator.spec.ts +++ b/src/engine/__tests__/evaluator.spec.ts @@ -87,6 +87,44 @@ describe('evaluator functions', () => { }); }); +describe('evaluator IF', () => { + const host = makeHost({ S1: { A1: 8, B1: 0 } }); + + it('picks the then-branch when the condition is truthy', () => { + expect(ev('=IF(1;2;3)', host)).toBe(2); + expect(ev('=IF(A1>5;"groß";"klein")', host)).toBe('groß'); + }); + + it('picks the else-branch when the condition is falsy', () => { + expect(ev('=IF(0;2;3)', host)).toBe(3); + expect(ev('=IF(A1<5;"groß";"klein")', host)).toBe('klein'); + }); + + it('returns 0 for a falsy condition with no else-branch', () => { + expect(ev('=IF(0;2)', host)).toBe(0); + }); + + it('never evaluates the untaken branch (short-circuit)', () => { + expect(ev('=IF(1;2;1/0)', host)).toBe(2); + expect(ev('=IF(B1=0;0;A1/B1)', host)).toBe(0); + }); + + it('propagates an error from the condition itself', () => { + const r = ev('=IF(1/0;2;3)', host); + expect(isError(r) && r.code).toBe('#DIV/0!'); + }); + + it('wrong number of arguments -> #ERR', () => { + expect(isError(ev('=IF(1)', host))).toBe(true); + expect(isError(ev('=IF(1;2;3;4)', host))).toBe(true); + }); + + it('works with comparison operators as the condition', () => { + expect(ev('=IF(A1>=8;1;0)', host)).toBe(1); + expect(ev('=IF(A1<>8;1;0)', host)).toBe(0); + }); +}); + describe('evaluator cross-sheet & errors', () => { const host = makeHost({ S1: { A1: 3 }, diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 44a0a2a..8bb2d97 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -27,7 +27,7 @@ * ordinary function call this can't be resolved any earlier. */ -import type { BlockCallNode, BlockNode, CellNode, Node, Statement } from './parser'; +import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser'; import { getFunction } from './functions'; import { expandRange } from './refs'; import { substitute } from './userFunctions'; @@ -101,6 +101,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue { return new FormulaError('#VALUE!', 'range used where a value is expected'); case 'func': { + // IF is the one built-in that must NOT evaluate its arguments eagerly + // (see functions.ts's FUNCTIONS.IF comment): only the taken branch may + // be computed, so e.g. IF(B1=0; 0; A1/B1) never divides by zero when + // B1=0. Special-cased here, before the generic eager-args dispatch. + if (node.name.toUpperCase() === 'IF') return evalIf(node, host); + const fn = getFunction(node.name); if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`); const values: CellValue[] = []; @@ -127,6 +133,26 @@ function evalScalar(node: Node, host: EngineHost): CellValue { } } +/** IF(cond; then; else?) -- the real, short-circuiting implementation (see + * the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF + * comment). Only `node.args[0]` is always evaluated; whichever of the + * other two branches is taken is evaluated next, and the other is never + * touched at all -- so an error (or, in a block-mode call, any other side + * effect) in the untaken branch never surfaces. Same truthy convention as + * everywhere else in this engine (`execStatement`'s `ifstmt` case, above): + * a number is truthy unless it's exactly 0; text/refs are truthy unless + * empty. */ +function evalIf(node: FuncNode, host: EngineHost): CellValue { + if (node.args.length !== 2 && node.args.length !== 3) { + return new FormulaError('#ERR', 'IF expects 2 or 3 arguments'); + } + const cond = evalScalar(node.args[0], host); + if (isError(cond)) return cond; + const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== ''; + if (truthy) return evalScalar(node.args[1], host); + return node.args.length === 3 ? evalScalar(node.args[2], host) : 0; +} + // --------------------------------------------------------------------------- // `kind: 'block'` custom functions: a small tree-walking interpreter for a // BlockCallNode's `block` (see parser.ts's grammar comment and diff --git a/src/engine/functions.ts b/src/engine/functions.ts index 5466de9..83e9254 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -13,7 +13,7 @@ * - text -> ignored by the numeric aggregates */ -import { FormulaError, type CellValue } from './types'; +import { FormulaError, isError, type CellValue } from './types'; export type FunctionFn = (values: CellValue[]) => CellValue; @@ -50,6 +50,26 @@ export const FUNCTIONS: Record = { 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; + }, }; // AVERAGE is an alias of AVG. diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 6066413..7dcea4d 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -721,6 +721,13 @@ describe('custom functions (document-level library)', () => { expect(s.doc.customFunctions).toHaveLength(0); }); + it('rejects a name colliding with the built-in IF function (case-insensitive)', () => { + const s = fresh(); + const result = s.addCustomFunction('if', ['x'], 'x') as { error: string }; + expect(result.error).toBeTruthy(); + expect(s.doc.customFunctions).toHaveLength(0); + }); + it('rejects a name colliding with another custom function (case-insensitive)', () => { const s = fresh(); s.addCustomFunction('Marge', ['x'], 'x');