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`.
+123 -1
View File
@@ -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: {
+3 -2
View File
@@ -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', () => {
+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})`;
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({
+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', () => {
it('evaluates precedents before dependents (chain across sheets)', () => {
const wb = fixture();
+136 -21
View File
@@ -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
View File
@@ -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)' },
+2
View File
@@ -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';
+2
View File
@@ -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
View File
@@ -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':
+32
View File
@@ -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;
/**
+12 -4
View File
@@ -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
View File
@@ -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));
}
}