From aef8f1349c15c6804c0c8af9d0981e708c572137 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Tue, 8 Sep 2026 22:34:33 +0200 Subject: [PATCH] =?UTF-8?q?AND/OR/NOT,=20Fehler-Literale=20und=20Zeilen/Sp?= =?UTF-8?q?alten=20einf=C3=BCgen/l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 108 ++++++- src/components/TableGrid.vue | 33 ++ src/engine/README.md | 24 ++ src/engine/__tests__/evaluator.spec.ts | 68 +++++ src/engine/__tests__/lexer.spec.ts | 23 ++ src/engine/__tests__/parser.spec.ts | 19 ++ src/engine/__tests__/userFunctions.spec.ts | 15 + src/engine/evaluator.ts | 18 +- src/engine/functions.ts | 37 ++- src/engine/lexer.ts | 22 ++ src/engine/parser.ts | 23 ++ src/engine/userFunctions.ts | 2 + src/store/__tests__/document.spec.ts | 230 ++++++++++++++ src/store/document.ts | 337 +++++++++++++++++++++ 14 files changed, 937 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 22b6d33..fc63f9c 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,41 @@ Beispiel mit. ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu getippten `:`-Bereichsangabe kombiniert werden. +### Zeilen & Spalten einfügen/löschen + +Rechtsklick auf einen **Zeilen-** oder **Spaltenkopf** öffnet ein Kontextmenü +mit **„Zeile oberhalb/unterhalb einfügen"**, **„Zeile löschen"** (analog für +Spalten) — anders als „Zeile/Spalte hinzufügen" (die bestehenden Buttons/ +Menüpunkte, die nur am **Rand** wachsen/schrumpfen, reines Resizing) wird +hier **mitten im Gitter** eingefügt/gelöscht: Zellinhalte, Formatierungen und +Zeilenhöhen-/Spaltenbreiten-Overrides ab der betroffenen Zeile/Spalte +verschieben sich, und **jede Formel im gesamten Dokument**, die eine +Referenz auf die verschobene Tabelle enthält — nicht nur innerhalb der +bearbeiteten Tabelle selbst, auch über `Tabelle!Zelle`/ +`Blatt!Tabelle::Zelle`-Qualifier aus anderen Tabellen — wird automatisch +mitgezogen: + +- Ein Bereich, der über die Einfüge-/Löschstelle hinausragt, **wächst bzw. + schrumpft** entsprechend (`=SUM(B2:B6)` bleibt beim Einfügen einer Zeile + mittendrin ein gültiger, nur größerer Bereich) — genau wie in Excel/Numbers. +- Eine Referenz, die **genau** auf die gelöschte Zeile/Spalte zeigt (eine + Einzelzelle, oder ein Bereich, der komplett darin lag), wird zu einem + echten **`#REF`**-Literal *in der Formel selbst* (z. B. `=B2+1` wird zu + `=#REF+1`) — dasselbe, was Excel inline anzeigt. `#REF` ist dabei keine + Notation, sondern ein echtes Grammatik-Element (`ErrorLiteralNode`, s. + „Formelgrammatik" unten): jeder der sieben Fehlercodes (`#REF`, `#DIV/0!`, + `#NAME?`, `#ERR`, `#CIRC`, `#VALUE!`, `#N/A`) lässt sich jetzt auch direkt + in eine Formel **tippen**, z. B. `=IF(A1=0;#N/A;A1)`. +- `$`-gesperrte Referenzen (`$B$5`) verschieben sich **trotzdem** — anders + als beim Kopieren ist `$` hier irrelevant: die referenzierte Zelle hat sich + strukturell bewegt, unabhängig davon, ob ihr Bezug gesperrt war. +- Eine Referenz **vor** der Einfüge-/Löschstelle bleibt **byte-identisch** + stehen (auch Groß-/Kleinschreibung, Leerzeichen) — nur das Notwendige wird + angefasst. +- Die aktuelle **Auswahl** wandert mit, falls sie auf der bearbeiteten + Tabelle liegt. +- **Undo/Redo-fähig** wie jede andere Änderung. + ### Zell-Formatierung Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im @@ -457,7 +492,7 @@ unary = ("+" | "-") unary power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet stärker als unäres Minus, d.h. -2^2 = -(2^2) = -4 *) -primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ; +primary = NUMBER | STRING | ERRORLIT | "(" expression ")" | funcall | reference | namedref ; funcall = IDENT "(" [ expression { ";" expression } ] ")" ; @@ -484,6 +519,11 @@ sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt, IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ; QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *) CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; +ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" | + "#VALUE!" | "#N/A" ; (* ein Fehlercode, direkt + getippt oder von einem + Zeilen/Spalten-Löschen + erzeugt, s. u. *) ``` Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < @@ -518,6 +558,17 @@ harter Parse-Fehler wie zuvor (s. „Benannte Bereiche"s „Bewusst ohne"). Ein *qualifizierter* nicht-zellförmiger Bezeichner (`Tabelle!Foo`) ist immer ein Parse-Fehler — ein benannter Bereich ist nie qualifiziert. +Ein **Fehlercode lässt sich auch direkt in eine Formel tippen**, z. B. +`=IF(A1=0;#N/A;A1)` oder `=1+#REF`. Wertet unbedingt zu genau diesem Fehler +aus, wie eine Zahl- oder Text-Konstante. Alle sieben Codes sind erlaubt: +`#REF`, `#DIV/0!`, `#NAME?`, `#ERR`, `#CIRC`, `#VALUE!`, `#N/A` — **ohne** +das in Excel übliche `!` am Ende von `#REF!`, da diese Engine ihren eigenen +Fehlercode konsequent ohne `!` schreibt (`#REF`, nicht `#REF!`; siehe +Fehlermodell unten). Genau das erzeugt auch ein **Zeilen-/Spalten-Löschen** +automatisch für eine dadurch ungültig gewordene Referenz (s. „Zeilen & +Spalten einfügen/löschen" oben) — die Formel zeigt danach z. B. `=#REF+1` +statt einer stillen Fehladressierung. + Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::` sind dabei austauschbar und **case-/whitespace-tolerant**: `Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`. @@ -565,8 +616,9 @@ das Dropdown, ohne die Bearbeitung selbst abzubrechen. **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), `MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann; -Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, -`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; +Sonst?)`, `AND(Bedingung1; Bedingung2; …)`, `OR(Bedingung1; Bedingung2; …)`, +`NOT(Bedingung)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, +`NOW()`, `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch verhalten. Argumente gemischt: @@ -588,6 +640,18 @@ Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B. Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name kollidiert mit der eingebauten Funktion. +**`AND`/`OR`/`NOT`** verknüpfen mehrere Bedingungen — z. B. +`=IF(AND(A1>0; A1<10); "im Bereich"; "außerhalb")`, da ein einzelner +Vergleich (`= <> > < >= <=`) nicht verkettbar ist (`1 0; x < schwelle) THEN … END`. + **Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz @@ -872,6 +936,11 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, - **`&`-Verkettung** — Präzedenz zwischen `+ -` und einem Vergleich, links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt `''` bei, Fehler propagieren; `CONCAT`/`CONCATENATE` als Funktions-Äquivalent. +- **`AND`/`OR`/`NOT`** — korrekte Wahrheitstabellen, Bereichsargument (jede + Zelle einzeln), **kein** Kurzschluss (ein Fehler in einem späteren + Argument schlägt durch, selbst wenn das Ergebnis schon feststeht, wie in + echtem Excel), Komposition innerhalb eines `IF` und innerhalb einer + „Mit Schleife"-`IF`-Bedingung, Arity-Fehler → `#ERR`. - **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching, Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle), @@ -882,6 +951,18 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, `FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes Argument), harter Parse-Fehler statt Fallback innerhalb eines Funktionskörpers selbst. +- **Fehler-Literale** (`ErrorLiteralNode`) — alle sieben Codes tokenisieren + als ein Token, Komposition mit Operatoren/Funktionen, Fehlerpropagation + wie bei jedem anderen Fehlerwert. +- **Zeilen/Spalten einfügen/löschen** — Datenverschiebung (Zellen, Formate, + Zeilenhöhen/Spaltenbreiten) innerhalb der Tabelle, dokumentweites + Umschreiben von Formeln (auch über `Tabelle!Zelle`- und + `Blatt!Tabelle::Zelle`-Qualifier aus *anderen* Tabellen), Wachsen/ + Schrumpfen eines Bereichs, der die Stelle überspannt, Kollaps zu `#REF` + bei einer exakt getroffenen Einzelzelle/einem vollständig gelöschten + Bereich, `$`-Sperren bleiben wirkungslos (anders als beim Kopieren), + unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen + Löschen der letzten verbleibenden Zeile/Spalte. - **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein `FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF` @@ -940,8 +1021,13 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Bekannte Grenzen -- **Zeilen/Spalten nur am Rand.** Einfügen/Löschen *innerhalb* eines Gitters (mit - automatischem Umschreiben relativer Bezüge) ist bewusst nicht implementiert. +- **Zeilen/Spalten einfügen/löschen** (s. „Zeilen & Spalten einfügen/löschen" + oben) bewusst ohne: Mehrfachauswahl (immer genau eine Zeile/Spalte pro + Aufruf), automatisches Nachziehen von `CellFormat`-Regeln über die + Grenze hinaus (eine neue Zeile ist immer unformatiert), und ein + `OFFSET`/`INDEX`-Äquivalent — ein `#REF`, das aus einer gelöschten Zeile + entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste + Zelle"-Heuristik. - **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der @@ -985,12 +1071,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei reiner Cursor-Bewegung ohne Tippen. - **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`, - nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte - Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), - echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes - sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger - Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`- - Bedingungen (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben). + nur ein Vergleich pro Ausdruck — `AND`/`OR` lösen das aber, s. u.), + `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes + Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar). + Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind + dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-Bedingungen + (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben). - **Keine Positions-Adressierung zwischen zwei Bereichen** (kein `OFFSET`/`INDEX`-Äquivalent) — eine `FOR`-Schleife kann nur den Bereich erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index a1bb28d..357349f 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -952,13 +952,44 @@ function onKeydown(e: KeyboardEvent) { // ---- context menu ----------------------------------------- const menu = ref({ x: 0, y: 0, open: false }); +/** Which row/col header (if any) the menu was opened on -- drives whether + * it shows the row/col insert-delete items (Excel-style, only from a + * header right-click) or the ordinary cell menu. */ +const menuCtx = ref<{ row: number | null; col: number | null }>({ row: null, col: null }); function onContextMenu(e: MouseEvent) { + const target = e.target as HTMLElement | null; + const rowHeadEl = target?.closest?.('th[data-rowhead]') as HTMLElement | null; + const colHeadEl = target?.closest?.('th[data-colhead]') as HTMLElement | null; + menuCtx.value = { + row: rowHeadEl ? Number(rowHeadEl.dataset.rowhead) : null, + col: colHeadEl ? Number(colHeadEl.dataset.colhead) : null, + }; const hit = cellFromEvent(e); // keep a multi-selection if the click landed inside it; otherwise select the cell if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r); menu.value = { x: e.clientX, y: e.clientY, open: true }; } const menuItems = computed(() => { + const t = table.value; + const ctx = menuCtx.value; + if (t && ctx.row !== null) { + const r = ctx.row; + const items = [ + { label: 'Zeile oberhalb einfügen', action: () => store.insertRow(props.tableId, r) }, + { label: 'Zeile unterhalb einfügen', action: () => store.insertRow(props.tableId, r + 1) }, + ]; + if (t.rows > 1) items.push({ label: 'Zeile löschen', action: () => store.deleteRow(props.tableId, r) }); + return items; + } + if (t && ctx.col !== null) { + const c = ctx.col; + const items = [ + { label: 'Spalte links einfügen', action: () => store.insertColumn(props.tableId, c) }, + { label: 'Spalte rechts einfügen', action: () => store.insertColumn(props.tableId, c + 1) }, + ]; + if (t.cols > 1) items.push({ label: 'Spalte löschen', action: () => store.deleteColumn(props.tableId, c) }); + return items; + } const items = [ { label: 'Kopieren', action: () => store.copySelection() }, { label: 'Ausschneiden', action: () => store.cutSelection() }, @@ -1061,6 +1092,7 @@ defineExpose({ v-for="c in table.cols" :key="'h' + (c - 1)" class="col-head" + :data-colhead="c - 1" :class="{ hot: colHot(c - 1), 'freeze-edge-right': c - 1 === frozenCols - 1 }" :style="c - 1 < frozenCols ? { left: colLeft(c - 1) + 'px', zIndex: '7' } : {}" @click="selectColumn(c - 1, $event)" @@ -1074,6 +1106,7 @@ defineExpose({ > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`. @@ -417,6 +421,16 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung. Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem eingebauten `FUNCTIONS.IF`-Eintrag). +- **`AND`/`OR`/`NOT` brauchen dagegen keine Sonderbehandlung** — anders als + `IF`/`XLOOKUP` sind es ganz normale `functions.ts`-Einträge, ohne + `evaluator.ts`-Kurzschluss: `AND`/`OR` werten (wie in echtem Excel) **alle** + Argumente eager aus, ein Fehler in irgendeinem Argument propagiert also + immer, egal ob das logische Ergebnis schon feststeht (`=AND(1=2; 1/0)` → + `#DIV/0!`, nicht `0`). `functions.ts#isTruthy()` ist die eine + Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung + gebraucht wird – `evalIf()` und `execStatement`s `ifstmt`-Fall importieren + sie jetzt aus `functions.ts`, statt sie (wie vor diesem Feature) je einmal + inline zu duplizieren. ## Datum & Zeit @@ -558,3 +572,13 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf. Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern, `Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle. + +Jeder dieser sieben Codes ist außerdem als **`ErrorLiteralNode`** direkt in +eine Formel tippbar (`=#REF`, `=IF(A1=0;#N/A;A1)`, …) – der Lexer erkennt +ihn als eigenes `ERRORLIT`-Token (längster Treffer zuerst, s. o. EBNF), +`evalScalar`s `case 'errorliteral':` gibt unbedingt `new FormulaError(code)` +zurück. `store/document.ts`s Zeilen-/Spalten-Löschen ist der eine Ort, der +selbst *Formeltext* mit `#REF`-Literalen erzeugt (für eine dadurch +kollabierte Referenz) – ansonsten ist es reine Nutzer-Eingabe, wie eine +Zahl- oder Text-Konstante. Kein `!` am Ende (`#REF`, nicht Excels `#REF!`) +– derselbe Code, den diese Engine selbst überall sonst produziert. diff --git a/src/engine/__tests__/evaluator.spec.ts b/src/engine/__tests__/evaluator.spec.ts index 8850e03..650e1b7 100644 --- a/src/engine/__tests__/evaluator.spec.ts +++ b/src/engine/__tests__/evaluator.spec.ts @@ -136,6 +136,74 @@ describe('evaluator IF', () => { }); }); +describe('evaluator AND/OR/NOT', () => { + const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } }); + + it('AND is true only when every argument is truthy', () => { + expect(ev('=AND(1;1;1)', host)).toBe(1); + expect(ev('=AND(1;0;1)', host)).toBe(0); + expect(ev('=AND(A1>0;A1<10)', host)).toBe(1); + expect(ev('=AND(A1>0;A1<5)', host)).toBe(0); + }); + + it('OR is true when at least one argument is truthy', () => { + expect(ev('=OR(0;0;1)', host)).toBe(1); + expect(ev('=OR(0;0;0)', host)).toBe(0); + }); + + it('NOT inverts a single condition', () => { + expect(ev('=NOT(1)', host)).toBe(0); + expect(ev('=NOT(0)', host)).toBe(1); + expect(ev('=NOT(B1=0)', host)).toBe(0); + }); + + it('a range argument checks every cell (Excel behaviour)', () => { + const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } }); + expect(ev('=AND(A1:A3)', h)).toBe(0); + expect(ev('=OR(A1:A3)', h)).toBe(1); + }); + + it('empty cell and non-empty text follow the shared truthy convention', () => { + expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy + expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy + }); + + it('composes inside IF, unlike a bare chained comparison', () => { + expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok'); + }); + + it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => { + expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true); + expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true); + }); + + it('wrong arity -> #ERR', () => { + expect(isError(ev('=AND()', host))).toBe(true); + expect(isError(ev('=NOT(1;2)', host))).toBe(true); + expect(isError(ev('=NOT()', host))).toBe(true); + }); +}); + +describe('evaluator error literals (ErrorLiteralNode)', () => { + const host = makeHost({ S1: {} }); + + it('evaluates directly to that FormulaError', () => { + const r = ev('=#REF', host); + expect(isError(r) && r.code).toBe('#REF'); + }); + + it('propagates through arithmetic like any other error', () => { + const r = ev('=1+#DIV/0!', host); + expect(isError(r) && r.code).toBe('#DIV/0!'); + }); + + it('usable as an IF branch, e.g. a deliberate #N/A', () => { + expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5); + const r = ev('=IF(1=1;#N/A;5)', host); + expect(isError(r) && r.code).toBe('#N/A'); + }); +}); + describe('evaluator comparisons', () => { const host = makeHost({ S1: { A1: 'Nord', B1: 5 } }); diff --git a/src/engine/__tests__/lexer.spec.ts b/src/engine/__tests__/lexer.spec.ts index 2c3a53b..a70d420 100644 --- a/src/engine/__tests__/lexer.spec.ts +++ b/src/engine/__tests__/lexer.spec.ts @@ -89,4 +89,27 @@ describe('lexer', () => { expect(types('A1B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']); }); + + it('tokenizes every error code as one ERRORLIT token', () => { + expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']); + expect(values('#REF')).toEqual(['#REF', '']); + expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']); + expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']); + expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']); + expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']); + expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']); + expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']); + }); + + it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => { + expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']); + // "#REF" matches greedily -- a trailing "!" some might expect (Excel + // spells it "#REF!") is its own BANG token, not part of the literal; + // this engine's own error code is "#REF" without "!" (see types.ts). + expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']); + }); + + it('throws on an unrecognized "#..." sequence', () => { + expect(() => tokenize('#FOO')).toThrow(); + }); }); diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index 882f17c..6a7e8ea 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -16,6 +16,8 @@ function sexpr(n: Node): string { return String(n.value); case 'string': return JSON.stringify(n.value); + case 'errorliteral': + return n.code; case 'unary': return `(${n.op} ${sexpr(n.operand)})`; case 'binary': @@ -242,6 +244,23 @@ describe('parser named-range references (NamedRefNode)', () => { }); }); +describe('parser error literals (ErrorLiteralNode)', () => { + it('every error code parses as a leaf', () => { + expect(parse('=#REF')).toBe('#REF'); + expect(parse('=#DIV/0!')).toBe('#DIV/0!'); + expect(parse('=#NAME?')).toBe('#NAME?'); + expect(parse('=#ERR')).toBe('#ERR'); + expect(parse('=#CIRC')).toBe('#CIRC'); + expect(parse('=#VALUE!')).toBe('#VALUE!'); + expect(parse('=#N/A')).toBe('#N/A'); + }); + + it('composes with operators/functions like any other literal', () => { + expect(parse('=1+#REF')).toBe('(+ 1 #REF)'); + expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)'); + }); +}); + describe('parser comparison operators (available in every formula, not just block IF conditions)', () => { it('parses all six comparison operators', () => { expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))'); diff --git a/src/engine/__tests__/userFunctions.spec.ts b/src/engine/__tests__/userFunctions.spec.ts index c382510..8ca03e1 100644 --- a/src/engine/__tests__/userFunctions.spec.ts +++ b/src/engine/__tests__/userFunctions.spec.ts @@ -395,6 +395,21 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f expect(val(wb, 's-umsatz', 'B9')).toBe(0); }); + it('AND/OR compose a compound condition inside an IF, same as any other function call', () => { + const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' }); + const inRange = def({ + id: 'f1', + name: 'INRANGE', + params: ['x'], + kind: 'block', + body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END', + }); + const wb = new Workbook([umsatz], [inRange]); + expect(val(wb, 's-umsatz', 'B9')).toBe(1); + wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range + expect(val(wb, 's-umsatz', 'B9')).toBe(0); + }); + it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => { // A3 is the first cell > 100; A4 must never affect the result const umsatz = sheet('s-umsatz', 'Umsatz', { diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index f859b3b..b3c22f2 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -40,7 +40,7 @@ */ import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; -import { getFunction } from './functions'; +import { getFunction, isTruthy } from './functions'; import { expandRange } from './refs'; import { substitute } from './userFunctions'; import { FormulaError, isError, type CellValue, type EngineHost } from './types'; @@ -55,6 +55,8 @@ function evalScalar(node: Node, host: EngineHost): CellValue { return node.value; case 'string': return node.value; + case 'errorliteral': + return new FormulaError(node.code); case 'unary': { const v = toNumber(evalScalar(node.operand, host)); @@ -173,18 +175,17 @@ function evalScalar(node: Node, host: EngineHost): CellValue { * 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. */ + * effect) in the untaken branch never surfaces. Truthy via functions.ts's + * `isTruthy()` -- the one convention shared everywhere in this engine a + * condition is needed (also `execStatement`'s `ifstmt` case, below, and + * `AND`/`OR`/`NOT`). */ 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); + if (isTruthy(cond)) return evalScalar(node.args[1], host); return node.args.length === 3 ? evalScalar(node.args[2], host) : 0; } @@ -403,8 +404,7 @@ function execStatement( case 'ifstmt': { const c = evalInEnv(stmt.cond, env, host); if (isError(c)) throw new ReturnSignal(c); - const truthy = typeof c === 'number' ? c !== 0 : c !== null && c !== ''; - if (truthy) execBlock(stmt.then, env, host, budget); + if (isTruthy(c)) execBlock(stmt.then, env, host, budget); else if (stmt.else) execBlock(stmt.else, env, host, budget); return; } diff --git a/src/engine/functions.ts b/src/engine/functions.ts index a19b892..b660d82 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -27,6 +27,17 @@ function nums(values: CellValue[]): number[] { 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 = { SUM(values) { return nums(values).reduce((a, b) => a + b, 0); @@ -78,11 +89,30 @@ export const FUNCTIONS: Record = { } const [cond, thenValue, elseValue] = values; if (isError(cond)) return cond; - const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== ''; - if (truthy) return thenValue; + 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 @@ -169,6 +199,9 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = { 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()' }, diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index 1214837..c1d51fb 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -8,11 +8,14 @@ * them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell). */ +import { ERROR_CODES } from './types'; + export type TokenType = | 'NUMBER' | 'STRING' | 'IDENT' // bare word: A1, SUM, Region, Q1 ... | 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes + | 'ERRORLIT' // a literal error code, e.g. "#REF" or "#DIV/0!" -- see ERROR_CODES | 'PLUS' | 'MINUS' | 'STAR' @@ -74,6 +77,10 @@ function isWordPart(ch: string): boolean { return isWordStart(ch) || isDigit(ch); } +/** Longest-first, so a prefix of another code (none collide today, but this + * stays correct if one ever did) never wins the match by accident. */ +const ERROR_CODES_BY_LENGTH = [...ERROR_CODES].sort((a, b) => b.length - a.length); + export function tokenize(src: string): Token[] { const tokens: Token[] = []; let i = 0; @@ -182,6 +189,21 @@ export function tokenize(src: string): Token[] { continue; } + // an error-code literal, e.g. "#REF" or "#DIV/0!" -- lets a formula + // literally contain/produce one, same as typing it in Excel; also how a + // structural row/col delete marks a now-broken reference in formula text + // (see store/document.ts's row/col delete). Recognized as one token + // regardless of what follows (a subsequent "!"/"?" is already consumed + // as part of the matched code, never re-tokenized as BANG etc.). + if (ch === '#') { + const code = ERROR_CODES_BY_LENGTH.find((c) => src.startsWith(c, i)); + if (code) { + tokens.push({ type: 'ERRORLIT', value: code, pos: i }); + i += code.length; + continue; + } + } + // single-character punctuation / operators const t = SINGLE[ch]; if (t) { diff --git a/src/engine/parser.ts b/src/engine/parser.ts index d29d942..aa36319 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -31,6 +31,7 @@ * -2^2 = -(2^2) = -4 *) * primary = NUMBER * | STRING + * | ERRORLIT * | "(" expression ")" * | funcall * | reference ; @@ -63,6 +64,12 @@ * (backward-compatible -- * see reference() below). *) * CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; + * ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" | + * "#VALUE!" | "#N/A" ; (* a literal error code, + * typed directly or + * produced by a + * structural row/col + * delete *) * * Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^ * --------------------------------------------------------------------------- @@ -84,10 +91,12 @@ import { tokenize, type Token, type TokenType, LexError } from './lexer'; import { looksLikeCell, parseA1 } from './refs'; +import type { ErrorCode } from './types'; export type Node = | NumberNode | StringNode + | ErrorLiteralNode | BinaryNode | UnaryNode | FuncNode @@ -106,6 +115,16 @@ export interface StringNode { kind: 'string'; value: string; } +/** A literal error code typed directly into a formula, e.g. `=#REF!+1` or + * `=IF(A1=0;#N/A;A1)` -- same as typing one in Excel. Evaluates directly to + * that `FormulaError`, unconditionally (it's not the *result* of anything, + * just a value like a number or string literal). Also how a structural + * row/col delete represents a now-broken reference in stored formula text + * (see store/document.ts). */ +export interface ErrorLiteralNode { + kind: 'errorliteral'; + code: ErrorCode; +} export interface BinaryNode { kind: 'binary'; op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<='; @@ -487,6 +506,10 @@ class Parser { this.next(); return { kind: 'string', value: t.value }; } + if (t.type === 'ERRORLIT') { + this.next(); + return { kind: 'errorliteral', code: t.value as ErrorCode }; + } if (t.type === 'LPAREN') { this.next(); const inner = this.expression(); diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts index 5ca13a4..3b41f4e 100644 --- a/src/engine/userFunctions.ts +++ b/src/engine/userFunctions.ts @@ -91,6 +91,7 @@ function expand( switch (node.kind) { case 'number': case 'string': + case 'errorliteral': case 'cell': case 'range': case 'namedref': @@ -179,6 +180,7 @@ export function substitute(node: Node, bound: ReadonlyMap): Node { return bound.get(node.name.toUpperCase()) ?? node; case 'number': case 'string': + case 'errorliteral': case 'cell': case 'range': case 'namedref': diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 23c5c4e..46066bd 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -1003,6 +1003,236 @@ describe('named ranges (document-level names for a fixed cell/range)', () => { }); }); +describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge growth)', () => { + const raw = (s: DocumentStore, table: string, a1: string) => { + const { col, row } = coords(a1); + return s.cellInput(tableId(s, table), col, row); + }; + + it('insertRow shifts cell data down from the insertion point, leaves earlier rows alone', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'A1', 'x'); + setCell(s, name, 'A2', 'y'); + s.insertRow(id, 1); + expect(val(s, name, 'A1')).toBe('x'); // row 0, before the insertion point -- untouched + expect(val(s, name, 'A2')).toBeNull(); // new empty row + expect(val(s, name, 'A3')).toBe('y'); // shifted down + expect(s.tableById(id)!.rows).toBe(13); // default 12 + 1 + }); + + it('deleteRow removes that row and shifts the rest up', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'A1', 'x'); + setCell(s, name, 'A2', 'y'); + setCell(s, name, 'A3', 'z'); + s.deleteRow(id, 1); + expect(val(s, name, 'A1')).toBe('x'); + expect(val(s, name, 'A2')).toBe('z'); // 'y' is gone, 'z' shifted up + expect(s.tableById(id)!.rows).toBe(11); + }); + + it('deleteRow refuses to remove the last remaining row', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const initialRows = s.tableById(id)!.rows; + for (let i = 0; i < initialRows - 1; i++) s.deleteRow(id, 0); + expect(s.tableById(id)!.rows).toBe(1); + s.deleteRow(id, 0); + expect(s.tableById(id)!.rows).toBe(1); // no-op, refused + }); + + it('insertColumn/deleteColumn mirror the row behaviour, incl. colWidths', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'A1', 'x'); + setCell(s, name, 'B1', 'y'); + s.setColWidth(id, 1, 111); // column B + s.insertColumn(id, 1); + expect(val(s, name, 'A1')).toBe('x'); + expect(val(s, name, 'B1')).toBeNull(); + expect(val(s, name, 'C1')).toBe('y'); // shifted right, width override moved with it + expect(s.tableById(id)!.colWidths[2]).toBe(111); + + s.deleteColumn(id, 1); // remove the now-empty B + expect(val(s, name, 'A1')).toBe('x'); + expect(val(s, name, 'B1')).toBe('y'); // shifted back left + expect(s.tableById(id)!.colWidths[1]).toBe(111); + }); + + it('a self-referencing formula (same table) is rewritten to keep pointing at the same logical cell', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B5', '20'); + setCell(s, name, 'C1', '=B5'); + expect(val(s, name, 'C1')).toBe(20); + + s.insertRow(id, 2); // B5 (row 4) shifts to B6; C1 (row 0) stays put + expect(raw(s, name, 'C1')).toBe('=B6'); + expect(val(s, name, 'C1')).toBe(20); + }); + + it('a cross-table qualified reference is rewritten too, not just formulas inside the edited table', () => { + const s = fresh(); + const src = s.addTable(s.doc.activeSheetId); + s.renameObject(src, 'Quelle'); + const dst = s.addTable(s.doc.activeSheetId); + s.renameObject(dst, 'Ziel'); + setCell(s, 'Quelle', 'B5', '42'); + setCell(s, 'Ziel', 'A1', '=Quelle!B5'); + expect(val(s, 'Ziel', 'A1')).toBe(42); + + s.insertRow(src, 2); + expect(raw(s, 'Ziel', 'A1')).toBe('=Quelle!B6'); + expect(val(s, 'Ziel', 'A1')).toBe(42); + }); + + it('a range that straddles the insertion point grows by one row instead of just sliding', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B1', '1'); + setCell(s, name, 'B2', '2'); + setCell(s, name, 'B3', '3'); + setCell(s, name, 'D1', '=SUM(B1:B3)'); + expect(val(s, name, 'D1')).toBe(6); + + s.insertRow(id, 1); // inside the range (row 1, between B1 and what was B2) + expect(raw(s, name, 'D1')).toBe('=SUM(B1:B4)'); // grew by one + setCell(s, name, 'B2', '99'); // the newly inserted row is now part of the range + expect(val(s, name, 'D1')).toBe(1 + 99 + 2 + 3); + }); + + it('a range fully after the deletion point shifts down, keeps its size', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '10'); + setCell(s, name, 'B3', '20'); + setCell(s, name, 'D5', '=SUM(B2:B3)'); // away from row 0, so it survives the delete itself + s.deleteRow(id, 0); // delete row 0, entirely before the range -- range shifts, doesn't shrink in size + expect(raw(s, name, 'D4')).toBe('=SUM(B1:B2)'); + expect(val(s, name, 'D4')).toBe(30); + }); + + it('deleting a row *inside* a multi-row range shrinks it by one, keeps the rest', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B1', '1'); + setCell(s, name, 'B2', '2'); + setCell(s, name, 'B3', '3'); + setCell(s, name, 'D1', '=SUM(B1:B3)'); + s.deleteRow(id, 1); // remove the middle row of the range + expect(raw(s, name, 'D1')).toBe('=SUM(B1:B2)'); + expect(val(s, name, 'D1')).toBe(1 + 3); // only 1 and (shifted) 3 remain + }); + + it('a single-cell reference exactly on the deleted row becomes a literal #REF', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '10'); + setCell(s, name, 'C1', '=B2+1'); + s.deleteRow(id, 1); // row 1 is exactly B2's row + expect(raw(s, name, 'C1')).toBe('=#REF+1'); + expect(isError(val(s, name, 'C1'))).toBe(true); + }); + + it('a range entirely on the deleted row collapses to a literal #REF', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '1'); + setCell(s, name, 'D2', '2'); + setCell(s, name, 'D1', '=SUM(B2:D2)'); // a 1-row range, entirely row 1 + s.deleteRow(id, 1); + expect(raw(s, name, 'D1')).toBe('=SUM(#REF)'); + expect(isError(val(s, name, 'D1'))).toBe(true); + }); + + it('a $-locked reference still shifts -- unlike copy/paste, $ has no effect on a structural insert/delete', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B5', '7'); + setCell(s, name, 'C1', '=$B$5'); + s.insertRow(id, 1); + expect(raw(s, name, 'C1')).toBe('=$B$6'); + expect(val(s, name, 'C1')).toBe(7); + }); + + it('a reference to a row before the insertion/deletion point is left untouched, byte-identical', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'A1', '5'); + // lower-case + extra spacing in the formula text -- proves an untouched + // reference (row 0, nowhere near the insertion point at row 5) is left + // completely alone, not just numerically equivalent + setCell(s, name, 'C1', '=a1 + 1'); + s.insertRow(id, 5); + expect(raw(s, name, 'C1')).toBe('=a1 + 1'); + s.deleteRow(id, 5); + expect(raw(s, name, 'C1')).toBe('=a1 + 1'); + }); + + it('the sheet-qualified 3-part form ("Blatt!Tabelle::Zelle") is resolved and rewritten too', () => { + const s = fresh(); + // fresh sheets, names that don't collide with the seed's own "Analyse"/ + // "Umsatz" (the whole point of the 3-part form is disambiguating a name + // used on more than one sheet, but that's not what this test is about) + const sheetA = s.addSheet(); + s.renameSheet(sheetA, 'ErsterBogen'); + const src = s.addTable(sheetA); + s.renameObject(src, 'Quelle3'); + const sheetB = s.addSheet(); + s.renameSheet(sheetB, 'ZweiterBogen'); + const dst = s.addTable(sheetB); + s.renameObject(dst, 'Ziel3'); + + setCell(s, 'Quelle3', 'B5', '42'); + setCell(s, 'Ziel3', 'A1', '=ErsterBogen!Quelle3::B5'); + expect(val(s, 'Ziel3', 'A1')).toBe(42); + + s.insertRow(src, 2); + expect(raw(s, 'Ziel3', 'A1')).toBe('=ErsterBogen!Quelle3::B6'); + expect(val(s, 'Ziel3', 'A1')).toBe(42); + }); + + it('undo/redo covers a row insert/delete, incl. the document-wide formula rewrite', () => { + vi.useFakeTimers(); + try { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B5', '10'); + setCell(s, name, 'C1', '=B5'); + vi.advanceTimersByTime(600); + + s.insertRow(id, 2); + vi.advanceTimersByTime(600); + expect(raw(s, name, 'C1')).toBe('=B6'); + + s.undo(); + expect(raw(s, name, 'C1')).toBe('=B5'); + expect(s.tableById(id)!.rows).toBe(12); + + s.redo(); + expect(raw(s, name, 'C1')).toBe('=B6'); + expect(s.tableById(id)!.rows).toBe(13); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); +}); + describe('functionSuggestions (formula autocomplete)', () => { it('filters built-ins by prefix, case-insensitively', () => { const s = fresh(); diff --git a/src/store/document.ts b/src/store/document.ts index 6e806a4..75412e6 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -203,6 +203,203 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): return '=' + out; } +// ---- row/col insert & delete: document-wide reference rewriting --------- +// A mid-grid row/col insert/delete (unlike edge growth/shrink, or copy/paste's +// shiftFormula above) can invalidate/relocate references from *any* formula +// in the *whole document*, not just the edited table's own -- another table's +// `Tabelle!Zelle` qualifier can point straight at it. `rewriteRefsForStructuralChange` +// below is the token-level rewriter that does that (same splice technique as +// shiftFormula: only the affected cell/range tokens' exact text is touched, +// everything else -- including whitespace -- is byte-identical); it's the +// engine used by `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` (in the +// store proper, below) via `rewriteDocumentRefs`. + +type Axis = 'row' | 'col'; + +/** Insert-side shift for one coordinate: everything at/after `at` moves by + * +1. Applied independently to a single cell and to each range endpoint -- + * for a range straddling `at` (start < at <= end), only the end moves, so + * the range *grows* by one to include the newly inserted row/col (the same + * behaviour Excel has for "insert a row inside a SUM range"). */ +function insertShift(v: number, at: number): number { + return v >= at ? v + 1 : v; +} +/** Delete-side shift for a range's *start* endpoint: `at` itself stays `at` + * (the next surviving row/col slides into that slot); only something + * strictly after `at` moves down by one. */ +function deleteShiftStart(v: number, at: number): number { + return v > at ? v - 1 : v; +} +/** Delete-side shift for a range's *end* endpoint: unlike the start + * endpoint, `at` itself also moves down by one here -- that row/col is + * gone, so the range's last surviving row/col is one less than before. */ +function deleteShiftEnd(v: number, at: number): number { + return v >= at ? v - 1 : v; +} + +function axisValue(axis: Axis, c: { col: number; row: number }): number { + return axis === 'row' ? c.row : c.col; +} +function withAxisValue( + axis: Axis, + c: { col: number; row: number; colAbs: boolean; rowAbs: boolean }, + v: number, +): string { + return axis === 'row' ? toA1(c.col, v, c.colAbs, c.rowAbs) : toA1(v, c.row, c.colAbs, c.rowAbs); +} + +/** True iff, with `toks[i]` a known "!" (BANG), the shape ahead is + * IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name + * followed by a table qualifier (the 3-part `Blatt!Tabelle::Zelle` form), + * not a bare (old-style) table qualifier of its own. Exact same lookahead + * as `parser.ts#Parser.looksLikeSheetThenTable`. */ +function looksLikeSheetThenTable(toks: Token[], i: number): boolean { + const n1 = toks[i + 1]; + const n2 = toks[i + 2]; + return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON'; +} + +/** + * Rewrite every reference to `targetTableId` inside one formula for a + * structural row/col insert (`delta` `1`) or delete (`delta` `-1`) at index + * `at` on `axis`. `homeTableId` is the formula's *own* table (needed to + * resolve a bare, unqualified reference, and -- via a transiently-set + * `wb.homeTableId` -- to break a same-table-name-on-two-sheets ambiguity + * the exact same way `wb.resolveTableName` already does for evaluation; + * this function literally calls it). + * + * A reference landing exactly on a deleted row/col -- or a range that + * collapses entirely (every row/col it covered was the one deleted) -- + * becomes a literal `#REF` (see engine/parser.ts's `ErrorLiteralNode`), + * the same thing Excel shows inline for a broken reference. A range that + * merely *shrinks* (only part of it was the deleted line) stays a valid, + * smaller range, exactly like Excel. + */ +function rewriteRefsForStructuralChange( + raw: string, + homeTableId: string, + targetTableId: string, + axis: Axis, + at: number, + delta: 1 | -1, + wb: Workbook, +): string { + if (!raw.startsWith('=')) return raw; + const body = raw.slice(1); + let toks: Token[]; + try { + toks = tokenize(body); + } catch { + return raw; // unparsable -- leave as-is, the evaluator already surfaces #ERR + } + + const savedHome = wb.homeTableId; + wb.homeTableId = homeTableId; + const resolve = (name: string, sheetName: string | null): string | null => + wb.resolveTableName(name, sheetName); + + interface Repl { + pos: number; + end: number; + text: string; + } + const repls: Repl[] = []; + + let i = 0; + while (i < toks.length && toks[i].type !== 'EOF') { + const tok = toks[i]; + let cellIdx = -1; + let resolvedTableId: string | null = null; + + if ((tok.type === 'IDENT' || tok.type === 'QUOTED') && toks[i + 1]) { + if (toks[i + 1].type === 'BANG' && looksLikeSheetThenTable(toks, i + 1)) { + resolvedTableId = resolve(toks[i + 2].value, tok.value); // sheet=tok, table=toks[i+2] + cellIdx = i + 4; // consumed: IDENT/QUOTED "!" IDENT/QUOTED "::" + } else if (toks[i + 1].type === 'BANG' || toks[i + 1].type === 'DBLCOLON') { + resolvedTableId = resolve(tok.value, null); + cellIdx = i + 2; + } + } + if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) { + resolvedTableId = homeTableId; + cellIdx = i; + } + if (cellIdx === -1) { + i++; + continue; + } + + const cellTokA = toks[cellIdx]; + if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { + i++; // qualifier not actually followed by a cell -- malformed, leave alone + continue; + } + const isRangeStart = toks[cellIdx + 1]?.type === 'COLON'; + const cellTokB = isRangeStart ? toks[cellIdx + 2] : undefined; + const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value); + + if (resolvedTableId === targetTableId) { + const a = parseA1(cellTokA.value); + if (validRange && cellTokB) { + const b = parseA1(cellTokB.value); + const va = axisValue(axis, a); + const vb = axisValue(axis, b); + const lo = Math.min(va, vb); + const hi = Math.max(va, vb); + if (delta > 0) { + const newLo = insertShift(lo, at); + const newHi = insertShift(hi, at); + if (newLo !== lo) { + repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) }); + } + if (newHi !== hi) { + repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) }); + } + } else { + const newLo = deleteShiftStart(lo, at); + const newHi = deleteShiftEnd(hi, at); + if (newLo > newHi) { + repls.push({ pos: cellTokA.pos, end: cellTokB.pos + cellTokB.value.length, text: '#REF' }); + } else { + if (newLo !== lo) { + repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, va === lo ? newLo : newHi) }); + } + if (newHi !== hi) { + repls.push({ pos: cellTokB.pos, end: cellTokB.pos + cellTokB.value.length, text: withAxisValue(axis, b, vb === lo ? newLo : newHi) }); + } + } + } + } else { + const v = axisValue(axis, a); + if (delta > 0) { + const nv = insertShift(v, at); + if (nv !== v) { + repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, nv) }); + } + } else if (v === at) { + repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: '#REF' }); + } else if (v > at) { + repls.push({ pos: cellTokA.pos, end: cellTokA.pos + cellTokA.value.length, text: withAxisValue(axis, a, v - 1) }); + } + } + } + + i = validRange ? cellIdx + 3 : cellIdx + 1; + } + + wb.homeTableId = savedHome; + if (repls.length === 0) return raw; + repls.sort((x, y) => x.pos - y.pos); + let out = ''; + let cursor = 0; + for (const r of repls) { + out += body.slice(cursor, r.pos) + r.text; + cursor = r.end; + } + out += body.slice(cursor); + return '=' + out; +} + function loadInitial(): DocumentState { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -1176,6 +1373,142 @@ function createStore() { syncWorkbook(); } + // ---- row/col insert & delete (mid-grid, not just edge growth) --------- + // Unlike setTableRows/setTableCols above (pure edge resize) or + // removeLastRow/removeLastColumn, these shift cell data/formats/row-height + // (or column-width) overrides *within* the table around the insertion/ + // deletion point, and rewrite every formula in the *whole document* that + // references a row/col at/after it on this table -- see + // `rewriteRefsForStructuralChange` above for the "how". + + /** Move `doc.selection` (if it's on `tableId`) the same way a row/col + * insert/delete moves cell data -- so the active cell/marquee stays on + * the "same" logical cell it was on before, not wherever that grid + * position now happens to be. `clampSelection` (called by every caller + * right after) catches anything this pushes out of the shrunk bounds. */ + function shiftSelectionForStructuralChange(tableId: string, axis: Axis, at: number, delta: 1 | -1) { + const s = doc.selection; + if (!s || s.tableId !== tableId) return; + const shift = (c: CellCoord): CellCoord => { + const v = axis === 'row' ? c.row : c.col; + const nv = delta > 0 ? insertShift(v, at) : deleteShiftStart(v, at); + return axis === 'row' ? { ...c, row: nv } : { ...c, col: nv }; + }; + doc.selection = { + tableId, + anchor: shift(s.anchor), + focus: shift(s.focus), + ranges: s.ranges?.map((r) => ({ anchor: shift(r.anchor), focus: shift(r.focus) })), + }; + } + + /** Rewrite every formula in the whole document referencing `targetTableId` + * after its rows/cols and cell data have already been shifted to their + * new shape (self-references inside `targetTableId` are included -- it's + * just another table as far as this loop is concerned). */ + function rewriteDocumentRefs(targetTableId: string, axis: Axis, at: number, delta: 1 | -1) { + for (const s of doc.sheets) { + for (const o of s.objects) { + if (o.kind !== 'table') continue; + for (const [key, raw] of Object.entries(o.cells)) { + const next = rewriteRefsForStructuralChange(raw, o.id, targetTableId, axis, at, delta, wb); + if (next !== raw) o.cells[key] = next; + } + } + } + } + + /** Re-key every entry of a "col,row" map, shifting the given axis at/after + * `at` by `delta` (dropping entries whose axis value === `at` when + * `delta` is -1 -- that row/col's own cells are gone). Shared by + * insertRow/deleteRow (axis 'row') and insertColumn/deleteColumn (axis + * 'col'). */ + function shiftCellKeyedMap( + map: Record, + axis: Axis, + at: number, + delta: 1 | -1, + ): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(map)) { + const [c, r] = k.split(',').map(Number); + const cur = axis === 'row' ? r : c; + if (delta < 0 && cur === at) continue; // this row/col's own cell is gone + const nv = delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at); + const key = axis === 'row' ? `${c},${nv}` : `${nv},${r}`; + out[key] = v; + } + return out; + } + /** Same idea as shiftCellKeyedMap, for a single-axis map keyed by plain + * row/col number (rowHeights, colWidths). */ + function shiftAxisKeyedMap(map: Record, at: number, delta: 1 | -1): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(map)) { + const cur = Number(k); + if (delta < 0 && cur === at) continue; + out[delta > 0 ? insertShift(cur, at) : deleteShiftStart(cur, at)] = v; + } + return out; + } + + function insertRow(tableId: string, at: number) { + const t = tableById(tableId); + if (!t) return; + const clamped = Math.max(0, Math.min(Math.floor(at), t.rows)); + t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1); + if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1); + if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1); + t.rows += 1; + shiftSelectionForStructuralChange(tableId, 'row', clamped, 1); + rewriteDocumentRefs(tableId, 'row', clamped, 1); + clampSelection(tableId); + syncWorkbook(); + } + function deleteRow(tableId: string, at: number) { + const t = tableById(tableId); + if (!t || t.rows <= 1) return; + const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1)); + t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1); + if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1); + if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1); + t.rows -= 1; + shiftSelectionForStructuralChange(tableId, 'row', clamped, -1); + rewriteDocumentRefs(tableId, 'row', clamped, -1); + pruneEmptyMaps(t); + clampTableView(t); + clampSelection(tableId); + syncWorkbook(); + } + function insertColumn(tableId: string, at: number) { + const t = tableById(tableId); + if (!t) return; + const clamped = Math.max(0, Math.min(Math.floor(at), t.cols)); + t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1); + if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1); + t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1); + t.cols += 1; + shiftSelectionForStructuralChange(tableId, 'col', clamped, 1); + rewriteDocumentRefs(tableId, 'col', clamped, 1); + clampSelection(tableId); + syncWorkbook(); + } + function deleteColumn(tableId: string, at: number) { + const t = tableById(tableId); + if (!t || t.cols <= 1) return; + const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1)); + t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1); + if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1); + t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1); + t.cols -= 1; + shiftSelectionForStructuralChange(tableId, 'col', clamped, -1); + rewriteDocumentRefs(tableId, 'col', clamped, -1); + pruneEmptyMaps(t); + clampTableView(t); + clampSelection(tableId); + syncWorkbook(); + } + // ---- cell formatting (alignment, number format, colours, bold/italic) --- const fmtKey = (c: number, r: number) => `${c},${r}`; function cellFormat(tableId: string, col: number, row: number): CellFormat { @@ -1837,6 +2170,10 @@ function createStore() { rowHeightOf, setTableRows, setTableCols, + insertRow, + deleteRow, + insertColumn, + deleteColumn, setTableView, // cell formatting cellFormat,