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
+99 -9
View File
@@ -35,11 +35,14 @@ Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
formula = "=" expression ;
expression = comparison ;
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
ein Vergleich, nicht
verkettbar; Ergebnis
1/0 (siehe Auswertungs-
modell unten) *)
concat = additive { "&" additive } ; (* links-assoziativ; Text-
Verkettung, zwischen
Vergleich und "+ -" *)
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
alte "expression"-Regel,
bevor Vergleiche
@@ -54,10 +57,16 @@ primary = NUMBER
| STRING
| "(" expression ")"
| funcall
| reference ;
| reference
| namedref ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
namedref = IDENT ; (* unqualifizierter, nicht zellförmiger
Bezeichner, kein Parameter -- ein
benannter-Bereich-Kandidat (s. u.),
nur außerhalb eines Funktionskörpers *)
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
Numbers-Stil "::" sind
@@ -78,7 +87,7 @@ QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
```
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` < unäres `+ -` < `^`.
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`.
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
@@ -111,8 +120,20 @@ wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown.
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
verwendet (`userFunctions.ts`).
Ohne das Argument (`paramNames === undefined`, jede gewöhnliche
Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne
Zellform seit **benannten Bereichen** geändert: er parst jetzt als
`NamedRefNode` (Feld `namedRefFallback` am `Parser`, s. u. „Benannte
Bereiche") statt als `ParseError`. **Mit** `paramNames` — auch als leeres
Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte
Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name
ist weiterhin ein harter `ParseError`. Grund: eine lokale/Schleifen-Variable,
die vor ihrer eigenen deklarierenden Anweisung verwendet wird (`x = x + 1`),
soll sofort beim Speichern scheitern, statt sich still zu einem
gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur
Laufzeit als `#NAME?` aufzufallen).
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
@@ -169,6 +190,54 @@ unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
## Benannte Bereiche
Anders als eine benutzerdefinierte Funktion wird ein `NamedRefNode`
**nicht** beim Parsen aufgelöst/inlined, sondern **live, bei jeder
Auswertung**, gegen `EngineHost.resolveNamedRange(name)` – dieselbe
"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier
(auch der wird nie zur Parse-Zeit zu einer Tabellen-*ID* aufgelöst). Grund:
so übersteht ein benannter Bereich eine spätere Umbenennung *seiner*
Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden
müsste.
- `types.ts#NamedRangeDef` (`{id, name, tableId, start, end}`) wird dem
`Workbook`-Konstruktor als dritter Parameter mitgegeben (analog zu den
`FunctionDef`s); `store/document.ts#toNamedRangeDefs` mappt
`DocumentState.namedRanges` dorthin.
- `workbook.ts#Workbook.resolveNamedRange(name)` sucht case-insensitiv/
getrimmt (dieselbe Konvention wie `resolveTableName`), löst `tableId` über
`tableById()` in den **aktuellen** Tabellennamen auf und gibt
`{qualifier, sheetQualifier, start, end}` zurück – exakt die Form, die ein
`CellNode`/`RangeNode` sonst auch trägt. Zieltabelle nicht mehr vorhanden
→ `null` → `#NAME?` (dieselbe Fehlerklasse wie ein unbekannter Name).
- `evaluator.ts#resolveNamedRef()` baut aus diesem Ergebnis einen
synthetischen `CellNode` (1×1-Bereich) oder `RangeNode` und lässt jeden
Aufrufer (`evalScalar`s `case 'namedref'`, `evalArg`,
`rangeOrCellToCellNodes` für eine `FOR`-Quelle) in die **bereits
vorhandene** Zell-/Bereichs-Logik durchfallen – keine Duplikation.
- `parser.ts#collectRefs` sammelt einen `NamedRefNode` unaufgelöst (der
Parser kennt das Dokument nicht); `workbook.ts#precedentsOf` löst ihn
dann über `resolveNamedRange` in konkrete Zell-Keys für den
Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine
Neuberechnung nötig, `evaluate()` liefert ohnehin `#NAME?`).
- **Grammatik-Änderung, die das erst ermöglicht:** ein unqualifizierter,
nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein `NamedRefNode`
statt eines `ParseError` – aber **nur außerhalb eines Funktionskörpers**
(`Parser`s `namedRefFallback`-Flag, `true` nur wenn `parseFormula()` ganz
ohne `paramNames`-Argument aufgerufen wird). Ein Aufrufer kann trotzdem
einen benannten Bereich als Argument an eine Funktion übergeben – der
`NamedRefNode` steht dann in `node.args`, wird über
`expand()`/`substitute()` (beide bekamen dafür einen `case 'namedref':
return node;`-Leaf, wie `cell`/`range`) an die passende Parameterstelle im
Funktionskörper substituiert, und trifft dort z. B. als `FOR`-Quelle auf
`rangeOrCellToCellNodes`, das ihn ganz normal auflöst.
`userFunctions.ts#findInvalidForSource` erlaubt `namedref` deshalb
ausdrücklich als gültige `FOR`-Quellenform (neben `param`/`cell`/`range`).
- **Kein Zyklus-Check.** Ein benannter Bereich hat keinen eigenen Ausdruck,
nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders
als eine benutzerdefinierte Funktion.
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
@@ -302,14 +371,35 @@ dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
- **Vergleiche sind ebenfalls numerisch** (`toNumber()`-Koerzierung wie bei
`+ - * /`), Ergebnis `1` (wahr) oder `0` (falsch); genutzt vor allem in
einer `IF`-Bedingung eines „Mit Schleife"-Funktionskörpers (s. o.), aber in
jeder Formel gültig.
- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
eine leere Zelle, die als `0` zählt) vergleichen **numerisch**
(`toNumber()`-Koerzierung wie bei `+ - * /`); zwei Texte (oder eine leere
Zelle, die dann als `''` zählt) vergleichen **text**uell –
case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s
Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt
zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der
**anderen** Seite an, sodass sowohl `=A1=0` als auch `=A1=""` auf einer
echt leeren `A1` funktionieren. Eine echte (nicht-leere) Zahl gegen
echten Text ist ein **Typ-Mismatch**, kein Crossmatch: `=`/`<>`
antworten eindeutig falsch/wahr, `> < >= <=` haben keine sinnvolle
Antwort und ergeben `#VALUE!` (siehe `evaluator.ts`s `compareValues()`).
- **`&` verkettet als Text** (`toText()`): eine Zahl rendert `String(n)`
ohne Locale-Formatierung (kein `Intl`, dieselbe „locale-neutral"-
Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt `''` bei
(nicht `'0'`). Präzedenz zwischen `+ -` und einem Vergleich (s. o. EBNF),
links-assoziativ. `CONCAT`/`CONCATENATE(Text1; Text2; …)` sind das
Funktions-Äquivalent (`functions.ts`), ohne Operator-Präzedenz.
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
gefundene Fehler gewinnt).
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
ergibt er `#VALUE!`.
- Ein **`NamedRefNode`** (benannter Bereich) löst live gegen
`host.resolveNamedRange()` auf und verhält sich danach exakt wie die
Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für
einen mehrzelligen Treffer). Unbekannter Name → `#NAME?`. Details:
„Benannte Bereiche" oben.
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.