From 2c54537427edf10acbbd13018b975ca32d5eeb55 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Tue, 8 Sep 2026 21:59:47 +0200 Subject: [PATCH] Text-Vergleich, &-Verkettung und benannte Bereiche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen"). - Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus CONCAT/CONCATENATE als Funktions-Äquivalent. - Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar. Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung), bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst nutzbar (Aufrufer können sie aber als Argument übergeben). - Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte "COUNT(...)" statt "COUNT2(...)"). 374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert. Co-Authored-By: Claude Sonnet 5 --- README.md | 213 +++++++++++++-- src/components/AppToolbar.vue | 11 + src/components/NamedRangesModal.vue | 291 +++++++++++++++++++++ src/data/seed.ts | 5 + src/engine/README.md | 108 +++++++- src/engine/__tests__/evaluator.spec.ts | 124 ++++++++- src/engine/__tests__/lexer.spec.ts | 5 +- src/engine/__tests__/parser.spec.ts | 49 +++- src/engine/__tests__/userFunctions.spec.ts | 12 + src/engine/__tests__/workbook.spec.ts | 55 ++++ src/engine/evaluator.ts | 157 +++++++++-- src/engine/functions.ts | 13 +- src/engine/index.ts | 2 + src/engine/lexer.ts | 2 + src/engine/parser.ts | 114 ++++++-- src/engine/types.ts | 32 +++ src/engine/userFunctions.ts | 16 +- src/engine/workbook.ts | 36 ++- src/io/json.ts | 46 ++++ src/store/__tests__/document.spec.ts | 132 ++++++++++ src/store/document.ts | 103 +++++++- src/store/types.ts | 20 ++ 22 files changed, 1460 insertions(+), 86 deletions(-) create mode 100644 src/components/NamedRangesModal.vue diff --git a/README.md b/README.md index 704fbe3..22b6d33 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,115 @@ ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details: [`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt. +#### SUMIF/COUNTIF/AVERAGEIF nachbauen + +Es gibt keine eingebauten `SUMIF`/`COUNTIF`/`AVERAGEIF` — aber die **„Mit +Schleife"**-Funktionen reichen aus, um die **selbstbezügliche** Form +nachzubauen: Kriterium und Aggregation laufen über **denselben** Bereich +(„Summe/Anzahl/Mittelwert aller Werte in `bereich`, die eine Bedingung +erfüllen"). Das deckt den häufigsten Alltagsfall ab — z. B. „Summe aller +Umsätze über 100" oder „Anzahl der Zeilen mit Region = Nord", solange +Kriterium und zu summierender Wert in derselben Zelle stehen. + +``` +summe = 0 +FOR x IN bereich DO + IF x > schwelle THEN + summe = summe + x + END +END +RETURN summe +``` + +aufgerufen als `=SUMME_WENN(Umsatz!B2:B13; 100)` — das Excel-Äquivalent zu +`SUMIF(Umsatz!B2:B13; ">100")` mit gleichem Kriterien- und Summenbereich. +`COUNTIF`/`AVERAGEIF` folgen demselben Muster, nur mit `zaehler = zaehler + 1` +statt `summe = summe + x`, bzw. am Ende `RETURN summe / zaehler` (mit +`IF zaehler = 0 THEN RETURN 0 END`-Guard gegen `#DIV/0!`). + +Seit dem Fix der Vergleichsoperatoren (s. u. „Formelgrammatik") funktioniert +das Kriterium auch mit **Text** — case-insensitiv und getrimmt, wie bei +`XLOOKUP`: + +``` +anzahl = 0 +FOR zeile IN bereich DO + IF zeile = kriterium THEN + anzahl = anzahl + 1 + END +END +RETURN anzahl +``` + +aufgerufen als `=ANZAHL_WENN(Umsatz!A2:A13; "Nord")` — zählt alle Zellen in +`Umsatz!A2:A13`, deren Text „Nord" ist (unabhängig von Groß-/Kleinschreibung +und Leerzeichen). + +**Grenze, die (noch) bleibt:** ein Excel-`SUMIF(Kriterienbereich; Kriterium; +Summenbereich)` mit **getrennten** Bereichen (Kriterium in Spalte A, zu +summierender Wert in Spalte B) ist damit **nicht** nachbaubar — die Sprache +hat kein `OFFSET`/`INDEX`-Äquivalent, um „die Zelle an derselben Position im +anderen Bereich" zu adressieren; eine `FOR`-Schleife erreicht nur den +Bereich, über den sie selbst iteriert. Siehe „Bekannte Grenzen" unten. + +### Benannte Bereiche + +Der Button **„🏷" (Benannte Bereiche)** in der Symbolleiste, direkt neben +**„𝑓 Funktionen"**, öffnet die einzige Stelle, an der dokumentweite Namen für +eine feste Zelle/einen festen Bereich angelegt, bearbeitet und gelöscht +werden — z. B. `Umsatz_Q1` statt `Umsatz!B2:B6`. Danach in **jeder** +Top-Level-Formel unqualifiziert nutzbar, ohne Tabellen-Präfix: +`=SUM(Umsatz_Q1)` oder `=Umsatz_Q1 + 1` für eine einzelne Zelle. Das +vorbefüllte Dokument bringt `Umsatz_Q1` (→ `Umsatz!B2:B6`) bereits als +Beispiel mit. + +- **Liste** zeigt jeden Namen als `Name → Tabelle (Blatt)!Bereich` — mit + **Bearb.** und **🗑** wie bei den Funktionen. +- **Formular**: Name, eine Tabelle (Auswahl über alle Blätter hinweg) und + zwei Zellfelder (Start/Ende als A1-Notation, z. B. `B2` und `B6`); ein + leeres zweites Feld bedeutet „nur die erste Zelle". **„+ Neuer Name"** + übernimmt automatisch die aktuelle Zellauswahl (Tabelle + Bereich) als + Vorbelegung, wenn eine Zelle/ein Bereich gerade markiert ist — analog zu + Excels „Namen aus Auswahl erstellen". +- **Validierung beim Speichern**: gültiger Name (keine Zahl/Zellform wie + `A1`), kein reserviertes Schlüsselwort (`FOR`/`IN`/`DO`/`RETURN`), keine + Kollision mit einem eingebauten Namen, einer benutzerdefinierten Funktion + oder einem anderen benannten Bereich (case-insensitiv). **Kein + Zyklus-Check nötig** — anders als eine benutzerdefinierte Funktion hat ein + benannter Bereich keinen eigenen Ausdruck, nur feste Koordinaten, kann sich + also nie selbst referenzieren. +- **Überlebt eine Tabellen-Umbenennung.** Intern wird die Tabellen-*ID* + gespeichert, nicht ihr Name — anders als ein normaler `Tabelle!Zelle`-Bezug + im Formeltext (der bricht bei Umbenennung, wie eh und je) wird ein + benannter Bereich beim Nachschlagen live auf den *aktuellen* Tabellennamen + aufgelöst. Wird die Tabelle dagegen **gelöscht**, bleibt der Name bestehen + und zeigt in Formeln danach `#NAME?` — „nichts wird automatisch + umgeschrieben", dieselbe Konvention wie beim Löschen einer benutzerdefinierten + Funktion oder Tabelle. +- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports. + +**Bewusst ohne** (Stand jetzt): +- **Nicht innerhalb einer benutzerdefinierten Funktion nutzbar** — im + Funktionskörper selbst bleibt ein unqualifizierter, nicht-zellförmiger + Name ein harter Parse-Fehler, genau wie vor diesem Feature. Grund: sonst + würde z. B. eine lokale Variable, die versehentlich vor ihrer eigenen + Zuweisung verwendet wird, nicht mehr sofort beim Speichern auffallen, + sondern erst zur Laufzeit als `#NAME?` — oder, schlimmer, sie würde + still passend zu einem gleichnamigen benannten Bereich irgendwo im + Dokument auflösen. Ein **Aufrufer** kann einen benannten Bereich aber + ganz normal als Argument übergeben (`=MEINEFUNKTION(Umsatz_Q1)`) — die + Einschränkung gilt nur fürs *Eintippen des bloßen Namens* im Funktionskörper + selbst. +- **Keine eigene Formel/kein Ausdruck** — nur eine feste Zelle/ein fester + Bereich, kein benannter *berechneter* Wert (dafür gibt es weiterhin + benutzerdefinierte Funktionen ohne Parameter, s. o.). +- **Nicht in der Funktions-Autovervollständigung** — das Dropdown beim + Tippen nach `=` schlägt bisher nur Funktionsnamen vor, keine benannten + Bereiche. +- **Kein „Bereich" als `Name:B2`** — ein benannter Bereich ist immer die + ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu + getippten `:`-Bereichsangabe kombiniert werden. + ### Zell-Formatierung Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im @@ -327,7 +436,7 @@ fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen. formula = "=" expression ; expression = comparison ; -comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens +comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens ein Vergleich, nicht verkettbar wie "a < b < c"; vor allem @@ -335,6 +444,12 @@ comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchsten „Mit Schleife"-Code (s. o.), aber in jeder Formel nutzbar *) +concat = additive { "&" additive } ; (* links-assoziativ; Text- + Verkettung, bindet + schwächer als "+ -", + stärker als ein + Vergleich -- derselbe + Platz wie bei Excel *) additive = term { ("+" | "-") term } ; (* links-assoziativ *) term = unary { ("*" | "/") unary } ; (* links-assoziativ *) unary = ("+" | "-") unary @@ -342,10 +457,19 @@ 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 ; +primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ; funcall = IDENT "(" [ expression { ";" expression } ] ")" ; +namedref = IDENT ; (* ein unqualifizierter, + nicht zellförmiger, + nicht in `(` mündender + Bezeichner -- ein + benannter-Bereich- + Kandidat, s. u.; nur + außerhalb eines + Funktionskörpers *) + reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ; qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *) sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt, @@ -362,9 +486,38 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *) CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; ``` -Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < +Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`. +**Vergleiche** sind numerisch, wenn beide Seiten Zahlen sind (leere Zelle +zählt als `0`), und **text**uell — case-insensitiv, getrimmt, wie bei +`XLOOKUP` (s. u.) —, wenn beide Seiten Text sind (leere Zelle zählt dann als +`''`). Eine echte Zahl gegen echten Text: `=`/`<>` antworten eindeutig +falsch/wahr, `> < >= <=` ergeben `#VALUE!` (kein sinnvoller Vergleich über +Typgrenzen). Nützlich z. B. für `=A1="Nord"` oder als Kriterium einer +selbstgebauten `COUNTIF`/`SUMIF`-Funktion (s. „SUMIF/COUNTIF/AVERAGEIF +nachbauen" oben). + +**`&` verkettet als Text**, z. B. `=A1&" "&B1` oder `="Herr "&Name`. Eine Zahl +wird dabei ohne Locale-Formatierung dargestellt (`5`, nicht `5,00`), eine +leere Zelle trägt `''` bei (nicht `'0'`). Bindet schwächer als `+`/`-` +(`="a"&1+2` = `"a3"`, nicht `"a1"+2`), aber stärker als ein Vergleich +(`=A1&B1="ab"` prüft das *Ergebnis* der Verkettung, nicht `B1="ab"` allein). +`CONCAT(Text1; Text2; …)`/`CONCATENATE(...)` machen dasselbe als Funktion, +ohne Operator-Präzedenz zu beachten — nützlich mit vielen Teilen oder +innerhalb eines „Mit Schleife"-Codes. + +Ein unqualifizierter Bezeichner, der weder eine Zelle noch (mit +`paramNames`) ein Parameter ist, parst als **`namedref`** — ein Kandidat für +einen **benannten Bereich** (s. „Benannte Bereiche" oben), erst zur +Auswertung/Abhängigkeits-Ermittlung gegen das Dokument aufgelöst (genau wie +ein Tabellen-Qualifier nie beim Parsen selbst geprüft wird). Ein unbekannter +Name ergibt `#NAME?`. **Nur außerhalb eines Funktionskörpers** — mit +`paramNames` bleibt ein unqualifizierter, nicht-zellförmiger Bezeichner ein +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. + 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`. @@ -411,10 +564,10 @@ Vorschlag (fügt `NAME(` ein, bereit für die Argumente), `Esc` schließt nur das Dropdown, ohne die Bearbeitung selbst abzubrechen. **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), -`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat; -Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert; -Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; -[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten +`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]; +[Vergleichsmodus]; [Suchmodus])` — 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. @@ -712,7 +865,23 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei v1→v2-Migration. - **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz - unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler). + unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler); + numerischer Vergleich, Text-Vergleich (case-insensitiv/getrimmt), leere + Zelle nimmt den Typ der Gegenseite an, Zahl-gegen-Text-Mismatch + (`=`/`<>` eindeutig, `> < >= <=` → `#VALUE!`). +- **`&`-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. +- **Benannte Bereiche** — Auflösung als Einzelzelle/Bereich (Skalar- und + Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching, + Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle), + Überleben einer Tabellen-Umbenennung, `#NAME?` bei unbekanntem Namen und + bei gelöschter Zieltabelle, `addNamedRange`/`updateNamedRange`/ + `deleteNamedRange`-Validierung (Kollision mit Built-in/eigener + Funktion/anderem Namen, Zellform), Undo/Redo, JSON-Round-Trip, Nutzung als + `FOR`-Quelle in einer benutzerdefinierten Funktion (als übergebenes + Argument), harter Parse-Fehler statt Fallback innerhalb eines + Funktionskörpers selbst. - **„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` @@ -784,9 +953,12 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, reaktiv), **kein** echter Web Worker. - **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von Quotes); exotische Excel-Formeln fallen auf den Wert zurück. -- Kein Text-Verkettungsoperator, keine benannten Bereiche (nur - benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein - Snapping der Canvas-Objekte. +- Kein Snapping der Canvas-Objekte. +- **Benannte Bereiche** bewusst ohne: Nutzung innerhalb einer + benutzerdefinierten Funktion (ein Aufrufer kann aber einen benannten + Bereich als Argument übergeben), einen eigenen Ausdruck/berechneten Wert + (nur feste Koordinaten), Funktions-Autovervollständigungs-Vorschläge, und + `Name:B2` als kombinierten Bereichsstart — s. „Benannte Bereiche" oben. - **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO- und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar, @@ -813,12 +985,19 @@ 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), Text-Vergleich (Vergleichsoperatoren - bleiben numerisch), `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.). + 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). +- **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 + Position in einem anderen Bereich". Ein klassisches dreiargumentiges + `SUMIF(Kriterienbereich; Kriterium; Summenbereich)` mit **getrennten** + Bereichen ist deshalb nicht nachbaubar; nur die selbstbezügliche Form + (Kriterium und Aggregation auf demselben Bereich) geht (s. o.). ## Projektstruktur diff --git a/src/components/AppToolbar.vue b/src/components/AppToolbar.vue index d4cebbe..12685eb 100644 --- a/src/components/AppToolbar.vue +++ b/src/components/AppToolbar.vue @@ -5,6 +5,7 @@ import { useTheme } from '../composables/useTheme'; import { useToasts } from '../composables/useToasts'; import BaseModal from './BaseModal.vue'; import CustomFunctionsModal from './CustomFunctionsModal.vue'; +import NamedRangesModal from './NamedRangesModal.vue'; import TheLogo from './TheLogo.vue'; import { WICOM, WICOM_SOCIAL } from '../config/brand'; import { @@ -29,6 +30,7 @@ const toasts = useToasts(); const fileMenuOpen = ref(false); const aboutOpen = ref(false); const functionsOpen = ref(false); +const namedRangesOpen = ref(false); const busy = ref(false); const fileInput = ref(null); @@ -249,6 +251,14 @@ function onReset() { > 𝑓 +
@@ -343,6 +353,7 @@ function onReset() { + diff --git a/src/components/NamedRangesModal.vue b/src/components/NamedRangesModal.vue new file mode 100644 index 0000000..f03af33 --- /dev/null +++ b/src/components/NamedRangesModal.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/src/data/seed.ts b/src/data/seed.ts index 63ca046..0c22c57 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -136,5 +136,10 @@ export function createSeedDocument(): DocumentState { locale: 'de-DE', csvDelimiter: ',', customFunctions: [], + namedRanges: [ + // demos "🏷 Namen": =Umsatz_Q1 anywhere resolves to Umsatz!B2:B6, even + // from a different sheet, without a table qualifier. + { id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } }, + ], }; } diff --git a/src/engine/README.md b/src/engine/README.md index 7d16470..62ef3f9 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -35,11 +35,14 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen formula = "=" expression ; expression = comparison ; -comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens +comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens ein Vergleich, nicht verkettbar; Ergebnis 1/0 (siehe Auswertungs- modell unten) *) +concat = additive { "&" additive } ; (* links-assoziativ; Text- + Verkettung, zwischen + Vergleich und "+ -" *) additive = term { ("+" | "-") term } ; (* links-assoziativ; die alte "expression"-Regel, bevor Vergleiche @@ -54,10 +57,16 @@ primary = NUMBER | STRING | "(" expression ")" | funcall - | reference ; + | reference + | namedref ; funcall = IDENT "(" [ expression { ";" expression } ] ")" ; +namedref = IDENT ; (* unqualifizierter, nicht zellförmiger + Bezeichner, kein Parameter -- ein + benannter-Bereich-Kandidat (s. u.), + nur außerhalb eines Funktionskörpers *) + reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ; qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der Numbers-Stil "::" sind @@ -78,7 +87,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen * CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; ``` -Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < unäres `+ -` < `^`. +Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`. `$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1` (beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie @@ -111,8 +120,20 @@ wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown. Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines `ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers** -verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie -zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`. +verwendet (`userFunctions.ts`). + +Ohne das Argument (`paramNames === undefined`, jede gewöhnliche +Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne +Zellform seit **benannten Bereichen** geändert: er parst jetzt als +`NamedRefNode` (Feld `namedRefFallback` am `Parser`, s. u. „Benannte +Bereiche") statt als `ParseError`. **Mit** `paramNames` — auch als leeres +Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte +Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name +ist weiterhin ein harter `ParseError`. Grund: eine lokale/Schleifen-Variable, +die vor ihrer eigenen deklarierenden Anweisung verwendet wird (`x = x + 1`), +soll sofort beim Speichern scheitern, statt sich still zu einem +gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur +Laufzeit als `#NAME?` aufzufallen). ## Blatt-Qualifier ("Blatt!Tabelle::Zelle") @@ -169,6 +190,54 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen, `evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`. +## Benannte Bereiche + +Anders als eine benutzerdefinierte Funktion wird ein `NamedRefNode` +**nicht** beim Parsen aufgelöst/inlined, sondern **live, bei jeder +Auswertung**, gegen `EngineHost.resolveNamedRange(name)` – dieselbe +"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier +(auch der wird nie zur Parse-Zeit zu einer Tabellen-*ID* aufgelöst). Grund: +so übersteht ein benannter Bereich eine spätere Umbenennung *seiner* +Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden +müsste. + +- `types.ts#NamedRangeDef` (`{id, name, tableId, start, end}`) wird dem + `Workbook`-Konstruktor als dritter Parameter mitgegeben (analog zu den + `FunctionDef`s); `store/document.ts#toNamedRangeDefs` mappt + `DocumentState.namedRanges` dorthin. +- `workbook.ts#Workbook.resolveNamedRange(name)` sucht case-insensitiv/ + getrimmt (dieselbe Konvention wie `resolveTableName`), löst `tableId` über + `tableById()` in den **aktuellen** Tabellennamen auf und gibt + `{qualifier, sheetQualifier, start, end}` zurück – exakt die Form, die ein + `CellNode`/`RangeNode` sonst auch trägt. Zieltabelle nicht mehr vorhanden + → `null` → `#NAME?` (dieselbe Fehlerklasse wie ein unbekannter Name). +- `evaluator.ts#resolveNamedRef()` baut aus diesem Ergebnis einen + synthetischen `CellNode` (1×1-Bereich) oder `RangeNode` und lässt jeden + Aufrufer (`evalScalar`s `case 'namedref'`, `evalArg`, + `rangeOrCellToCellNodes` für eine `FOR`-Quelle) in die **bereits + vorhandene** Zell-/Bereichs-Logik durchfallen – keine Duplikation. +- `parser.ts#collectRefs` sammelt einen `NamedRefNode` unaufgelöst (der + Parser kennt das Dokument nicht); `workbook.ts#precedentsOf` löst ihn + dann über `resolveNamedRange` in konkrete Zell-Keys für den + Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine + Neuberechnung nötig, `evaluate()` liefert ohnehin `#NAME?`). +- **Grammatik-Änderung, die das erst ermöglicht:** ein unqualifizierter, + nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein `NamedRefNode` + statt eines `ParseError` – aber **nur außerhalb eines Funktionskörpers** + (`Parser`s `namedRefFallback`-Flag, `true` nur wenn `parseFormula()` ganz + ohne `paramNames`-Argument aufgerufen wird). Ein Aufrufer kann trotzdem + einen benannten Bereich als Argument an eine Funktion übergeben – der + `NamedRefNode` steht dann in `node.args`, wird über + `expand()`/`substitute()` (beide bekamen dafür einen `case 'namedref': + return node;`-Leaf, wie `cell`/`range`) an die passende Parameterstelle im + Funktionskörper substituiert, und trifft dort z. B. als `FOR`-Quelle auf + `rangeOrCellToCellNodes`, das ihn ganz normal auflöst. + `userFunctions.ts#findInvalidForSource` erlaubt `namedref` deshalb + ausdrücklich als gültige `FOR`-Quellenform (neben `param`/`cell`/`range`). +- **Kein Zyklus-Check.** Ein benannter Bereich hat keinen eigenen Ausdruck, + nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders + als eine benutzerdefinierte Funktion. + ## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`) Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'` @@ -302,14 +371,35 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung. - **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`. -- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei - `+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in - einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in - jeder Formel gültig. +- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch); + genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"- + Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder + eine leere Zelle, die als `0` zählt) vergleichen **numerisch** + (`toNumber()`-Koerzierung wie bei `+ - * /`); zwei Texte (oder eine leere + Zelle, die dann als `''` zählt) vergleichen **text**uell – + case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s + Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt + zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der + **anderen** Seite an, sodass sowohl `=A1=0` als auch `=A1=""` auf einer + echt leeren `A1` funktionieren. Eine echte (nicht-leere) Zahl gegen + echten Text ist ein **Typ-Mismatch**, kein Crossmatch: `=`/`<>` + antworten eindeutig falsch/wahr, `> < >= <=` haben keine sinnvolle + Antwort und ergeben `#VALUE!` (siehe `evaluator.ts`s `compareValues()`). +- **`&` verkettet als Text** (`toText()`): eine Zahl rendert `String(n)` + ohne Locale-Formatierung (kein `Intl`, dieselbe „locale-neutral"- + Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt `''` bei + (nicht `'0'`). Präzedenz zwischen `+ -` und einem Vergleich (s. o. EBNF), + links-assoziativ. `CONCAT`/`CONCATENATE(Text1; Text2; …)` sind das + Funktions-Äquivalent (`functions.ts`), ohne Operator-Präzedenz. - **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste gefundene Fehler gewinnt). - Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position ergibt er `#VALUE!`. +- Ein **`NamedRefNode`** (benannter Bereich) löst live gegen + `host.resolveNamedRange()` auf und verhält sich danach exakt wie die + Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für + einen mehrzelligen Treffer). Unbekannter Name → `#NAME?`. Details: + „Benannte Bereiche" oben. - `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`. diff --git a/src/engine/__tests__/evaluator.spec.ts b/src/engine/__tests__/evaluator.spec.ts index 0f055f9..8850e03 100644 --- a/src/engine/__tests__/evaluator.spec.ts +++ b/src/engine/__tests__/evaluator.spec.ts @@ -3,10 +3,15 @@ import { parseFormula } from '../parser'; import { evaluate } from '../evaluator'; import { FormulaError, isError, type CellValue, type EngineHost } from '../types'; -/** Minimal in-memory host: two tables addressed by name. */ +type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } }; + +/** Minimal in-memory host: two tables addressed by name, plus an optional + * name -> named-range map (name lower-cased, same matching convention as + * the real Workbook). */ function makeHost( data: Record>, home = 'S1', + namedRanges: Record = {}, ): EngineHost { const sizes: Record = { S1: { cols: 10, rows: 10 }, @@ -21,6 +26,7 @@ function makeHost( return { homeTableId: home, resolveTableName: idFor, + resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null, tableSize: (id) => sizes[id] ?? null, getComputed(tableId, col, row) { const cell = String.fromCharCode(65 + col) + (row + 1); @@ -130,6 +136,122 @@ describe('evaluator IF', () => { }); }); +describe('evaluator comparisons', () => { + const host = makeHost({ S1: { A1: 'Nord', B1: 5 } }); + + it('numeric comparisons work as before, empty cell counts as 0', () => { + expect(ev('=B1>=5', host)).toBe(1); + expect(ev('=B1<>5', host)).toBe(0); + expect(ev('=Z9=0', host)).toBe(1); + }); + + it('text equality is case-insensitive and trimmed, like XLOOKUP', () => { + expect(ev('=A1="Nord"', host)).toBe(1); + expect(ev('=A1=" nord "', host)).toBe(1); + expect(ev('=A1="Süd"', host)).toBe(0); + }); + + it('text ordering compares lexicographically', () => { + expect(ev('=A1<"Süd"', host)).toBe(1); + expect(ev('=A1>"Süd"', host)).toBe(0); + }); + + it('an empty cell takes on whichever type the other side is', () => { + expect(ev('=Z9=""', host)).toBe(1); + expect(ev('=Z9="x"', host)).toBe(0); + }); + + it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => { + expect(ev('=B1="5"', host)).toBe(0); + expect(ev('=B1<>"5"', host)).toBe(1); + expect(isError(ev('=B1>"5"', host))).toBe(true); + expect(isError(ev('=A1<5', host))).toBe(true); + }); +}); + +describe('evaluator & (text concatenation)', () => { + const host = makeHost({ S1: { A1: 'Herr', B1: 5 } }); + + it('joins two strings', () => { + expect(ev('="a"&"b"', host)).toBe('ab'); + }); + + it('renders a number plain, no locale formatting', () => { + expect(ev('="Wert: "&B1', host)).toBe('Wert: 5'); + }); + + it('an empty cell contributes "" (not "0")', () => { + expect(ev('="["&Z9&"]"', host)).toBe('[]'); + }); + + it('is left-associative and chains', () => { + expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild'); + }); + + it('binds looser than + but tighter than a comparison', () => { + expect(ev('="a"&1+2', host)).toBe('a3'); + expect(ev('=A1&"x"="Herrx"', host)).toBe(1); + }); + + it('propagates an error from either side', () => { + expect(isError(ev('=(1/0)&"x"', host))).toBe(true); + }); +}); + +describe('evaluator CONCAT/CONCATENATE', () => { + const host = makeHost({ S1: { A1: 'x', B1: 'y' } }); + + it('joins any number of arguments, no separator', () => { + expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc'); + expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y'); + }); + + it('an empty cell contributes ""', () => { + expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy'); + }); +}); + +describe('evaluator named ranges (NamedRefNode)', () => { + it('a single-cell named range behaves exactly like that cell in scalar position', () => { + const host = makeHost({ S1: { B2: 42 } }, 'S1', { + preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }, + }); + expect(ev('=Preis + 1', host)).toBe(43); + }); + + it('matching is case-insensitive and trimmed, like a table qualifier', () => { + const host = makeHost({ S1: { B2: 42 } }, 'S1', { + preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }, + }); + expect(ev('=PREIS', host)).toBe(42); + }); + + it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => { + const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', { + bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }, + }); + expect(ev('=SUM(Bereich)', host)).toBe(15); + }); + + it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => { + const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', { + bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }, + }); + expect(isError(ev('=Bereich', host))).toBe(true); + }); + + it('an unknown name is #NAME?', () => { + expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true); + }); + + it('resolves through another table via its qualifier', () => { + const host = makeHost({ S2: { C3: 7 } }, 'S1', { + andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } }, + }); + expect(ev('=Andernorts * 2', host)).toBe(14); + }); +}); + describe('evaluator XLOOKUP', () => { const textHost = makeHost({ S1: { diff --git a/src/engine/__tests__/lexer.spec.ts b/src/engine/__tests__/lexer.spec.ts index 795f74f..2c3a53b 100644 --- a/src/engine/__tests__/lexer.spec.ts +++ b/src/engine/__tests__/lexer.spec.ts @@ -18,12 +18,13 @@ describe('lexer', () => { }); it('tokenizes operators and punctuation', () => { - expect(types('+-*/^():;!')).toEqual([ + expect(types('+-*/^&():;!')).toEqual([ 'PLUS', 'MINUS', 'STAR', 'SLASH', 'CARET', + 'AMP', 'LPAREN', 'RPAREN', 'COLON', @@ -66,7 +67,7 @@ describe('lexer', () => { }); it('throws on an unexpected character', () => { - expect(() => tokenize('A1 & B2')).toThrow(); + expect(() => tokenize('A1 @ B2')).toThrow(); }); it('tokenizes assignment/equality and comparison operators', () => { diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index 3daf67d..882f17c 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -28,6 +28,8 @@ function sexpr(n: Node): string { return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`; case 'param': return `param(${n.name})`; + case 'namedref': + return `namedref(${n.name})`; case 'blockcall': // never appears in freshly-parsed input (see parser.ts's comment on // BlockCallNode) -- exhaustiveness only, no test exercises this. @@ -84,6 +86,15 @@ describe('parser precedence & associativity', () => { '(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))', ); }); + + it('& binds looser than + - but tighter than a comparison', () => { + expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))'); + expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")'); + }); + + it('& is left-associative', () => { + expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")'); + }); }); describe('parser references', () => { @@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () => expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError); }); - it('without paramNames, ordinary cell-formula behaviour is unchanged', () => { - // same string, no param list -- "revenue" doesn't look like a cell -> still throws - expect(() => parseFormula('=revenue')).toThrow(ParseError); + it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => { + // same string, no param list -- "revenue" doesn't look like a cell, but + // this is now an ordinary top-level formula, where that's a named-range + // candidate (see the "named ranges" describe block below) rather than a + // ParseError -- unlike inside a function body (the case just above), + // where the fallback is deliberately off. + expect(parse('=revenue')).toBe('namedref(revenue)'); }); it('a param can mix with real cell references in the same formula', () => { @@ -198,8 +213,32 @@ describe('parser errors', () => { it('throws on an unbalanced paren', () => { expect(() => parseFormula('=SUM(A1')).toThrow(ParseError); }); - it('throws on a non-cell identifier used as a reference', () => { - expect(() => parseFormula('=foo + 1')).toThrow(ParseError); +}); + +describe('parser named-range references (NamedRefNode)', () => { + it('a bare, unqualified, non-cell identifier parses as a namedref', () => { + expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)'); + expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)'); + }); + + it('mixes with real cell/table references and functions', () => { + expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe( + '(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))', + ); + }); + + it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => { + expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError); + expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError); + }); + + it('never a range-start: "Name:B2" is not supported', () => { + expect(() => parseFormula('=foo:B2')).toThrow(ParseError); + }); + + it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => { + expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError); + expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError); }); }); diff --git a/src/engine/__tests__/userFunctions.spec.ts b/src/engine/__tests__/userFunctions.spec.ts index 8ff91be..c382510 100644 --- a/src/engine/__tests__/userFunctions.spec.ts +++ b/src/engine/__tests__/userFunctions.spec.ts @@ -321,6 +321,18 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4); }); + it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => { + const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' }); + const wb = new Workbook( + [umsatz], + [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })], + [{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }], + ); + expect(val(wb, 's-umsatz', 'B9')).toBe(24); + wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range + expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4); + }); + it('a FOR body with multiple statements runs all of them, every iteration', () => { const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' }); const doubleSum = def({ diff --git a/src/engine/__tests__/workbook.spec.ts b/src/engine/__tests__/workbook.spec.ts index fbdcb81..96c0c05 100644 --- a/src/engine/__tests__/workbook.spec.ts +++ b/src/engine/__tests__/workbook.spec.ts @@ -138,6 +138,61 @@ describe('Workbook structural changes', () => { }); }); +describe('Workbook named ranges', () => { + function namedRangeFixture(): Workbook { + const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' }); + return new Workbook( + [umsatz], + [], + [{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }], + ); + } + + it('resolves a single-cell named range like an ordinary cell reference', () => { + const wb = namedRangeFixture(); + expect(val(wb, 's-umsatz', 'B9')).toBe(101); + }); + + it('is a dependency -- recalculates when the referenced cell changes', () => { + const wb = namedRangeFixture(); + wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500 + expect(val(wb, 's-umsatz', 'B9')).toBe(501); + }); + + it('a multi-cell named range works as a range function argument', () => { + const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' }); + const wb = new Workbook( + [umsatz], + [], + [{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }], + ); + expect(val(wb, 's-umsatz', 'B9')).toBe(300); + }); + + it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => { + const wb = namedRangeFixture(); + wb.tables[0].name = 'Verkauf'; // rename the table Preis points at + wb.rebuild(); + expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF + }); + + it('an unknown name is #NAME?', () => { + const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' }); + const wb = new Workbook([umsatz], [], []); + expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?'); + }); + + it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => { + const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' }); + const wb = new Workbook( + [umsatz], + [], + [{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }], + ); + expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?'); + }); +}); + describe('Workbook recalculation order', () => { it('evaluates precedents before dependents (chain across sheets)', () => { const wb = fixture(); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index e402cc6..f859b3b 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -11,10 +11,22 @@ * ---------------- * - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking * string is coerced; any other string yields `#VALUE!`. - * - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`. + * - Comparisons (`= <> > < >= <=`) result `1`/`0`. Two numbers (or an empty + * cell, which takes on `0`) compare numerically; two texts (or an empty + * cell, which takes on `''`) compare case-insensitively and trimmed -- + * same convention as XLOOKUP's own text matching, see evalXLookup(). A + * number against a real (non-empty) text never crossmatches: `=`/`<>` + * answer definitively false/true, `> < >= <=` have no sane answer and + * yield `#VALUE!` (see compareValues()). + * - `&` concatenates as text: a number renders plain (`String(n)`, no + * locale formatting), an empty cell renders as `''` (see toText()). * - Any error among operands / arguments propagates unchanged (first wins). * - A range may only appear as a function argument. Used in scalar position it * yields `#VALUE!`. + * - A `NamedRefNode` (a bare, unqualified name that isn't a cell) resolves + * live against `host.resolveNamedRange()` into the cell/range it stands + * for, then evaluates exactly like that reference would have (see + * `resolveNamedRef()`). Unknown name -> `#NAME?`. * - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`. * - Division by zero -> `#DIV/0!`. * @@ -27,7 +39,7 @@ * ordinary function call this can't be resolved any earlier. */ -import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser'; +import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import { getFunction } from './functions'; import { expandRange } from './refs'; import { substitute } from './userFunctions'; @@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue { if (isError(r)) return r; return Math.pow(l, r); } + if (isComparisonOp(node.op)) { + const lv = evalScalar(node.left, host); + if (isError(lv)) return lv; + const rv = evalScalar(node.right, host); + if (isError(rv)) return rv; + return compareValues(node.op, lv, rv); + } + if (node.op === '&') { + const lv = toText(evalScalar(node.left, host)); + if (isError(lv)) return lv; + const rv = toText(evalScalar(node.right, host)); + if (isError(rv)) return rv; + return lv + rv; + } const l = toNumber(evalScalar(node.left, host)); if (isError(l)) return l; const r = toNumber(evalScalar(node.right, host)); @@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue { return l * r; case '/': return r === 0 ? new FormulaError('#DIV/0!') : l / r; - case '=': - return l === r ? 1 : 0; - case '<>': - return l !== r ? 1 : 0; - case '>': - return l > r ? 1 : 0; - case '<': - return l < r ? 1 : 0; - case '>=': - return l >= r ? 1 : 0; - case '<=': - return l <= r ? 1 : 0; } return new FormulaError('#ERR', `unknown operator ${node.op}`); } @@ -100,6 +114,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue { // A bare range can't collapse to a scalar. return new FormulaError('#VALUE!', 'range used where a value is expected'); + case 'namedref': { + const resolved = resolveNamedRef(node, host); + if (isError(resolved)) return resolved; + return evalScalar(resolved, host); // a resolved 1x1 range is a 'cell', so this recursion terminates + } + 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 @@ -390,7 +410,7 @@ function execStatement( } case 'for': { const source = substitute(stmt.source, env); - const cells = rangeOrCellToCellNodes(source, stmt.varName); + const cells = rangeOrCellToCellNodes(source, stmt.varName, host); for (const cell of cells) { budget.steps++; if (budget.steps > MAX_LOOP_STEPS) { @@ -405,12 +425,21 @@ function execStatement( } /** A FOR loop's source, once names are resolved against the current - * environment, must be a single cell or a range -- never a computed value - * (mirrors "a range may only appear as a function argument" for ordinary - * formulas). Expands a range to its individual cells via `expandRange` - * (the same helper `evalArg` uses for a range function argument), carrying - * the same table/sheet qualifier through to each. */ -function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] { + * environment, must be a single cell, a range, or a named range -- never a + * computed value (mirrors "a range may only appear as a function argument" + * for ordinary formulas). Expands a range to its individual cells via + * `expandRange` (the same helper `evalArg` uses for a range function + * argument), carrying the same table/sheet qualifier through to each. A + * named range resolves first (via `host`), then falls into the same + * cell/range handling as any other reference. */ +function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHost): CellNode[] { + if (node.kind === 'namedref') { + const resolved = resolveNamedRef(node, host); + if (isError(resolved)) { + throw new Error(`FOR ${loopVarName} IN ...: unbekannter Name '${node.name}'`); + } + node = resolved; + } if (node.kind === 'cell') return [node]; if (node.kind === 'range') { return expandRange(node.start, node.end).map( @@ -428,6 +457,11 @@ function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] { /** Evaluate one function argument into a flat list of values (ranges expand). */ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { + if (node.kind === 'namedref') { + const resolved = resolveNamedRef(node, host); + if (isError(resolved)) return resolved; + return evalArg(resolved, host); // now a plain 'cell'/'range', handled below/by evalScalar + } if (node.kind === 'range') { const tableId = node.qualifier == null @@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { return [v]; } +/** Resolve a `NamedRefNode` to the concrete `CellNode`/`RangeNode` it stands + * for right now (via `host.resolveNamedRange()`), so every other place that + * already knows how to evaluate a cell/range (`evalScalar`, `evalArg`, + * `rangeOrCellToCellNodes`) can just fall through to that existing logic + * instead of duplicating it. `#NAME?` when the name is unknown -- the same + * error class an unknown function name gets. */ +function resolveNamedRef(node: NamedRefNode, host: EngineHost): CellNode | RangeNode | FormulaError { + const def = host.resolveNamedRange(node.name); + if (!def) return new FormulaError('#NAME?', `unknown name '${node.name}'`); + const sheetField = def.sheetQualifier === undefined ? {} : { sheetQualifier: def.sheetQualifier }; + if (def.start.col === def.end.col && def.start.row === def.end.row) { + return { kind: 'cell', qualifier: def.qualifier, ...sheetField, col: def.start.col, row: def.start.row }; + } + return { kind: 'range', qualifier: def.qualifier, ...sheetField, start: def.start, end: def.end }; +} + /** Coerce a cell value to a number for arithmetic, or return an error value. */ function toNumber(v: CellValue): number | FormulaError { if (isError(v)) return v; @@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError { const n = Number(trimmed); return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`); } + +/** Coerce a cell value to text for `&` concatenation, or return an error + * value. An empty cell becomes `''` (not `'0'`), matching how it's ignored + * everywhere else in this engine rather than treated as a literal number. + * A number renders plain (`String(n)`, no thousands separator/locale -- + * same "locale-neutral" convention as CSV/XLSX export), never `Intl`. */ +function toText(v: CellValue): string | FormulaError { + if (isError(v)) return v; + if (v === null) return ''; + return typeof v === 'string' ? v : String(v); +} + +type ComparisonOp = '=' | '<>' | '>' | '<' | '>=' | '<='; + +function isComparisonOp(op: string): op is ComparisonOp { + return op === '=' || op === '<>' || op === '>' || op === '<' || op === '>=' || op === '<='; +} + +/** `= <> > < >= <=` on two already-evaluated scalars -- see the module + * comment's "Comparisons" bullet for the full rule. An empty cell (`null`) + * takes on whichever type the other side is (`0` for a number, `''` for + * text), so both `=A1=0` and `=A1=""` work on a genuinely empty `A1`. Text + * is matched the same way XLOOKUP matches text (case-insensitive, trimmed) + * -- kept as one convention across the engine rather than two. */ +function compareValues(op: ComparisonOp, l: CellValue, r: CellValue): CellValue { + const lIsText = typeof l === 'string'; + const rIsText = typeof r === 'string'; + + // A real number against real (non-empty) text is a genuine type mismatch + // -- an empty cell (null) is handled below, taking on whichever type the + // OTHER side is, so it never reaches this branch. + if ((typeof l === 'number' && rIsText) || (lIsText && typeof r === 'number')) { + if (op === '=') return 0; + if (op === '<>') return 1; + return new FormulaError('#VALUE!', 'text and number are not comparable with > < >= <='); + } + + if (lIsText || rIsText) { + const lt = (l ?? '').toString().trim().toLowerCase(); + const rt = (r ?? '').toString().trim().toLowerCase(); + return applyCompare(op, lt, rt); + } + const ln = l === null ? 0 : (l as number); + const rn = r === null ? 0 : (r as number); + return applyCompare(op, ln, rn); +} + +function applyCompare(op: ComparisonOp, l: number, r: number): CellValue; +function applyCompare(op: ComparisonOp, l: string, r: string): CellValue; +function applyCompare(op: ComparisonOp, l: number | string, r: number | string): CellValue { + switch (op) { + case '=': + return l === r ? 1 : 0; + case '<>': + return l !== r ? 1 : 0; + case '>': + return l > r ? 1 : 0; + case '<': + return l < r ? 1 : 0; + case '>=': + return l >= r ? 1 : 0; + case '<=': + return l <= r ? 1 : 0; + } +} diff --git a/src/engine/functions.ts b/src/engine/functions.ts index 5d0c4b0..a19b892 100644 --- a/src/engine/functions.ts +++ b/src/engine/functions.ts @@ -56,6 +56,13 @@ export const FUNCTIONS: Record = { 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 @@ -142,6 +149,8 @@ export const FUNCTIONS: Record = { // 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 @@ -156,7 +165,9 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = { name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' }, { name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' }, { name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' }, - { name: 'COUNT2', hint: 'COUNT(Text1; 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: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' }, { name: 'TIME', hint: 'TIME(Std; Min; Sek)' }, diff --git a/src/engine/index.ts b/src/engine/index.ts index a3e7b40..a7f5a8e 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -14,6 +14,7 @@ export { type Node, type CellNode, type RangeNode, + type NamedRefNode, type BlockNode, } from './parser'; export { evaluate } from './evaluator'; @@ -63,4 +64,5 @@ export { type CellValue, type Scalar, type EngineHost, + type NamedRangeDef, } from './types'; diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index e16ed8b..1214837 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -18,6 +18,7 @@ export type TokenType = | 'STAR' | 'SLASH' | 'CARET' + | 'AMP' // "&" -- text concatenation | 'LPAREN' | 'RPAREN' | 'SEMICOLON' @@ -48,6 +49,7 @@ const SINGLE: Record = { '*': 'STAR', '/': 'SLASH', '^': 'CARET', + '&': 'AMP', '(': 'LPAREN', ')': 'RPAREN', ';': 'SEMICOLON', diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 36eab52..d29d942 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -7,7 +7,7 @@ * formula = "=" expression ; * * expression = comparison ; - * comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; + * comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; * (* at most one comparison, * not chainable like * "a < b < c"; mainly @@ -15,6 +15,12 @@ * function body's IF * condition, but valid * in any formula *) + * concat = additive { "&" additive } ; (* left assoc; text + * concatenation, binds + * looser than "+ -" but + * tighter than a + * comparison -- same + * slot Excel gives "&" *) * additive = term { ("+" | "-") term } ; (* left assoc *) * term = unary { ("*" | "/") unary } ; (* left assoc *) * unary = ("+" | "-") unary @@ -58,15 +64,21 @@ * see reference() below). *) * CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; * - * Precedence, lowest to highest: comparison < + - < * / < unary +/- < ^ + * Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^ * --------------------------------------------------------------------------- * * `primary` also accepts a bare IDENT as a *parameter reference* -- but only * when parseFormula() is called with a `paramNames` list (used to parse a * user-defined function's body; see src/engine/userFunctions.ts). Without - * that list -- i.e. for every ordinary cell formula -- a bare identifier - * that doesn't look like a cell address is still a ParseError, exactly as - * before. + * that list -- i.e. for every ordinary cell formula -- a bare, unqualified + * IDENT that doesn't look like a cell address is instead a *named-range* + * reference (`NamedRefNode`, see below): the parser has no opinion on + * whether the name actually exists (same as it never validates a table + * qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation + * time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell + * identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is + * always unqualified, so a qualifier always means "this must be a real + * cell". * --------------------------------------------------------------------------- */ @@ -81,6 +93,7 @@ export type Node = | FuncNode | CellNode | RangeNode + | NamedRefNode | ParamNode | BlockCallNode | OmittedNode; @@ -95,7 +108,7 @@ export interface StringNode { } export interface BinaryNode { kind: 'binary'; - op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<='; + op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<='; left: Node; right: Node; } @@ -131,6 +144,17 @@ export interface RangeNode { start: { col: number; row: number }; end: { col: number; row: number }; } +/** A bare, unqualified name that isn't a param and doesn't look like a cell + * -- a candidate reference to a document-wide *named range* (`Umsatz_Q1` + * instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷 + * Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't + * know the document's named ranges (same as it never validates a table + * qualifier) -- resolved live, by name, at evaluation/dependency-collection + * time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */ +export interface NamedRefNode { + kind: 'namedref'; + name: string; +} /** A reference to a user-defined function's own parameter -- only produced * when parseFormula() is given a `paramNames` list (parsing a function * body); never appears in an ordinary cell formula's AST. Resolved away by @@ -237,8 +261,19 @@ class Parser { * -- a plain expression body (parseFormula()'s normal path) never mutates * this, so its behaviour is completely unaffected. */ private readonly paramNames: Set; - constructor(private readonly toks: Token[], paramNames: readonly string[] = []) { + /** Whether a bare, unqualified, non-cell, non-param identifier falls back + * to a `NamedRefNode` (an ordinary top-level formula) instead of a hard + * `ParseError` (parsing a function body) -- see reference()'s named-range + * branch and parseFormula()'s doc comment for why function bodies opt + * out of the fallback. */ + private readonly namedRefFallback: boolean; + constructor( + private readonly toks: Token[], + paramNames: readonly string[] = [], + namedRefFallback = true, + ) { this.paramNames = new Set(paramNames.map((p) => p.toUpperCase())); + this.namedRefFallback = namedRefFallback; } private peek(): Token { @@ -364,11 +399,11 @@ class Parser { * parens, assignment RHS, ...) -- wraps `additive` with an optional * single comparison, the loosest-binding operator. */ private expression(): Node { - const left = this.additive(); + const left = this.concat(); const opType = this.comparisonOpType(); if (!opType) return left; this.next(); - return { kind: 'binary', op: opType, left, right: this.additive() }; + return { kind: 'binary', op: opType, left, right: this.concat() }; } private comparisonOpType(): BinaryNode['op'] | null { @@ -390,6 +425,19 @@ class Parser { } } + /** `&` -- text concatenation, left-associative. Binds looser than `+ -` + * (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter + * than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not + * `A1&(B1="ab")`) -- same slot Excel gives `&`. */ + private concat(): Node { + let left = this.additive(); + while (this.at('AMP')) { + this.next(); + left = { kind: 'binary', op: '&', left, right: this.additive() }; + } + return left; + } + /** The old `expression` rule -- `+`/`-`, left-associative -- renamed once * `expression` itself gained the comparison wrapper above it. */ private additive(): Node { @@ -536,6 +584,19 @@ class Parser { if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) { return { kind: 'param', name: a.value }; } + // an unqualified, non-cell-shaped bare name -- a named-range candidate + // (see NamedRefNode's doc comment), but only for an ordinary top-level + // formula (namedRefFallback); a function body keeps the strict old + // behaviour so e.g. a local used before its own declaring statement + // still fails immediately at save time instead of silently becoming a + // named-range lookup (or worse, one that happens to resolve because a + // same-named named range exists elsewhere in the document) -- a caller + // can still *pass* a named range as an argument, it just can't be typed + // bare inside the body itself. Never a range-start either way: "Name:B2" + // isn't supported, so this doesn't fall through to the ":"-range check. + if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) { + return { kind: 'namedref', name: a.value }; + } if (!looksLikeCell(a.value)) { throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`); } @@ -570,7 +631,13 @@ class Parser { * `paramNames`, when given, is a user-defined function's parameter list -- * bare identifiers matching one of them parse as a {@link ParamNode} instead * of requiring cell-address shape. Omit it (the default) for every ordinary - * cell formula; behaviour is then identical to before this existed. + * cell formula; behaviour is then identical to before this existed, *except* + * that a bare, unqualified, non-cell identifier now parses as a + * {@link NamedRefNode} (a named-range candidate) instead of a ParseError -- + * see NamedRefNode's doc comment. That fallback only applies when + * `paramNames` is omitted entirely (`undefined`) -- passing it, even as an + * empty array, means "this is a function body", where the fallback is + * deliberately off (see the Parser class's `namedRefFallback` field). */ export function parseFormula(src: string, paramNames?: readonly string[]): Node { const body = src.startsWith('=') ? src.slice(1) : src; @@ -581,7 +648,7 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node if (e instanceof LexError) throw new ParseError(e.message); throw e; } - return new Parser(toks, paramNames).parse(); + return new Parser(toks, paramNames, paramNames === undefined).parse(); } /** @@ -601,18 +668,27 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]): if (e instanceof LexError) throw new ParseError(e.message); throw e; } - return new Parser(toks, paramNames).parseBlock(); + return new Parser(toks, paramNames, false).parseBlock(); } -/** Walk an AST and collect every cell/range reference node it contains. - * For a `blockcall`, walks both its (already-resolved) `args` and every - * expression reachable inside its `block` -- conservatively: a reference - * inside a branch/loop that might not execute at runtime still counts as a - * dependency, same as an ordinary `IF`-style formula in any spreadsheet. */ -export function collectRefs(node: Node, out: Array = []): Array { +/** Walk an AST and collect every cell/range/named-range reference node it + * contains. For a `blockcall`, walks both its (already-resolved) `args` and + * every expression reachable inside its `block` -- conservatively: a + * reference inside a branch/loop that might not execute at runtime still + * counts as a dependency, same as an ordinary `IF`-style formula in any + * spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this + * function is host-agnostic (doesn't know the document's named ranges), + * same as it never resolves a table qualifier either; the caller + * (`Workbook.precedentsOf`) is the one that turns it into concrete cell + * keys via `resolveNamedRange()`. */ +export function collectRefs( + node: Node, + out: Array = [], +): Array { switch (node.kind) { case 'cell': case 'range': + case 'namedref': out.push(node); break; case 'binary': @@ -633,7 +709,7 @@ export function collectRefs(node: Node, out: Array = []): return out; } -function collectRefsInBlock(block: BlockNode, out: Array): void { +function collectRefsInBlock(block: BlockNode, out: Array): void { for (const stmt of block.statements) { switch (stmt.kind) { case 'assign': diff --git a/src/engine/types.ts b/src/engine/types.ts index c821c6e..22ebd90 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -58,6 +58,22 @@ export interface CellRef extends Coord { tableId: string; } +/** A document-defined name resolving to a fixed cell/range on a specific + * table -- fed into {@link Workbook} (see workbook.ts) so it can implement + * {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is + * no expression/formula of its own, just fixed coordinates -- so there is + * nothing that could cyclically reference itself. `tableId` (not a table + * *name*) so a named range survives its target table being renamed; the + * host resolves it to that table's *current* name on lookup, the same + * qualifier a formula's own `Tabelle!Zelle` reference would carry. */ +export interface NamedRangeDef { + id: string; + name: string; + tableId: string; + start: Coord; + end: Coord; +} + /** Stable string key for a cell across the whole document. */ export function keyOf(tableId: string, col: number, row: number): string { return `${tableId}␟${col}␟${row}`; @@ -99,6 +115,22 @@ export interface EngineHost { * (`#REF` -- the formula needs an explicit sheet qualifier). */ resolveTableName(name: string, sheetName?: string | null): string | null; + /** + * Resolve a *named range* (as written, unqualified, in a formula -- e.g. + * `Umsatz_Q1`) to the table-qualified cell/range it currently stands for. + * Matching is case-insensitive and tolerant of surrounding whitespace, + * same as {@link resolveTableName}. `null` when the name is unknown (or + * its target table has since been deleted) -- the caller turns that into + * `#NAME?`, the same error class an unknown function name gets. + * + * Resolved by *name*, live, every time -- never baked into the AST -- so a + * named range survives its target table being renamed, the same way a + * `Tabelle!Zelle` reference's table name is looked up fresh on every + * evaluation rather than resolved once at parse time. + */ + resolveNamedRange( + name: string, + ): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null; /** Dimensions of a table, used for range clamping and #REF detection. */ tableSize(tableId: string): { cols: number; rows: number } | null; /** diff --git a/src/engine/userFunctions.ts b/src/engine/userFunctions.ts index a09ffc4..5ca13a4 100644 --- a/src/engine/userFunctions.ts +++ b/src/engine/userFunctions.ts @@ -93,6 +93,7 @@ function expand( case 'string': case 'cell': case 'range': + case 'namedref': case 'param': case 'omitted': return node; // leaves -- nothing to expand @@ -180,6 +181,7 @@ export function substitute(node: Node, bound: ReadonlyMap): Node { case 'string': case 'cell': case 'range': + case 'namedref': case 'omitted': return node; case 'unary': @@ -293,16 +295,22 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string /** The first FOR loop, anywhere in the block (including nested inside * another FOR/IF), whose source isn't (syntactically) a parameter, a - * single cell, or a range -- i.e. can never resolve to a concrete list of - * cells no matter what the caller passes. Checked at save time so this is - * caught immediately, not just on the first real call; the evaluator's + * single cell, a range, or a named-range reference -- i.e. can never + * resolve to a concrete list of cells no matter what the caller passes. + * Checked at save time so this is caught immediately, not just on the + * first real call; the evaluator's * interpreter re-checks the same thing on the *substituted* source at call * time regardless (needed either way, since a parameter's shape isn't * known until then) as the actual enforcement. */ export function findInvalidForSource(block: BlockNode): ForNode | null { for (const stmt of block.statements) { if (stmt.kind === 'for') { - if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') { + if ( + stmt.source.kind !== 'param' && + stmt.source.kind !== 'cell' && + stmt.source.kind !== 'range' && + stmt.source.kind !== 'namedref' + ) { return stmt; } const nested = findInvalidForSource(stmt.body); diff --git a/src/engine/workbook.ts b/src/engine/workbook.ts index 209e977..aaee823 100644 --- a/src/engine/workbook.ts +++ b/src/engine/workbook.ts @@ -31,6 +31,7 @@ import { parseKey, type CellValue, type EngineHost, + type NamedRangeDef, } from './types'; import { evaluate } from './evaluator'; @@ -81,10 +82,17 @@ export class Workbook implements EngineHost { private readonly values = new Map(); /** user-defined function library for this document, name.toUpperCase() -> def. */ private readonly functionIndex: Map; + /** document-wide named ranges (see NamedRangeDef's doc comment). */ + private readonly namedRanges: NamedRangeDef[]; - constructor(tables: TableModel[], functionDefs: FunctionDef[] = []) { + constructor( + tables: TableModel[], + functionDefs: FunctionDef[] = [], + namedRanges: NamedRangeDef[] = [], + ) { this.tables = tables; this.functionIndex = buildFunctionIndex(functionDefs); + this.namedRanges = namedRanges; this.rebuild(); } @@ -112,6 +120,17 @@ export class Workbook implements EngineHost { return null; // still ambiguous -> #REF, needs an explicit sheet qualifier } + resolveNamedRange( + name: string, + ): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null { + const needle = name.trim().toLowerCase(); + const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle); + if (!def) return null; + const table = this.tableById(def.tableId); + if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename + return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end }; + } + tableSize(tableId: string): { cols: number; rows: number } | null { const t = this.tableById(tableId); return t ? { cols: t.cols, rows: t.rows } : null; @@ -201,13 +220,20 @@ export class Workbook implements EngineHost { this.homeTableId = tableId; const out: string[] = []; for (const ref of collectRefs(ast)) { + // a named range isn't itself a qualifier/coords pair -- resolve it to + // one first (same shape as a 'cell'/'range' ref), then fall through to + // the identical edge-wiring below. + const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref; + if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME? const target = - ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier, ref.sheetQualifier ?? null); + resolved.qualifier == null + ? tableId + : this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null); if (!target) continue; // unknown table -> no edge; evaluator emits #REF - if (ref.kind === 'cell') { - out.push(keyOf(target, ref.col, ref.row)); + if ('col' in resolved) { + out.push(keyOf(target, resolved.col, resolved.row)); } else { - for (const c of expandRange(ref.start, ref.end)) { + for (const c of expandRange(resolved.start, resolved.end)) { out.push(keyOf(target, c.col, c.row)); } } diff --git a/src/io/json.ts b/src/io/json.ts index da59159..88a4ca3 100644 --- a/src/io/json.ts +++ b/src/io/json.ts @@ -9,11 +9,13 @@ */ import type { + CellCoord, CellFormat, ChartObject, CustomFunction, DocumentState, HAlign, + NamedRange, Sheet, SheetObject, TableObject, @@ -58,6 +60,46 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] { return out; } +function isCellCoord(x: unknown): x is CellCoord { + return ( + !!x && + typeof x === 'object' && + typeof (x as CellCoord).col === 'number' && + typeof (x as CellCoord).row === 'number' + ); +} + +/** Defaults to `[]` -- absent in every document saved before this feature + * existed. Drops a malformed entry, and any entry whose `tableId` doesn't + * match a real table *in this document* (a dangling reference -- e.g. a + * hand-edited file, or a table that no longer exists) -- rather than + * leaving it to resolve to `#NAME?` forever; the live "delete a table" + * path (`document.ts`) does leave such entries in place, for consistency + * with how a deleted table's ordinary formula references are never + * auto-pruned either -- this is stricter only because *importing* JSON is + * already a validating boundary for everything else in the document. */ +function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet): NamedRange[] { + if (!Array.isArray(x)) return []; + const out: NamedRange[] = []; + for (const r of x) { + if ( + !r || + typeof r !== 'object' || + typeof (r as { id?: unknown }).id !== 'string' || + typeof (r as { name?: unknown }).name !== 'string' || + typeof (r as { tableId?: unknown }).tableId !== 'string' || + !isCellCoord((r as { start?: unknown }).start) || + !isCellCoord((r as { end?: unknown }).end) + ) { + continue; + } + const rec = r as NamedRange; + if (!tableIds.has(rec.tableId)) continue; + out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } }); + } + return out; +} + const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; function colorOrUndef(x: unknown): string | undefined { return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined; @@ -293,6 +335,7 @@ export function validateDocument(x: unknown): DocumentState { const sheetIds = new Set(); const objectIds = new Set(); + const tableIds = new Set(); for (const s of doc.sheets) { if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects)) throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.'); @@ -303,6 +346,7 @@ export function validateDocument(x: unknown): DocumentState { throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.'); if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.'); objectIds.add(o.id); + if (isTableObject(o)) tableIds.add(o.id); } } if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId)) @@ -330,6 +374,7 @@ export function validateDocument(x: unknown): DocumentState { locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE, csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',', customFunctions: normaliseCustomFunctions(doc.customFunctions), + namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds), }; } @@ -393,6 +438,7 @@ export function migrateToV2(x: unknown): DocumentState { locale: DEFAULT_LOCALE, csvDelimiter: ',', customFunctions: [], + namedRanges: [], }; } diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 73ee63e..23c5c4e 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -871,6 +871,138 @@ describe('custom functions (document-level library)', () => { }); }); +describe('named ranges (document-level names for a fixed cell/range)', () => { + it('addNamedRange stores the definition and a formula elsewhere resolves it unqualified', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '42'); + + const result = s.addNamedRange('Preis', id, coords('B2'), coords('B2')); + expect(typeof result).toBe('string'); // the new range's id, not { error } + expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toMatchObject({ tableId: id }); + + setCell(s, name, 'A1', '=Preis + 1'); + expect(val(s, name, 'A1')).toBe(43); + }); + + it('a multi-cell named range works as a range function argument', () => { + 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'); + s.addNamedRange('Bereich', id, coords('B2'), coords('B3')); + setCell(s, name, 'A1', '=SUM(Bereich)'); + expect(val(s, name, 'A1')).toBe(30); + }); + + it('rejects a name colliding with a built-in function', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const result = s.addNamedRange('SUM', id, coords('B2'), coords('B2')) as { error: string }; + expect(result.error).toBeTruthy(); + }); + + it('rejects a name colliding with an existing custom function', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + s.addCustomFunction('MARGE', ['x'], 'x'); + const result = s.addNamedRange('MARGE', id, coords('B2'), coords('B2')) as { error: string }; + expect(result.error).toBeTruthy(); + }); + + it('rejects a name colliding with another named range (case-insensitive)', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + s.addNamedRange('Preis', id, coords('B2'), coords('B2')); + const result = s.addNamedRange('preis', id, coords('C2'), coords('C2')) as { error: string }; + expect(result.error).toBeTruthy(); + }); + + it('rejects a name that looks like a cell address', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const result = s.addNamedRange('B2', id, coords('C2'), coords('C2')) as { error: string }; + expect(result.error).toBeTruthy(); + }); + + it('updateNamedRange changes name/coords and existing formulas re-resolve', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '10'); + setCell(s, name, 'C2', '99'); + const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string; + setCell(s, name, 'A1', '=Preis'); + expect(val(s, name, 'A1')).toBe(10); + + s.updateNamedRange(rid, 'Preis', id, coords('C2'), coords('C2')); + expect(val(s, name, 'A1')).toBe(99); + }); + + it('deleteNamedRange removes the definition; existing formulas become #NAME?', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1") + setCell(s, name, 'B2', '10'); + const rid = s.addNamedRange('Preis', id, coords('B2'), coords('B2')) as string; + setCell(s, name, 'A1', '=Preis'); + expect(val(s, name, 'A1')).toBe(10); + + s.deleteNamedRange(rid); + expect(s.doc.namedRanges).toHaveLength(before); + expect(s.doc.namedRanges.find((r) => r.id === rid)).toBeUndefined(); + expect(isError(val(s, name, 'A1'))).toBe(true); + }); + + it('survives its target table being renamed', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + const name = s.tableById(id)!.name; + setCell(s, name, 'B2', '10'); + s.addNamedRange('Preis', id, coords('B2'), coords('B2')); + setCell(s, name, 'A1', '=Preis'); + expect(val(s, name, 'A1')).toBe(10); + + s.renameObject(id, 'Verkauf'); + expect(val(s, 'Verkauf', 'A1')).toBe(10); // still resolves, no #NAME?/#REF + }); + + it('undo/redo covers a named-range change', () => { + vi.useFakeTimers(); + try { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + vi.advanceTimersByTime(600); // let addTable settle into its own history step first + const before = s.doc.namedRanges.length; // the seed already ships one ("Umsatz_Q1") + + s.addNamedRange('Preis', id, coords('B2'), coords('B2')); + vi.advanceTimersByTime(600); + expect(s.doc.namedRanges).toHaveLength(before + 1); + + s.undo(); + expect(s.doc.namedRanges).toHaveLength(before); + s.redo(); + expect(s.doc.namedRanges).toHaveLength(before + 1); + expect(s.doc.namedRanges.find((r) => r.name === 'Preis')).toBeTruthy(); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + + it('JSON export/import round-trips namedRanges losslessly', () => { + const s = fresh(); + const id = s.addTable(s.doc.activeSheetId); + s.addNamedRange('Preis', id, coords('B2'), coords('B2')); + const before = JSON.stringify(s.doc.namedRanges); + s.replaceDocument(parseDocument(serializeDocument(s.doc))); + expect(JSON.stringify(s.doc.namedRanges)).toBe(before); + }); +}); + 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 db93463..6e806a4 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -39,6 +39,7 @@ import { type TableModel, type Token, type FunctionDef, + type NamedRangeDef, } from '../engine'; import { formatValue, @@ -104,6 +105,16 @@ function toFunctionDefs(doc: DocumentState): FunctionDef[] { })); } +function toNamedRangeDefs(doc: DocumentState): NamedRangeDef[] { + return doc.namedRanges.map((r) => ({ + id: r.id, + name: r.name, + tableId: r.tableId, + start: r.start, + end: r.end, + })); +} + /** A normalised rectangle (corners in either order) used by selection + clipboard. */ type Rect = { c0: number; c1: number; r0: number; r1: number }; @@ -210,7 +221,7 @@ function loadInitial(): DocumentState { function createStore() { const doc = reactive(loadInitial()); - let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc)); + let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc)); const recalcTick = ref(0); const bump = () => (recalcTick.value += 1); @@ -230,7 +241,7 @@ function createStore() { const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20)); function syncWorkbook() { - wb = new Workbook(toTableModels(doc), toFunctionDefs(doc)); + wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc)); bump(); } @@ -280,6 +291,7 @@ function createStore() { locale: d.locale, csvDelimiter: d.csvDelimiter, customFunctions: d.customFunctions, + namedRanges: d.namedRanges, }); let historySnapshot = snapshot(); let historyFingerprint = contentFingerprint(historySnapshot); @@ -865,6 +877,89 @@ function createStore() { syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table } + // ---- named ranges (document-level names for a fixed cell/range) ------- + // Created/edited/removed exclusively through the "🏷 Namen" UI. Unlike a + // custom function there is no expression of its own -- just fixed + // coordinates on a specific table -- so there's nothing that could + // cyclically reference itself, and no "does every path RETURN" check + // either. `tableId` (not the table's name) is what's stored, so a named + // range survives its target table being *renamed* (see + // engine/workbook.ts's `resolveNamedRange`); only an outright *deletion* + // of that table leaves it dangling -> #NAME? on the next rebuild, same + // "nothing is auto-rewritten" convention as a plain formula reference to a + // deleted table. + function validateNamedRange( + name: string, + tableId: string, + start: CellCoord, + end: CellCoord, + ignoreId: string | null, + ): string | null { + const cleanName = name.trim(); + if (!IDENT_RE.test(cleanName)) { + return `"${name}" ist kein gültiger Name.`; + } + if (looksLikeCell(cleanName)) { + return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Name nicht erlaubt.`; + } + if (RESERVED_NAMES.has(cleanName.toUpperCase())) { + return `"${cleanName}" ist ein reserviertes Schlüsselwort (FOR/IN/DO/RETURN) und kann nicht als Name verwendet werden.`; + } + if (hasFunction(cleanName)) { + return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`; + } + const fnCollision = doc.customFunctions.find( + (f) => f.name.toLowerCase() === cleanName.toLowerCase(), + ); + if (fnCollision) return `Es gibt bereits eine Funktion namens "${fnCollision.name}".`; + const rangeCollision = doc.namedRanges.find( + (r) => r.id !== ignoreId && r.name.toLowerCase() === cleanName.toLowerCase(), + ); + if (rangeCollision) return `Es gibt bereits einen Namen "${rangeCollision.name}".`; + if (!tableById(tableId)) return 'Tabelle nicht gefunden.'; + if (start.col < 0 || start.row < 0 || end.col < 0 || end.row < 0) { + return 'Ungültiger Bereich.'; + } + return null; + } + + function addNamedRange( + name: string, + tableId: string, + start: CellCoord, + end: CellCoord, + ): string | { error: string } { + const error = validateNamedRange(name, tableId, start, end, null); + if (error) return { error }; + const id = uid('nr'); + doc.namedRanges.push({ id, name: name.trim(), tableId, start: { ...start }, end: { ...end } }); + syncWorkbook(); + return id; + } + + function updateNamedRange( + id: string, + name: string, + tableId: string, + start: CellCoord, + end: CellCoord, + ): void | { error: string } { + const r = doc.namedRanges.find((x) => x.id === id); + if (!r) return { error: 'Name nicht gefunden.' }; + const error = validateNamedRange(name, tableId, start, end, id); + if (error) return { error }; + r.name = name.trim(); + r.tableId = tableId; + r.start = { ...start }; + r.end = { ...end }; + syncWorkbook(); + } + + function deleteNamedRange(id: string) { + doc.namedRanges = doc.namedRanges.filter((r) => r.id !== id); + syncWorkbook(); // affected formulas become #NAME? on rebuild, same as a deleted table + } + function moveObject(objId: string, x: number, y: number) { const o = objectById(objId); if (!o) return; @@ -1616,6 +1711,7 @@ function createStore() { doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE; doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ','; doc.customFunctions = next.customFunctions ?? []; + doc.namedRanges = next.namedRanges ?? []; syncWorkbook(); } /** Loading a different document outright (JSON import "replace", "reset to @@ -1710,6 +1806,9 @@ function createStore() { addCustomFunction, updateCustomFunction, deleteCustomFunction, + addNamedRange, + updateNamedRange, + deleteNamedRange, resizeChart, bringToFront, setChartView, diff --git a/src/store/types.ts b/src/store/types.ts index d7a3dea..94cee01 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -173,6 +173,25 @@ export interface CustomFunction { kind: 'expression' | 'block'; } +/** A document-wide name that stands for a fixed cell/range on a specific + * table (`Umsatz_Q1` instead of `Umsatz!B2:B13`), usable unqualified in any + * ordinary top-level formula (not inside a custom function's own body -- + * see engine/parser.ts's `namedRefFallback`). Created, edited and removed + * exclusively through the "🏷 Namen" UI. Unlike a `CustomFunction`, there is + * no expression to evaluate, just fixed coordinates -- so no cycle + * detection is needed. `tableId` (not the table's *name*) so a named range + * survives its target table being renamed -- see + * engine/workbook.ts's `resolveNamedRange`. */ +export interface NamedRange { + id: string; + /** document-unique, compared case-insensitively; never a built-in/custom + * function name, never shaped like a cell reference (A1, $B2, ...). */ + name: string; + tableId: string; + start: CellCoord; + end: CellCoord; +} + /** One entry in the formula-autocomplete dropdown (see document.ts's * `functionSuggestions()`). */ export interface FnSuggestion { @@ -191,4 +210,5 @@ export interface DocumentState { locale: NumberLocale; csvDelimiter: ',' | ';'; customFunctions: CustomFunction[]; + namedRanges: NamedRange[]; }