Text-Vergleich, &-Verkettung und benannte Bereiche

- 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 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 21:59:47 +02:00
co-authored by Claude Sonnet 5
parent 9015a991aa
commit 2c54537427
22 changed files with 1460 additions and 86 deletions
+196 -17
View File
@@ -258,6 +258,115 @@ ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details: nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt. [`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 ### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im 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 ; formula = "=" expression ;
expression = comparison ; expression = comparison ;
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht ein Vergleich, nicht
verkettbar wie verkettbar wie
"a < b < c"; vor allem "a < b < c"; vor allem
@@ -335,6 +444,12 @@ comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchsten
„Mit Schleife"-Code „Mit Schleife"-Code
(s. o.), aber in jeder (s. o.), aber in jeder
Formel nutzbar *) 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 *) additive = term { ("+" | "-") term } ; (* links-assoziativ *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *) term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary unary = ("+" | "-") unary
@@ -342,10 +457,19 @@ unary = ("+" | "-") unary
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
stärker als unäres Minus, stärker als unäres Minus,
d.h. -2^2 = -(2^2) = -4 *) d.h. -2^2 = -(2^2) = -4 *)
primary = NUMBER | STRING | "(" expression ")" | funcall | reference ; primary = NUMBER | STRING | "(" expression ")" | funcall | reference | namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ; 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 ] ; reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *) qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt, sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
@@ -362,9 +486,38 @@ QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
``` ```
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` <
unäres `+ -` < `^`. 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 `::` Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**: sind dabei austauschbar und **case-/whitespace-tolerant**:
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`. `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. das Dropdown, ohne die Bearbeitung selbst abzubrechen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`), **Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)`, `DATE(Jahr; Monat; `MIN`, `MAX`, `COUNT`, `CONCAT` (Alias `CONCATENATE`), `IF(Bedingung; Dann;
Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`, `XLOOKUP(Suchwert; Sonst?)`, `DATE(Jahr; Monat; Tag)`, `TIME(Std; Min; Sek)`, `TODAY()`, `NOW()`,
Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; `XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
[Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten [Vergleichsmodus]; [Suchmodus])` — plus alle über **„𝑓 Funktionen"** angelegten
benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben),
die sich für den Aufrufer identisch verhalten. Argumente gemischt: die sich für den Aufrufer identisch verhalten. Argumente gemischt:
Einzelzellen, Bereiche (lokal oder `Name!…`), Zahl-Literale — z. B. 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 Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
v1→v2-Migration. v1→v2-Migration.
- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz - **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 - **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen `FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF` 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. reaktiv), **kein** echter Web Worker.
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von - **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
Quotes); exotische Excel-Formeln fallen auf den Wert zurück. Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
- Kein Text-Verkettungsoperator, keine benannten Bereiche (nur - Kein Snapping der Canvas-Objekte.
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein - **Benannte Bereiche** bewusst ohne: Nutzung innerhalb einer
Snapping der Canvas-Objekte. 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- - **Datum & Zeit**: die automatische Erkennung beim Eintippen erfasst nur ISO-
und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs. und Punkt-Daten, absichtlich **nicht** Schrägstrich-Daten (Tag-zuerst vs.
Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar, 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 zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei
reiner Cursor-Bewegung ohne Tippen. reiner Cursor-Bewegung ohne Tippen.
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`, - **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren nur ein Vergleich pro Ausdruck), `AND`/`OR`/`NOT` für zusammengesetzte
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen, Bedingungen, `BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg),
`BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar). sichtbar). Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger
Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in `IF`-
dagegen unterstützt (s. o.). 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 ## Projektstruktur
+11
View File
@@ -5,6 +5,7 @@ import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue'; import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue'; import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import TheLogo from './TheLogo.vue'; import TheLogo from './TheLogo.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand'; import { WICOM, WICOM_SOCIAL } from '../config/brand';
import { import {
@@ -29,6 +30,7 @@ const toasts = useToasts();
const fileMenuOpen = ref(false); const fileMenuOpen = ref(false);
const aboutOpen = ref(false); const aboutOpen = ref(false);
const functionsOpen = ref(false); const functionsOpen = ref(false);
const namedRangesOpen = ref(false);
const busy = ref(false); const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -249,6 +251,14 @@ function onReset() {
> >
𝑓 𝑓
</button> </button>
<button
class="btn accent icon-btn"
title="Benannte Bereiche"
aria-label="Benannte Bereiche"
@click="namedRangesOpen = true"
>
🏷
</button>
<div class="spacer" /> <div class="spacer" />
@@ -343,6 +353,7 @@ function onReset() {
<!-- Benutzerdefinierte Funktionen --> <!-- Benutzerdefinierte Funktionen -->
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" /> <CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<!-- Über / Impressum --> <!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false"> <BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
+291
View File
@@ -0,0 +1,291 @@
<script setup lang="ts">
/**
* "🏷 Namen" -- the exclusive UI for creating, viewing, editing and deleting
* the document's named ranges (a document-wide name standing for a fixed
* cell/range on a specific table, usable unqualified in any top-level
* formula -- see src/store/types.ts's `NamedRange`). Mirrors
* CustomFunctionsModal.vue's list/form structure.
*/
import { computed, reactive, ref, watch } from 'vue';
import { looksLikeCell, parseA1, toA1 } from '../engine';
import { useDocument } from '../store/document';
import type { TableObject } from '../store/types';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const store = useDocument();
type Mode = 'list' | 'edit';
const mode = ref<Mode>('list');
const editingId = ref<string | null>(null);
const form = reactive({ name: '', tableId: '', start: '', end: '' });
const formError = ref<string | null>(null);
const allTables = computed(() =>
store.doc.sheets.flatMap((s) =>
s.objects
.filter((o): o is TableObject => o.kind === 'table')
.map((t) => ({ id: t.id, name: t.name, sheetName: s.name })),
),
);
function tableLabel(tableId: string): string {
const t = allTables.value.find((x) => x.id === tableId);
return t ? `${t.name} (${t.sheetName})` : '?';
}
/** e.g. "B2" or "B2:B6" (single cell when start === end). */
function rangeLabel(r: { start: { col: number; row: number }; end: { col: number; row: number } }): string {
const a = toA1(r.start.col, r.start.row);
const b = toA1(r.end.col, r.end.row);
return a === b ? a : `${a}:${b}`;
}
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
// -progress never lingers into the next open.
watch(
() => props.open,
(o) => {
if (o) {
mode.value = 'list';
formError.value = null;
}
},
);
function openNew() {
editingId.value = null;
formError.value = null;
// prefill from the current cell selection, if any -- "create name from
// selection", same idea as Excel's own shortcut for this.
const sel = store.doc.selection;
if (sel) {
const rect = store.selectionRects(sel)[0];
form.tableId = sel.tableId;
form.start = toA1(rect.c0, rect.r0);
form.end = toA1(rect.c1, rect.r1);
} else {
form.tableId = allTables.value[0]?.id ?? '';
form.start = '';
form.end = '';
}
form.name = '';
mode.value = 'edit';
}
function openEdit(id: string) {
const r = store.doc.namedRanges.find((x) => x.id === id);
if (!r) return;
editingId.value = id;
form.name = r.name;
form.tableId = r.tableId;
form.start = toA1(r.start.col, r.start.row);
form.end = toA1(r.end.col, r.end.row);
formError.value = null;
mode.value = 'edit';
}
function save() {
const startTok = form.start.trim();
const endTok = form.end.trim() || startTok;
if (!looksLikeCell(startTok)) {
formError.value = `"${form.start}" ist kein gültiger Zellbezug.`;
return;
}
if (!looksLikeCell(endTok)) {
formError.value = `"${form.end}" ist kein gültiger Zellbezug.`;
return;
}
const start = parseA1(startTok);
const end = parseA1(endTok);
const result = editingId.value
? store.updateNamedRange(editingId.value, form.name, form.tableId, start, end)
: store.addNamedRange(form.name, form.tableId, start, end);
if (result && typeof result === 'object' && 'error' in result) {
formError.value = result.error;
return;
}
formError.value = null;
mode.value = 'list';
}
function cancelEdit() {
formError.value = null;
mode.value = 'list';
}
function removeRange(id: string, name: string) {
if (confirm(`Name "${name}" löschen? Formeln, die ihn verwenden, zeigen danach #NAME?.`)) {
store.deleteNamedRange(id);
}
}
function close() {
emit('close');
}
</script>
<template>
<BaseModal
:open="open"
:title="mode === 'list' ? 'Benannte Bereiche' : editingId ? 'Namen bearbeiten' : 'Neuer Name'"
@close="close"
>
<!-- ---- list view ---- -->
<template v-if="mode === 'list'">
<p v-if="!store.doc.namedRanges.length" class="hint">
Noch keine benannten Bereiche definiert. Mit „+ Neuer Name" einen anlegen und danach
wie <code>=Umsatz_Q1</code> in jeder Formel verwenden, ohne Tabellen-Qualifier.
</p>
<ul v-else class="nr-list">
<li v-for="r in store.doc.namedRanges" :key="r.id" class="nr-row">
<code class="nr-sig">
{{ r.name }} → {{ tableLabel(r.tableId) }}!{{ rangeLabel(r) }}
</code>
<span class="nr-actions">
<button class="mini" @click="openEdit(r.id)">Bearb.</button>
<button class="mini danger" title="Löschen" @click="removeRange(r.id, r.name)">🗑</button>
</span>
</li>
</ul>
</template>
<!-- ---- edit / create form ---- -->
<template v-else>
<label class="row">
Name
<input v-model="form.name" type="text" placeholder="z. B. Umsatz_Q1" />
</label>
<label class="row">
Tabelle
<select v-model="form.tableId">
<option v-for="t in allTables" :key="t.id" :value="t.id">
{{ t.name }} ({{ t.sheetName }})
</option>
</select>
</label>
<div class="row range-row">
<span class="range-label">Bereich</span>
<div class="range-fields">
<input v-model="form.start" type="text" placeholder="B2" class="cell-input" />
<span class="range-sep">:</span>
<input v-model="form.end" type="text" placeholder="B6 (leer = nur B2)" class="cell-input" />
</div>
</div>
<p class="hint">
Eine leere zweite Zelle bedeutet „nur die erste Zelle" (kein Bereich). Der Name ist
danach in jeder Formel unqualifiziert nutzbar, z. B. <code>=SUM(Umsatz_Q1)</code> oder
<code>=Umsatz_Q1 + 1</code> für eine einzelne Zelle. Er überlebt eine spätere Umbenennung
der Tabelle; wird die Tabelle gelöscht, zeigen Formeln, die den Namen nutzen, danach
<code>#NAME?</code>.
</p>
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
</template>
<template #footer>
<template v-if="mode === 'list'">
<button class="primary" @click="openNew">+ Neuer Name</button>
</template>
<template v-else>
<button @click="cancelEdit">Abbrechen</button>
<button class="primary" @click="save">Speichern</button>
</template>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 0;
}
.hint code {
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
}
.nr-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nr-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
}
.nr-sig {
font-size: 12.5px;
font-family: inherit;
overflow-wrap: anywhere;
}
.nr-actions {
display: flex;
gap: 6px;
flex: none;
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input[type='text'],
.row select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
background: var(--surface-2);
color: var(--text);
}
.range-fields {
display: flex;
align-items: center;
gap: 6px;
}
.cell-input {
width: 120px;
}
.range-sep {
color: var(--text-dim);
}
.form-error {
margin: 4px 0 0;
font-size: 12.5px;
color: #dc2626;
line-height: 1.4;
}
</style>
+5
View File
@@ -136,5 +136,10 @@ export function createSeedDocument(): DocumentState {
locale: 'de-DE', locale: 'de-DE',
csvDelimiter: ',', csvDelimiter: ',',
customFunctions: [], 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 } },
],
}; };
} }
+99 -9
View File
@@ -35,11 +35,14 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
formula = "=" expression ; formula = "=" expression ;
expression = comparison ; expression = comparison ;
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht ein Vergleich, nicht
verkettbar; Ergebnis verkettbar; Ergebnis
1/0 (siehe Auswertungs- 1/0 (siehe Auswertungs-
modell unten) *) modell unten) *)
concat = additive { "&" additive } ; (* links-assoziativ; Text-
Verkettung, zwischen
Vergleich und "+ -" *)
additive = term { ("+" | "-") term } ; (* links-assoziativ; die additive = term { ("+" | "-") term } ; (* links-assoziativ; die
alte "expression"-Regel, alte "expression"-Regel,
bevor Vergleiche bevor Vergleiche
@@ -54,10 +57,16 @@ primary = NUMBER
| STRING | STRING
| "(" expression ")" | "(" expression ")"
| funcall | funcall
| reference ; | reference
| namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ; 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 ] ; reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
Numbers-Stil "::" sind Numbers-Stil "::" sind
@@ -78,7 +87,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; 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` `$` 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 (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 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 eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers** `ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie verwendet (`userFunctions.ts`).
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
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") ## 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, derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`. `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'`) ## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
Eine zweite, eigene Grammatik nur für Funktionskörper mit `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 - **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`. zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei - **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
`+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
jeder Formel gültig. 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 - **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
gefundene Fehler gewinnt). gefundene Fehler gewinnt).
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position - Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
ergibt er `#VALUE!`. 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!`. - `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. - `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`. - `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
+123 -1
View File
@@ -3,10 +3,15 @@ import { parseFormula } from '../parser';
import { evaluate } from '../evaluator'; import { evaluate } from '../evaluator';
import { FormulaError, isError, type CellValue, type EngineHost } from '../types'; 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( function makeHost(
data: Record<string, Record<string, CellValue>>, data: Record<string, Record<string, CellValue>>,
home = 'S1', home = 'S1',
namedRanges: Record<string, NamedRangeShape> = {},
): EngineHost { ): EngineHost {
const sizes: Record<string, { cols: number; rows: number }> = { const sizes: Record<string, { cols: number; rows: number }> = {
S1: { cols: 10, rows: 10 }, S1: { cols: 10, rows: 10 },
@@ -21,6 +26,7 @@ function makeHost(
return { return {
homeTableId: home, homeTableId: home,
resolveTableName: idFor, resolveTableName: idFor,
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
tableSize: (id) => sizes[id] ?? null, tableSize: (id) => sizes[id] ?? null,
getComputed(tableId, col, row) { getComputed(tableId, col, row) {
const cell = String.fromCharCode(65 + col) + (row + 1); 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', () => { describe('evaluator XLOOKUP', () => {
const textHost = makeHost({ const textHost = makeHost({
S1: { S1: {
+3 -2
View File
@@ -18,12 +18,13 @@ describe('lexer', () => {
}); });
it('tokenizes operators and punctuation', () => { it('tokenizes operators and punctuation', () => {
expect(types('+-*/^():;!')).toEqual([ expect(types('+-*/^&():;!')).toEqual([
'PLUS', 'PLUS',
'MINUS', 'MINUS',
'STAR', 'STAR',
'SLASH', 'SLASH',
'CARET', 'CARET',
'AMP',
'LPAREN', 'LPAREN',
'RPAREN', 'RPAREN',
'COLON', 'COLON',
@@ -66,7 +67,7 @@ describe('lexer', () => {
}); });
it('throws on an unexpected character', () => { it('throws on an unexpected character', () => {
expect(() => tokenize('A1 & B2')).toThrow(); expect(() => tokenize('A1 @ B2')).toThrow();
}); });
it('tokenizes assignment/equality and comparison operators', () => { it('tokenizes assignment/equality and comparison operators', () => {
+44 -5
View File
@@ -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})`; return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
case 'param': case 'param':
return `param(${n.name})`; return `param(${n.name})`;
case 'namedref':
return `namedref(${n.name})`;
case 'blockcall': case 'blockcall':
// never appears in freshly-parsed input (see parser.ts's comment on // never appears in freshly-parsed input (see parser.ts's comment on
// BlockCallNode) -- exhaustiveness only, no test exercises this. // 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))', '(- (* (+ 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', () => { describe('parser references', () => {
@@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () =>
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError); expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
}); });
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => { 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 -> still throws // same string, no param list -- "revenue" doesn't look like a cell, but
expect(() => parseFormula('=revenue')).toThrow(ParseError); // 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', () => { 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', () => { it('throws on an unbalanced paren', () => {
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError); 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);
}); });
}); });
@@ -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); 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', () => { 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 umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
const doubleSum = def({ const doubleSum = def({
+55
View File
@@ -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', () => { describe('Workbook recalculation order', () => {
it('evaluates precedents before dependents (chain across sheets)', () => { it('evaluates precedents before dependents (chain across sheets)', () => {
const wb = fixture(); const wb = fixture();
+136 -21
View File
@@ -11,10 +11,22 @@
* ---------------- * ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking * - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`. * 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). * - Any error among operands / arguments propagates unchanged (first wins).
* - A range may only appear as a function argument. Used in scalar position it * - A range may only appear as a function argument. Used in scalar position it
* yields `#VALUE!`. * 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?`. * - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - Division by zero -> `#DIV/0!`. * - Division by zero -> `#DIV/0!`.
* *
@@ -27,7 +39,7 @@
* ordinary function call this can't be resolved any earlier. * 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 { getFunction } from './functions';
import { expandRange } from './refs'; import { expandRange } from './refs';
import { substitute } from './userFunctions'; import { substitute } from './userFunctions';
@@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
if (isError(r)) return r; if (isError(r)) return r;
return Math.pow(l, 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)); const l = toNumber(evalScalar(node.left, host));
if (isError(l)) return l; if (isError(l)) return l;
const r = toNumber(evalScalar(node.right, host)); const r = toNumber(evalScalar(node.right, host));
@@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return l * r; return l * r;
case '/': case '/':
return r === 0 ? new FormulaError('#DIV/0!') : l / r; 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}`); 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. // A bare range can't collapse to a scalar.
return new FormulaError('#VALUE!', 'range used where a value is expected'); 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': { case 'func': {
// IF is the one built-in that must NOT evaluate its arguments eagerly // 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 // (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
@@ -390,7 +410,7 @@ function execStatement(
} }
case 'for': { case 'for': {
const source = substitute(stmt.source, env); const source = substitute(stmt.source, env);
const cells = rangeOrCellToCellNodes(source, stmt.varName); const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) { for (const cell of cells) {
budget.steps++; budget.steps++;
if (budget.steps > MAX_LOOP_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 /** 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 * environment, must be a single cell, a range, or a named range -- never a
* (mirrors "a range may only appear as a function argument" for ordinary * computed value (mirrors "a range may only appear as a function argument"
* formulas). Expands a range to its individual cells via `expandRange` * for ordinary formulas). Expands a range to its individual cells via
* (the same helper `evalArg` uses for a range function argument), carrying * `expandRange` (the same helper `evalArg` uses for a range function
* the same table/sheet qualifier through to each. */ * argument), carrying the same table/sheet qualifier through to each. A
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] { * 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 === 'cell') return [node];
if (node.kind === 'range') { if (node.kind === 'range') {
return expandRange(node.start, node.end).map( 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). */ /** Evaluate one function argument into a flat list of values (ranges expand). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { 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') { if (node.kind === 'range') {
const tableId = const tableId =
node.qualifier == null node.qualifier == null
@@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
return [v]; 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. */ /** Coerce a cell value to a number for arithmetic, or return an error value. */
function toNumber(v: CellValue): number | FormulaError { function toNumber(v: CellValue): number | FormulaError {
if (isError(v)) return v; if (isError(v)) return v;
@@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError {
const n = Number(trimmed); const n = Number(trimmed);
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`); 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;
}
}
+12 -1
View File
@@ -56,6 +56,13 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
return values.length; 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* // IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only // 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 // 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<string, FunctionFn> = {
// AVERAGE is an alias of AVG. // AVERAGE is an alias of AVG.
FUNCTIONS.AVERAGE = FUNCTIONS.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 /** One-line signature hint per built-in, for the formula-autocomplete
* dropdown (see store/document.ts's `functionSuggestions()` and * 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: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' }, { name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
{ name: 'COUNT', hint: 'COUNT(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: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' }, { name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' }, { name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
+2
View File
@@ -14,6 +14,7 @@ export {
type Node, type Node,
type CellNode, type CellNode,
type RangeNode, type RangeNode,
type NamedRefNode,
type BlockNode, type BlockNode,
} from './parser'; } from './parser';
export { evaluate } from './evaluator'; export { evaluate } from './evaluator';
@@ -63,4 +64,5 @@ export {
type CellValue, type CellValue,
type Scalar, type Scalar,
type EngineHost, type EngineHost,
type NamedRangeDef,
} from './types'; } from './types';
+2
View File
@@ -18,6 +18,7 @@ export type TokenType =
| 'STAR' | 'STAR'
| 'SLASH' | 'SLASH'
| 'CARET' | 'CARET'
| 'AMP' // "&" -- text concatenation
| 'LPAREN' | 'LPAREN'
| 'RPAREN' | 'RPAREN'
| 'SEMICOLON' | 'SEMICOLON'
@@ -48,6 +49,7 @@ const SINGLE: Record<string, TokenType> = {
'*': 'STAR', '*': 'STAR',
'/': 'SLASH', '/': 'SLASH',
'^': 'CARET', '^': 'CARET',
'&': 'AMP',
'(': 'LPAREN', '(': 'LPAREN',
')': 'RPAREN', ')': 'RPAREN',
';': 'SEMICOLON', ';': 'SEMICOLON',
+95 -19
View File
@@ -7,7 +7,7 @@
* formula = "=" expression ; * formula = "=" expression ;
* *
* expression = comparison ; * expression = comparison ;
* comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; * comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
* (* at most one comparison, * (* at most one comparison,
* not chainable like * not chainable like
* "a < b < c"; mainly * "a < b < c"; mainly
@@ -15,6 +15,12 @@
* function body's IF * function body's IF
* condition, but valid * condition, but valid
* in any formula *) * 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 *) * additive = term { ("+" | "-") term } ; (* left assoc *)
* term = unary { ("*" | "/") unary } ; (* left assoc *) * term = unary { ("*" | "/") unary } ; (* left assoc *)
* unary = ("+" | "-") unary * unary = ("+" | "-") unary
@@ -58,15 +64,21 @@
* see reference() below). *) * see reference() below). *)
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; * 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 * `primary` also accepts a bare IDENT as a *parameter reference* -- but only
* when parseFormula() is called with a `paramNames` list (used to parse a * when parseFormula() is called with a `paramNames` list (used to parse a
* user-defined function's body; see src/engine/userFunctions.ts). Without * user-defined function's body; see src/engine/userFunctions.ts). Without
* that list -- i.e. for every ordinary cell formula -- a bare identifier * that list -- i.e. for every ordinary cell formula -- a bare, unqualified
* that doesn't look like a cell address is still a ParseError, exactly as * IDENT that doesn't look like a cell address is instead a *named-range*
* before. * 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 | FuncNode
| CellNode | CellNode
| RangeNode | RangeNode
| NamedRefNode
| ParamNode | ParamNode
| BlockCallNode | BlockCallNode
| OmittedNode; | OmittedNode;
@@ -95,7 +108,7 @@ export interface StringNode {
} }
export interface BinaryNode { export interface BinaryNode {
kind: 'binary'; kind: 'binary';
op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<='; op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
left: Node; left: Node;
right: Node; right: Node;
} }
@@ -131,6 +144,17 @@ export interface RangeNode {
start: { col: number; row: number }; start: { col: number; row: number };
end: { 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 /** A reference to a user-defined function's own parameter -- only produced
* when parseFormula() is given a `paramNames` list (parsing a function * when parseFormula() is given a `paramNames` list (parsing a function
* body); never appears in an ordinary cell formula's AST. Resolved away by * 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 * -- a plain expression body (parseFormula()'s normal path) never mutates
* this, so its behaviour is completely unaffected. */ * this, so its behaviour is completely unaffected. */
private readonly paramNames: Set<string>; private readonly paramNames: Set<string>;
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.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
this.namedRefFallback = namedRefFallback;
} }
private peek(): Token { private peek(): Token {
@@ -364,11 +399,11 @@ class Parser {
* parens, assignment RHS, ...) -- wraps `additive` with an optional * parens, assignment RHS, ...) -- wraps `additive` with an optional
* single comparison, the loosest-binding operator. */ * single comparison, the loosest-binding operator. */
private expression(): Node { private expression(): Node {
const left = this.additive(); const left = this.concat();
const opType = this.comparisonOpType(); const opType = this.comparisonOpType();
if (!opType) return left; if (!opType) return left;
this.next(); 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 { 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 /** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
* `expression` itself gained the comparison wrapper above it. */ * `expression` itself gained the comparison wrapper above it. */
private additive(): Node { private additive(): Node {
@@ -536,6 +584,19 @@ class Parser {
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) { if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
return { kind: 'param', name: a.value }; 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)) { if (!looksLikeCell(a.value)) {
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`); 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 -- * `paramNames`, when given, is a user-defined function's parameter list --
* bare identifiers matching one of them parse as a {@link ParamNode} instead * bare identifiers matching one of them parse as a {@link ParamNode} instead
* of requiring cell-address shape. Omit it (the default) for every ordinary * 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 { export function parseFormula(src: string, paramNames?: readonly string[]): Node {
const body = src.startsWith('=') ? src.slice(1) : src; 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); if (e instanceof LexError) throw new ParseError(e.message);
throw e; 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); if (e instanceof LexError) throw new ParseError(e.message);
throw e; 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. /** Walk an AST and collect every cell/range/named-range reference node it
* For a `blockcall`, walks both its (already-resolved) `args` and every * contains. For a `blockcall`, walks both its (already-resolved) `args` and
* expression reachable inside its `block` -- conservatively: a reference * every expression reachable inside its `block` -- conservatively: a
* inside a branch/loop that might not execute at runtime still counts as a * reference inside a branch/loop that might not execute at runtime still
* dependency, same as an ordinary `IF`-style formula in any spreadsheet. */ * counts as a dependency, same as an ordinary `IF`-style formula in any
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> { * 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<CellNode | RangeNode | NamedRefNode> = [],
): Array<CellNode | RangeNode | NamedRefNode> {
switch (node.kind) { switch (node.kind) {
case 'cell': case 'cell':
case 'range': case 'range':
case 'namedref':
out.push(node); out.push(node);
break; break;
case 'binary': case 'binary':
@@ -633,7 +709,7 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
return out; return out;
} }
function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode>): void { function collectRefsInBlock(block: BlockNode, out: Array<CellNode | RangeNode | NamedRefNode>): void {
for (const stmt of block.statements) { for (const stmt of block.statements) {
switch (stmt.kind) { switch (stmt.kind) {
case 'assign': case 'assign':
+32
View File
@@ -58,6 +58,22 @@ export interface CellRef extends Coord {
tableId: string; 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. */ /** Stable string key for a cell across the whole document. */
export function keyOf(tableId: string, col: number, row: number): string { export function keyOf(tableId: string, col: number, row: number): string {
return `${tableId}␟${col}␟${row}`; return `${tableId}␟${col}␟${row}`;
@@ -99,6 +115,22 @@ export interface EngineHost {
* (`#REF` -- the formula needs an explicit sheet qualifier). * (`#REF` -- the formula needs an explicit sheet qualifier).
*/ */
resolveTableName(name: string, sheetName?: string | null): string | null; 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. */ /** Dimensions of a table, used for range clamping and #REF detection. */
tableSize(tableId: string): { cols: number; rows: number } | null; tableSize(tableId: string): { cols: number; rows: number } | null;
/** /**
+12 -4
View File
@@ -93,6 +93,7 @@ function expand(
case 'string': case 'string':
case 'cell': case 'cell':
case 'range': case 'range':
case 'namedref':
case 'param': case 'param':
case 'omitted': case 'omitted':
return node; // leaves -- nothing to expand return node; // leaves -- nothing to expand
@@ -180,6 +181,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
case 'string': case 'string':
case 'cell': case 'cell':
case 'range': case 'range':
case 'namedref':
case 'omitted': case 'omitted':
return node; return node;
case 'unary': 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 /** The first FOR loop, anywhere in the block (including nested inside
* another FOR/IF), whose source isn't (syntactically) a parameter, a * 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 * single cell, a range, or a named-range reference -- i.e. can never
* cells no matter what the caller passes. Checked at save time so this is * resolve to a concrete list of cells no matter what the caller passes.
* caught immediately, not just on the first real call; the evaluator's * 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 * interpreter re-checks the same thing on the *substituted* source at call
* time regardless (needed either way, since a parameter's shape isn't * time regardless (needed either way, since a parameter's shape isn't
* known until then) as the actual enforcement. */ * known until then) as the actual enforcement. */
export function findInvalidForSource(block: BlockNode): ForNode | null { export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) { for (const stmt of block.statements) {
if (stmt.kind === 'for') { 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; return stmt;
} }
const nested = findInvalidForSource(stmt.body); const nested = findInvalidForSource(stmt.body);
+31 -5
View File
@@ -31,6 +31,7 @@ import {
parseKey, parseKey,
type CellValue, type CellValue,
type EngineHost, type EngineHost,
type NamedRangeDef,
} from './types'; } from './types';
import { evaluate } from './evaluator'; import { evaluate } from './evaluator';
@@ -81,10 +82,17 @@ export class Workbook implements EngineHost {
private readonly values = new Map<string, CellValue>(); private readonly values = new Map<string, CellValue>();
/** user-defined function library for this document, name.toUpperCase() -> def. */ /** user-defined function library for this document, name.toUpperCase() -> def. */
private readonly functionIndex: Map<string, FunctionDef>; private readonly functionIndex: Map<string, FunctionDef>;
/** 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.tables = tables;
this.functionIndex = buildFunctionIndex(functionDefs); this.functionIndex = buildFunctionIndex(functionDefs);
this.namedRanges = namedRanges;
this.rebuild(); this.rebuild();
} }
@@ -112,6 +120,17 @@ export class Workbook implements EngineHost {
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier 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 { tableSize(tableId: string): { cols: number; rows: number } | null {
const t = this.tableById(tableId); const t = this.tableById(tableId);
return t ? { cols: t.cols, rows: t.rows } : null; return t ? { cols: t.cols, rows: t.rows } : null;
@@ -201,13 +220,20 @@ export class Workbook implements EngineHost {
this.homeTableId = tableId; this.homeTableId = tableId;
const out: string[] = []; const out: string[] = [];
for (const ref of collectRefs(ast)) { 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 = 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 (!target) continue; // unknown table -> no edge; evaluator emits #REF
if (ref.kind === 'cell') { if ('col' in resolved) {
out.push(keyOf(target, ref.col, ref.row)); out.push(keyOf(target, resolved.col, resolved.row));
} else { } 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)); out.push(keyOf(target, c.col, c.row));
} }
} }
+46
View File
@@ -9,11 +9,13 @@
*/ */
import type { import type {
CellCoord,
CellFormat, CellFormat,
ChartObject, ChartObject,
CustomFunction, CustomFunction,
DocumentState, DocumentState,
HAlign, HAlign,
NamedRange,
Sheet, Sheet,
SheetObject, SheetObject,
TableObject, TableObject,
@@ -58,6 +60,46 @@ function normaliseCustomFunctions(x: unknown): CustomFunction[] {
return out; 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<string>): 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; 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 { function colorOrUndef(x: unknown): string | undefined {
return typeof x === 'string' && HEX_COLOR.test(x) ? x : 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<string>(); const sheetIds = new Set<string>();
const objectIds = new Set<string>(); const objectIds = new Set<string>();
const tableIds = new Set<string>();
for (const s of doc.sheets) { for (const s of doc.sheets) {
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects)) if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.'); 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.'); throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.'); if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
objectIds.add(o.id); objectIds.add(o.id);
if (isTableObject(o)) tableIds.add(o.id);
} }
} }
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId)) 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, locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',', csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
customFunctions: normaliseCustomFunctions(doc.customFunctions), customFunctions: normaliseCustomFunctions(doc.customFunctions),
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
}; };
} }
@@ -393,6 +438,7 @@ export function migrateToV2(x: unknown): DocumentState {
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
csvDelimiter: ',', csvDelimiter: ',',
customFunctions: [], customFunctions: [],
namedRanges: [],
}; };
} }
+132
View File
@@ -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)', () => { describe('functionSuggestions (formula autocomplete)', () => {
it('filters built-ins by prefix, case-insensitively', () => { it('filters built-ins by prefix, case-insensitively', () => {
const s = fresh(); const s = fresh();
+101 -2
View File
@@ -39,6 +39,7 @@ import {
type TableModel, type TableModel,
type Token, type Token,
type FunctionDef, type FunctionDef,
type NamedRangeDef,
} from '../engine'; } from '../engine';
import { import {
formatValue, 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. */ /** A normalised rectangle (corners in either order) used by selection + clipboard. */
type Rect = { c0: number; c1: number; r0: number; r1: number }; type Rect = { c0: number; c1: number; r0: number; r1: number };
@@ -210,7 +221,7 @@ function loadInitial(): DocumentState {
function createStore() { function createStore() {
const doc = reactive<DocumentState>(loadInitial()); const doc = reactive<DocumentState>(loadInitial());
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc)); let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
const recalcTick = ref(0); const recalcTick = ref(0);
const bump = () => (recalcTick.value += 1); 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)); const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
function syncWorkbook() { function syncWorkbook() {
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc)); wb = new Workbook(toTableModels(doc), toFunctionDefs(doc), toNamedRangeDefs(doc));
bump(); bump();
} }
@@ -280,6 +291,7 @@ function createStore() {
locale: d.locale, locale: d.locale,
csvDelimiter: d.csvDelimiter, csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions, customFunctions: d.customFunctions,
namedRanges: d.namedRanges,
}); });
let historySnapshot = snapshot(); let historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot); let historyFingerprint = contentFingerprint(historySnapshot);
@@ -865,6 +877,89 @@ function createStore() {
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table 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) { function moveObject(objId: string, x: number, y: number) {
const o = objectById(objId); const o = objectById(objId);
if (!o) return; if (!o) return;
@@ -1616,6 +1711,7 @@ function createStore() {
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE; doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ','; doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
doc.customFunctions = next.customFunctions ?? []; doc.customFunctions = next.customFunctions ?? [];
doc.namedRanges = next.namedRanges ?? [];
syncWorkbook(); syncWorkbook();
} }
/** Loading a different document outright (JSON import "replace", "reset to /** Loading a different document outright (JSON import "replace", "reset to
@@ -1710,6 +1806,9 @@ function createStore() {
addCustomFunction, addCustomFunction,
updateCustomFunction, updateCustomFunction,
deleteCustomFunction, deleteCustomFunction,
addNamedRange,
updateNamedRange,
deleteNamedRange,
resizeChart, resizeChart,
bringToFront, bringToFront,
setChartView, setChartView,
+20
View File
@@ -173,6 +173,25 @@ export interface CustomFunction {
kind: 'expression' | 'block'; 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 /** One entry in the formula-autocomplete dropdown (see document.ts's
* `functionSuggestions()`). */ * `functionSuggestions()`). */
export interface FnSuggestion { export interface FnSuggestion {
@@ -191,4 +210,5 @@ export interface DocumentState {
locale: NumberLocale; locale: NumberLocale;
csvDelimiter: ',' | ';'; csvDelimiter: ',' | ';';
customFunctions: CustomFunction[]; customFunctions: CustomFunction[];
namedRanges: NamedRange[];
} }