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:
+99
-9
@@ -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`.
|
||||
|
||||
@@ -3,10 +3,15 @@ import { parseFormula } from '../parser';
|
||||
import { evaluate } from '../evaluator';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name. */
|
||||
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name, plus an optional
|
||||
* name -> named-range map (name lower-cased, same matching convention as
|
||||
* the real Workbook). */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
namedRanges: Record<string, NamedRangeShape> = {},
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
@@ -21,6 +26,7 @@ function makeHost(
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
@@ -130,6 +136,122 @@ describe('evaluator IF', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator comparisons', () => {
|
||||
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
|
||||
|
||||
it('numeric comparisons work as before, empty cell counts as 0', () => {
|
||||
expect(ev('=B1>=5', host)).toBe(1);
|
||||
expect(ev('=B1<>5', host)).toBe(0);
|
||||
expect(ev('=Z9=0', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
|
||||
expect(ev('=A1="Nord"', host)).toBe(1);
|
||||
expect(ev('=A1=" nord "', host)).toBe(1);
|
||||
expect(ev('=A1="Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('text ordering compares lexicographically', () => {
|
||||
expect(ev('=A1<"Süd"', host)).toBe(1);
|
||||
expect(ev('=A1>"Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('an empty cell takes on whichever type the other side is', () => {
|
||||
expect(ev('=Z9=""', host)).toBe(1);
|
||||
expect(ev('=Z9="x"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
|
||||
expect(ev('=B1="5"', host)).toBe(0);
|
||||
expect(ev('=B1<>"5"', host)).toBe(1);
|
||||
expect(isError(ev('=B1>"5"', host))).toBe(true);
|
||||
expect(isError(ev('=A1<5', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator & (text concatenation)', () => {
|
||||
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
|
||||
|
||||
it('joins two strings', () => {
|
||||
expect(ev('="a"&"b"', host)).toBe('ab');
|
||||
});
|
||||
|
||||
it('renders a number plain, no locale formatting', () => {
|
||||
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
|
||||
});
|
||||
|
||||
it('an empty cell contributes "" (not "0")', () => {
|
||||
expect(ev('="["&Z9&"]"', host)).toBe('[]');
|
||||
});
|
||||
|
||||
it('is left-associative and chains', () => {
|
||||
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
|
||||
});
|
||||
|
||||
it('binds looser than + but tighter than a comparison', () => {
|
||||
expect(ev('="a"&1+2', host)).toBe('a3');
|
||||
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates an error from either side', () => {
|
||||
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator CONCAT/CONCATENATE', () => {
|
||||
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
|
||||
|
||||
it('joins any number of arguments, no separator', () => {
|
||||
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
|
||||
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
|
||||
});
|
||||
|
||||
it('an empty cell contributes ""', () => {
|
||||
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator named ranges (NamedRefNode)', () => {
|
||||
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=Preis + 1', host)).toBe(43);
|
||||
});
|
||||
|
||||
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=PREIS', host)).toBe(42);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(ev('=SUM(Bereich)', host)).toBe(15);
|
||||
});
|
||||
|
||||
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(isError(ev('=Bereich', host))).toBe(true);
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves through another table via its qualifier', () => {
|
||||
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
|
||||
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
|
||||
});
|
||||
expect(ev('=Andernorts * 2', host)).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator XLOOKUP', () => {
|
||||
const textHost = makeHost({
|
||||
S1: {
|
||||
|
||||
@@ -18,12 +18,13 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^():;!')).toEqual([
|
||||
expect(types('+-*/^&():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'AMP',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
@@ -66,7 +67,7 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
expect(() => tokenize('A1 @ B2')).toThrow();
|
||||
});
|
||||
|
||||
it('tokenizes assignment/equality and comparison operators', () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ function sexpr(n: Node): string {
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
case 'namedref':
|
||||
return `namedref(${n.name})`;
|
||||
case 'blockcall':
|
||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
||||
@@ -84,6 +86,15 @@ describe('parser precedence & associativity', () => {
|
||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||
);
|
||||
});
|
||||
|
||||
it('& binds looser than + - but tighter than a comparison', () => {
|
||||
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
|
||||
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
|
||||
});
|
||||
|
||||
it('& is left-associative', () => {
|
||||
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser references', () => {
|
||||
@@ -176,9 +187,13 @@ describe('parseFormula(src, paramNames) -- user-defined function bodies', () =>
|
||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('without paramNames, ordinary cell-formula behaviour is unchanged', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell -> still throws
|
||||
expect(() => parseFormula('=revenue')).toThrow(ParseError);
|
||||
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell, but
|
||||
// this is now an ordinary top-level formula, where that's a named-range
|
||||
// candidate (see the "named ranges" describe block below) rather than a
|
||||
// ParseError -- unlike inside a function body (the case just above),
|
||||
// where the fallback is deliberately off.
|
||||
expect(parse('=revenue')).toBe('namedref(revenue)');
|
||||
});
|
||||
|
||||
it('a param can mix with real cell references in the same formula', () => {
|
||||
@@ -198,8 +213,32 @@ describe('parser errors', () => {
|
||||
it('throws on an unbalanced paren', () => {
|
||||
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
||||
});
|
||||
it('throws on a non-cell identifier used as a reference', () => {
|
||||
expect(() => parseFormula('=foo + 1')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
describe('parser named-range references (NamedRefNode)', () => {
|
||||
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
|
||||
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
|
||||
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
|
||||
});
|
||||
|
||||
it('mixes with real cell/table references and functions', () => {
|
||||
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
|
||||
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
|
||||
);
|
||||
});
|
||||
|
||||
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
|
||||
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
|
||||
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('never a range-start: "Name:B2" is not supported', () => {
|
||||
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
|
||||
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
|
||||
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -321,6 +321,18 @@ describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined f
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
|
||||
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||
const doubleSum = def({
|
||||
|
||||
@@ -138,6 +138,61 @@ describe('Workbook structural changes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook named ranges', () => {
|
||||
function namedRangeFixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' });
|
||||
return new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
}
|
||||
|
||||
it('resolves a single-cell named range like an ordinary cell reference', () => {
|
||||
const wb = namedRangeFixture();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101);
|
||||
});
|
||||
|
||||
it('is a dependency -- recalculates when the referenced cell changes', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(501);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a range function argument', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(300);
|
||||
});
|
||||
|
||||
it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.tables[0].name = 'Verkauf'; // rename the table Preis points at
|
||||
wb.rebuild();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' });
|
||||
const wb = new Workbook([umsatz], [], []);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
|
||||
+136
-21
@@ -11,10 +11,22 @@
|
||||
* ----------------
|
||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||
* string is coerced; any other string yields `#VALUE!`.
|
||||
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
|
||||
* - Comparisons (`= <> > < >= <=`) result `1`/`0`. Two numbers (or an empty
|
||||
* cell, which takes on `0`) compare numerically; two texts (or an empty
|
||||
* cell, which takes on `''`) compare case-insensitively and trimmed --
|
||||
* same convention as XLOOKUP's own text matching, see evalXLookup(). A
|
||||
* number against a real (non-empty) text never crossmatches: `=`/`<>`
|
||||
* answer definitively false/true, `> < >= <=` have no sane answer and
|
||||
* yield `#VALUE!` (see compareValues()).
|
||||
* - `&` concatenates as text: a number renders plain (`String(n)`, no
|
||||
* locale formatting), an empty cell renders as `''` (see toText()).
|
||||
* - Any error among operands / arguments propagates unchanged (first wins).
|
||||
* - A range may only appear as a function argument. Used in scalar position it
|
||||
* yields `#VALUE!`.
|
||||
* - A `NamedRefNode` (a bare, unqualified name that isn't a cell) resolves
|
||||
* live against `host.resolveNamedRange()` into the cell/range it stands
|
||||
* for, then evaluates exactly like that reference would have (see
|
||||
* `resolveNamedRef()`). Unknown name -> `#NAME?`.
|
||||
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
|
||||
* - Division by zero -> `#DIV/0!`.
|
||||
*
|
||||
@@ -27,7 +39,7 @@
|
||||
* ordinary function call this can't be resolved any earlier.
|
||||
*/
|
||||
|
||||
import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser';
|
||||
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
|
||||
import { getFunction } from './functions';
|
||||
import { expandRange } from './refs';
|
||||
import { substitute } from './userFunctions';
|
||||
@@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
if (isError(r)) return r;
|
||||
return Math.pow(l, r);
|
||||
}
|
||||
if (isComparisonOp(node.op)) {
|
||||
const lv = evalScalar(node.left, host);
|
||||
if (isError(lv)) return lv;
|
||||
const rv = evalScalar(node.right, host);
|
||||
if (isError(rv)) return rv;
|
||||
return compareValues(node.op, lv, rv);
|
||||
}
|
||||
if (node.op === '&') {
|
||||
const lv = toText(evalScalar(node.left, host));
|
||||
if (isError(lv)) return lv;
|
||||
const rv = toText(evalScalar(node.right, host));
|
||||
if (isError(rv)) return rv;
|
||||
return lv + rv;
|
||||
}
|
||||
const l = toNumber(evalScalar(node.left, host));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
@@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
return l * r;
|
||||
case '/':
|
||||
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
||||
case '=':
|
||||
return l === r ? 1 : 0;
|
||||
case '<>':
|
||||
return l !== r ? 1 : 0;
|
||||
case '>':
|
||||
return l > r ? 1 : 0;
|
||||
case '<':
|
||||
return l < r ? 1 : 0;
|
||||
case '>=':
|
||||
return l >= r ? 1 : 0;
|
||||
case '<=':
|
||||
return l <= r ? 1 : 0;
|
||||
}
|
||||
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
||||
}
|
||||
@@ -100,6 +114,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
// A bare range can't collapse to a scalar.
|
||||
return new FormulaError('#VALUE!', 'range used where a value is expected');
|
||||
|
||||
case 'namedref': {
|
||||
const resolved = resolveNamedRef(node, host);
|
||||
if (isError(resolved)) return resolved;
|
||||
return evalScalar(resolved, host); // a resolved 1x1 range is a 'cell', so this recursion terminates
|
||||
}
|
||||
|
||||
case 'func': {
|
||||
// IF is the one built-in that must NOT evaluate its arguments eagerly
|
||||
// (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
|
||||
@@ -390,7 +410,7 @@ function execStatement(
|
||||
}
|
||||
case 'for': {
|
||||
const source = substitute(stmt.source, env);
|
||||
const cells = rangeOrCellToCellNodes(source, stmt.varName);
|
||||
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
|
||||
for (const cell of cells) {
|
||||
budget.steps++;
|
||||
if (budget.steps > MAX_LOOP_STEPS) {
|
||||
@@ -405,12 +425,21 @@ function execStatement(
|
||||
}
|
||||
|
||||
/** A FOR loop's source, once names are resolved against the current
|
||||
* environment, must be a single cell or a range -- never a computed value
|
||||
* (mirrors "a range may only appear as a function argument" for ordinary
|
||||
* formulas). Expands a range to its individual cells via `expandRange`
|
||||
* (the same helper `evalArg` uses for a range function argument), carrying
|
||||
* the same table/sheet qualifier through to each. */
|
||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||
* environment, must be a single cell, a range, or a named range -- never a
|
||||
* computed value (mirrors "a range may only appear as a function argument"
|
||||
* for ordinary formulas). Expands a range to its individual cells via
|
||||
* `expandRange` (the same helper `evalArg` uses for a range function
|
||||
* argument), carrying the same table/sheet qualifier through to each. A
|
||||
* named range resolves first (via `host`), then falls into the same
|
||||
* cell/range handling as any other reference. */
|
||||
function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHost): CellNode[] {
|
||||
if (node.kind === 'namedref') {
|
||||
const resolved = resolveNamedRef(node, host);
|
||||
if (isError(resolved)) {
|
||||
throw new Error(`FOR ${loopVarName} IN ...: unbekannter Name '${node.name}'`);
|
||||
}
|
||||
node = resolved;
|
||||
}
|
||||
if (node.kind === 'cell') return [node];
|
||||
if (node.kind === 'range') {
|
||||
return expandRange(node.start, node.end).map(
|
||||
@@ -428,6 +457,11 @@ function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
|
||||
|
||||
/** Evaluate one function argument into a flat list of values (ranges expand). */
|
||||
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
if (node.kind === 'namedref') {
|
||||
const resolved = resolveNamedRef(node, host);
|
||||
if (isError(resolved)) return resolved;
|
||||
return evalArg(resolved, host); // now a plain 'cell'/'range', handled below/by evalScalar
|
||||
}
|
||||
if (node.kind === 'range') {
|
||||
const tableId =
|
||||
node.qualifier == null
|
||||
@@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
return [v];
|
||||
}
|
||||
|
||||
/** Resolve a `NamedRefNode` to the concrete `CellNode`/`RangeNode` it stands
|
||||
* for right now (via `host.resolveNamedRange()`), so every other place that
|
||||
* already knows how to evaluate a cell/range (`evalScalar`, `evalArg`,
|
||||
* `rangeOrCellToCellNodes`) can just fall through to that existing logic
|
||||
* instead of duplicating it. `#NAME?` when the name is unknown -- the same
|
||||
* error class an unknown function name gets. */
|
||||
function resolveNamedRef(node: NamedRefNode, host: EngineHost): CellNode | RangeNode | FormulaError {
|
||||
const def = host.resolveNamedRange(node.name);
|
||||
if (!def) return new FormulaError('#NAME?', `unknown name '${node.name}'`);
|
||||
const sheetField = def.sheetQualifier === undefined ? {} : { sheetQualifier: def.sheetQualifier };
|
||||
if (def.start.col === def.end.col && def.start.row === def.end.row) {
|
||||
return { kind: 'cell', qualifier: def.qualifier, ...sheetField, col: def.start.col, row: def.start.row };
|
||||
}
|
||||
return { kind: 'range', qualifier: def.qualifier, ...sheetField, start: def.start, end: def.end };
|
||||
}
|
||||
|
||||
/** Coerce a cell value to a number for arithmetic, or return an error value. */
|
||||
function toNumber(v: CellValue): number | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
@@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError {
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||
}
|
||||
|
||||
/** Coerce a cell value to text for `&` concatenation, or return an error
|
||||
* value. An empty cell becomes `''` (not `'0'`), matching how it's ignored
|
||||
* everywhere else in this engine rather than treated as a literal number.
|
||||
* A number renders plain (`String(n)`, no thousands separator/locale --
|
||||
* same "locale-neutral" convention as CSV/XLSX export), never `Intl`. */
|
||||
function toText(v: CellValue): string | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
if (v === null) return '';
|
||||
return typeof v === 'string' ? v : String(v);
|
||||
}
|
||||
|
||||
type ComparisonOp = '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
|
||||
function isComparisonOp(op: string): op is ComparisonOp {
|
||||
return op === '=' || op === '<>' || op === '>' || op === '<' || op === '>=' || op === '<=';
|
||||
}
|
||||
|
||||
/** `= <> > < >= <=` on two already-evaluated scalars -- see the module
|
||||
* comment's "Comparisons" bullet for the full rule. An empty cell (`null`)
|
||||
* takes on whichever type the other side is (`0` for a number, `''` for
|
||||
* text), so both `=A1=0` and `=A1=""` work on a genuinely empty `A1`. Text
|
||||
* is matched the same way XLOOKUP matches text (case-insensitive, trimmed)
|
||||
* -- kept as one convention across the engine rather than two. */
|
||||
function compareValues(op: ComparisonOp, l: CellValue, r: CellValue): CellValue {
|
||||
const lIsText = typeof l === 'string';
|
||||
const rIsText = typeof r === 'string';
|
||||
|
||||
// A real number against real (non-empty) text is a genuine type mismatch
|
||||
// -- an empty cell (null) is handled below, taking on whichever type the
|
||||
// OTHER side is, so it never reaches this branch.
|
||||
if ((typeof l === 'number' && rIsText) || (lIsText && typeof r === 'number')) {
|
||||
if (op === '=') return 0;
|
||||
if (op === '<>') return 1;
|
||||
return new FormulaError('#VALUE!', 'text and number are not comparable with > < >= <=');
|
||||
}
|
||||
|
||||
if (lIsText || rIsText) {
|
||||
const lt = (l ?? '').toString().trim().toLowerCase();
|
||||
const rt = (r ?? '').toString().trim().toLowerCase();
|
||||
return applyCompare(op, lt, rt);
|
||||
}
|
||||
const ln = l === null ? 0 : (l as number);
|
||||
const rn = r === null ? 0 : (r as number);
|
||||
return applyCompare(op, ln, rn);
|
||||
}
|
||||
|
||||
function applyCompare(op: ComparisonOp, l: number, r: number): CellValue;
|
||||
function applyCompare(op: ComparisonOp, l: string, r: string): CellValue;
|
||||
function applyCompare(op: ComparisonOp, l: number | string, r: number | string): CellValue {
|
||||
switch (op) {
|
||||
case '=':
|
||||
return l === r ? 1 : 0;
|
||||
case '<>':
|
||||
return l !== r ? 1 : 0;
|
||||
case '>':
|
||||
return l > r ? 1 : 0;
|
||||
case '<':
|
||||
return l < r ? 1 : 0;
|
||||
case '>=':
|
||||
return l >= r ? 1 : 0;
|
||||
case '<=':
|
||||
return l <= r ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -56,6 +56,13 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
return values.length;
|
||||
},
|
||||
|
||||
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
|
||||
// as the `&` operator (toText() in evaluator.ts): a number prints plain
|
||||
// (no locale formatting), an empty cell contributes ''.
|
||||
CONCAT(values) {
|
||||
return values.map((v) => (v === null ? '' : String(v))).join('');
|
||||
},
|
||||
|
||||
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
||||
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
||||
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
||||
@@ -142,6 +149,8 @@ export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
|
||||
// AVERAGE is an alias of AVG.
|
||||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||||
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
|
||||
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
|
||||
|
||||
/** One-line signature hint per built-in, for the formula-autocomplete
|
||||
* dropdown (see store/document.ts's `functionSuggestions()` and
|
||||
@@ -156,7 +165,9 @@ export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> =
|
||||
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
|
||||
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
|
||||
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
|
||||
{ name: 'COUNT2', hint: 'COUNT(Text1; Zahl2; …)' },
|
||||
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
|
||||
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
|
||||
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
|
||||
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
|
||||
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
||||
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
type NamedRefNode,
|
||||
type BlockNode,
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
@@ -63,4 +64,5 @@ export {
|
||||
type CellValue,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
|
||||
@@ -18,6 +18,7 @@ export type TokenType =
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'AMP' // "&" -- text concatenation
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
@@ -48,6 +49,7 @@ const SINGLE: Record<string, TokenType> = {
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'&': 'AMP',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
|
||||
+95
-19
@@ -7,7 +7,7 @@
|
||||
* formula = "=" expression ;
|
||||
*
|
||||
* expression = comparison ;
|
||||
* comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ;
|
||||
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
|
||||
* (* at most one comparison,
|
||||
* not chainable like
|
||||
* "a < b < c"; mainly
|
||||
@@ -15,6 +15,12 @@
|
||||
* function body's IF
|
||||
* condition, but valid
|
||||
* in any formula *)
|
||||
* concat = additive { "&" additive } ; (* left assoc; text
|
||||
* concatenation, binds
|
||||
* looser than "+ -" but
|
||||
* tighter than a
|
||||
* comparison -- same
|
||||
* slot Excel gives "&" *)
|
||||
* additive = term { ("+" | "-") term } ; (* left assoc *)
|
||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||
* unary = ("+" | "-") unary
|
||||
@@ -58,15 +64,21 @@
|
||||
* see reference() below). *)
|
||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
*
|
||||
* Precedence, lowest to highest: comparison < + - < * / < unary +/- < ^
|
||||
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
||||
* when parseFormula() is called with a `paramNames` list (used to parse a
|
||||
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare identifier
|
||||
* that doesn't look like a cell address is still a ParseError, exactly as
|
||||
* before.
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
|
||||
* IDENT that doesn't look like a cell address is instead a *named-range*
|
||||
* reference (`NamedRefNode`, see below): the parser has no opinion on
|
||||
* whether the name actually exists (same as it never validates a table
|
||||
* qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation
|
||||
* time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell
|
||||
* identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is
|
||||
* always unqualified, so a qualifier always means "this must be a real
|
||||
* cell".
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
@@ -81,6 +93,7 @@ export type Node =
|
||||
| FuncNode
|
||||
| CellNode
|
||||
| RangeNode
|
||||
| NamedRefNode
|
||||
| ParamNode
|
||||
| BlockCallNode
|
||||
| OmittedNode;
|
||||
@@ -95,7 +108,7 @@ export interface StringNode {
|
||||
}
|
||||
export interface BinaryNode {
|
||||
kind: 'binary';
|
||||
op: '+' | '-' | '*' | '/' | '^' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
left: Node;
|
||||
right: Node;
|
||||
}
|
||||
@@ -131,6 +144,17 @@ export interface RangeNode {
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
}
|
||||
/** A bare, unqualified name that isn't a param and doesn't look like a cell
|
||||
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
|
||||
* instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷
|
||||
* Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't
|
||||
* know the document's named ranges (same as it never validates a table
|
||||
* qualifier) -- resolved live, by name, at evaluation/dependency-collection
|
||||
* time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */
|
||||
export interface NamedRefNode {
|
||||
kind: 'namedref';
|
||||
name: string;
|
||||
}
|
||||
/** A reference to a user-defined function's own parameter -- only produced
|
||||
* when parseFormula() is given a `paramNames` list (parsing a function
|
||||
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
||||
@@ -237,8 +261,19 @@ class Parser {
|
||||
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
||||
* this, so its behaviour is completely unaffected. */
|
||||
private readonly paramNames: Set<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.namedRefFallback = namedRefFallback;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
@@ -364,11 +399,11 @@ class Parser {
|
||||
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
||||
* single comparison, the loosest-binding operator. */
|
||||
private expression(): Node {
|
||||
const left = this.additive();
|
||||
const left = this.concat();
|
||||
const opType = this.comparisonOpType();
|
||||
if (!opType) return left;
|
||||
this.next();
|
||||
return { kind: 'binary', op: opType, left, right: this.additive() };
|
||||
return { kind: 'binary', op: opType, left, right: this.concat() };
|
||||
}
|
||||
|
||||
private comparisonOpType(): BinaryNode['op'] | null {
|
||||
@@ -390,6 +425,19 @@ class Parser {
|
||||
}
|
||||
}
|
||||
|
||||
/** `&` -- text concatenation, left-associative. Binds looser than `+ -`
|
||||
* (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter
|
||||
* than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not
|
||||
* `A1&(B1="ab")`) -- same slot Excel gives `&`. */
|
||||
private concat(): Node {
|
||||
let left = this.additive();
|
||||
while (this.at('AMP')) {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: '&', left, right: this.additive() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
|
||||
* `expression` itself gained the comparison wrapper above it. */
|
||||
private additive(): Node {
|
||||
@@ -536,6 +584,19 @@ class Parser {
|
||||
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
||||
return { kind: 'param', name: a.value };
|
||||
}
|
||||
// an unqualified, non-cell-shaped bare name -- a named-range candidate
|
||||
// (see NamedRefNode's doc comment), but only for an ordinary top-level
|
||||
// formula (namedRefFallback); a function body keeps the strict old
|
||||
// behaviour so e.g. a local used before its own declaring statement
|
||||
// still fails immediately at save time instead of silently becoming a
|
||||
// named-range lookup (or worse, one that happens to resolve because a
|
||||
// same-named named range exists elsewhere in the document) -- a caller
|
||||
// can still *pass* a named range as an argument, it just can't be typed
|
||||
// bare inside the body itself. Never a range-start either way: "Name:B2"
|
||||
// isn't supported, so this doesn't fall through to the ":"-range check.
|
||||
if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) {
|
||||
return { kind: 'namedref', name: a.value };
|
||||
}
|
||||
if (!looksLikeCell(a.value)) {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
@@ -570,7 +631,13 @@ class Parser {
|
||||
* `paramNames`, when given, is a user-defined function's parameter list --
|
||||
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
||||
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
||||
* cell formula; behaviour is then identical to before this existed.
|
||||
* cell formula; behaviour is then identical to before this existed, *except*
|
||||
* that a bare, unqualified, non-cell identifier now parses as a
|
||||
* {@link NamedRefNode} (a named-range candidate) instead of a ParseError --
|
||||
* see NamedRefNode's doc comment. That fallback only applies when
|
||||
* `paramNames` is omitted entirely (`undefined`) -- passing it, even as an
|
||||
* empty array, means "this is a function body", where the fallback is
|
||||
* deliberately off (see the Parser class's `namedRefFallback` field).
|
||||
*/
|
||||
export function parseFormula(src: string, paramNames?: readonly string[]): Node {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
@@ -581,7 +648,7 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks, paramNames).parse();
|
||||
return new Parser(toks, paramNames, paramNames === undefined).parse();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -601,18 +668,27 @@ export function parseFunctionBlock(src: string, paramNames: readonly string[]):
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks, paramNames).parseBlock();
|
||||
return new Parser(toks, paramNames, false).parseBlock();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range reference node it contains.
|
||||
* For a `blockcall`, walks both its (already-resolved) `args` and every
|
||||
* expression reachable inside its `block` -- conservatively: a reference
|
||||
* inside a branch/loop that might not execute at runtime still counts as a
|
||||
* dependency, same as an ordinary `IF`-style formula in any spreadsheet. */
|
||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||
/** Walk an AST and collect every cell/range/named-range reference node it
|
||||
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
||||
* every expression reachable inside its `block` -- conservatively: a
|
||||
* reference inside a branch/loop that might not execute at runtime still
|
||||
* counts as a dependency, same as an ordinary `IF`-style formula in any
|
||||
* spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this
|
||||
* function is host-agnostic (doesn't know the document's named ranges),
|
||||
* same as it never resolves a table qualifier either; the caller
|
||||
* (`Workbook.precedentsOf`) is the one that turns it into concrete cell
|
||||
* keys via `resolveNamedRange()`. */
|
||||
export function collectRefs(
|
||||
node: Node,
|
||||
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
||||
): Array<CellNode | RangeNode | NamedRefNode> {
|
||||
switch (node.kind) {
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
out.push(node);
|
||||
break;
|
||||
case 'binary':
|
||||
@@ -633,7 +709,7 @@ export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []):
|
||||
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) {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
|
||||
@@ -58,6 +58,22 @@ export interface CellRef extends Coord {
|
||||
tableId: string;
|
||||
}
|
||||
|
||||
/** A document-defined name resolving to a fixed cell/range on a specific
|
||||
* table -- fed into {@link Workbook} (see workbook.ts) so it can implement
|
||||
* {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is
|
||||
* no expression/formula of its own, just fixed coordinates -- so there is
|
||||
* nothing that could cyclically reference itself. `tableId` (not a table
|
||||
* *name*) so a named range survives its target table being renamed; the
|
||||
* host resolves it to that table's *current* name on lookup, the same
|
||||
* qualifier a formula's own `Tabelle!Zelle` reference would carry. */
|
||||
export interface NamedRangeDef {
|
||||
id: string;
|
||||
name: string;
|
||||
tableId: string;
|
||||
start: Coord;
|
||||
end: Coord;
|
||||
}
|
||||
|
||||
/** Stable string key for a cell across the whole document. */
|
||||
export function keyOf(tableId: string, col: number, row: number): string {
|
||||
return `${tableId}␟${col}␟${row}`;
|
||||
@@ -99,6 +115,22 @@ export interface EngineHost {
|
||||
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
||||
*/
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null;
|
||||
/**
|
||||
* Resolve a *named range* (as written, unqualified, in a formula -- e.g.
|
||||
* `Umsatz_Q1`) to the table-qualified cell/range it currently stands for.
|
||||
* Matching is case-insensitive and tolerant of surrounding whitespace,
|
||||
* same as {@link resolveTableName}. `null` when the name is unknown (or
|
||||
* its target table has since been deleted) -- the caller turns that into
|
||||
* `#NAME?`, the same error class an unknown function name gets.
|
||||
*
|
||||
* Resolved by *name*, live, every time -- never baked into the AST -- so a
|
||||
* named range survives its target table being renamed, the same way a
|
||||
* `Tabelle!Zelle` reference's table name is looked up fresh on every
|
||||
* evaluation rather than resolved once at parse time.
|
||||
*/
|
||||
resolveNamedRange(
|
||||
name: string,
|
||||
): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null;
|
||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,7 @@ function expand(
|
||||
case 'string':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'param':
|
||||
case 'omitted':
|
||||
return node; // leaves -- nothing to expand
|
||||
@@ -180,6 +181,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
case 'string':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'omitted':
|
||||
return node;
|
||||
case 'unary':
|
||||
@@ -293,16 +295,22 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
|
||||
|
||||
/** The first FOR loop, anywhere in the block (including nested inside
|
||||
* another FOR/IF), whose source isn't (syntactically) a parameter, a
|
||||
* single cell, or a range -- i.e. can never resolve to a concrete list of
|
||||
* cells no matter what the caller passes. Checked at save time so this is
|
||||
* caught immediately, not just on the first real call; the evaluator's
|
||||
* single cell, a range, or a named-range reference -- i.e. can never
|
||||
* resolve to a concrete list of cells no matter what the caller passes.
|
||||
* Checked at save time so this is caught immediately, not just on the
|
||||
* first real call; the evaluator's
|
||||
* interpreter re-checks the same thing on the *substituted* source at call
|
||||
* time regardless (needed either way, since a parameter's shape isn't
|
||||
* known until then) as the actual enforcement. */
|
||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'for') {
|
||||
if (stmt.source.kind !== 'param' && stmt.source.kind !== 'cell' && stmt.source.kind !== 'range') {
|
||||
if (
|
||||
stmt.source.kind !== 'param' &&
|
||||
stmt.source.kind !== 'cell' &&
|
||||
stmt.source.kind !== 'range' &&
|
||||
stmt.source.kind !== 'namedref'
|
||||
) {
|
||||
return stmt;
|
||||
}
|
||||
const nested = findInvalidForSource(stmt.body);
|
||||
|
||||
+31
-5
@@ -31,6 +31,7 @@ import {
|
||||
parseKey,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
@@ -81,10 +82,17 @@ export class Workbook implements EngineHost {
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||
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.functionIndex = buildFunctionIndex(functionDefs);
|
||||
this.namedRanges = namedRanges;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
@@ -112,6 +120,17 @@ export class Workbook implements EngineHost {
|
||||
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
||||
}
|
||||
|
||||
resolveNamedRange(
|
||||
name: string,
|
||||
): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle);
|
||||
if (!def) return null;
|
||||
const table = this.tableById(def.tableId);
|
||||
if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename
|
||||
return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end };
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
@@ -201,13 +220,20 @@ export class Workbook implements EngineHost {
|
||||
this.homeTableId = tableId;
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
// a named range isn't itself a qualifier/coords pair -- resolve it to
|
||||
// one first (same shape as a 'cell'/'range' ref), then fall through to
|
||||
// the identical edge-wiring below.
|
||||
const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref;
|
||||
if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME?
|
||||
const target =
|
||||
ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier, ref.sheetQualifier ?? null);
|
||||
resolved.qualifier == null
|
||||
? tableId
|
||||
: this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if (ref.kind === 'cell') {
|
||||
out.push(keyOf(target, ref.col, ref.row));
|
||||
if ('col' in resolved) {
|
||||
out.push(keyOf(target, resolved.col, resolved.row));
|
||||
} else {
|
||||
for (const c of expandRange(ref.start, ref.end)) {
|
||||
for (const c of expandRange(resolved.start, resolved.end)) {
|
||||
out.push(keyOf(target, c.col, c.row));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user