- init
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# Formel-Engine
|
||||
|
||||
Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kein
|
||||
`formula-parser`, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.
|
||||
|
||||
Die adressierbare Einheit ist eine **Tabelle** – ein Tabellen-Objekt auf einer
|
||||
Numbers-artigen Blattfläche. Tabellennamen sind dokumentweit eindeutig, deshalb
|
||||
löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
|
||||
|
||||
## Module
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|----------------|---------|
|
||||
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
|
||||
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
|
||||
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. |
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)`, Spaltenbuchstaben, Bereichs-Expansion. |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`) als Tabelle `{ NAME → fn(values) }`. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
|
||||
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
|
||||
Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
|
||||
`recalcTick`-Ref – dadurch verhält sich jede Formelzelle im UI wie ein `computed`.
|
||||
|
||||
## Grammatik (EBNF)
|
||||
|
||||
```ebnf
|
||||
formula = "=" expression ;
|
||||
|
||||
expression = term { ("+" | "-") term } ; (* links-assoziativ *)
|
||||
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
||||
unary = ("+" | "-") unary
|
||||
| power ;
|
||||
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
||||
stärker als unäres Minus:
|
||||
-2^2 = -(2^2) = -4 *)
|
||||
primary = NUMBER
|
||||
| STRING
|
||||
| "(" expression ")"
|
||||
| funcall
|
||||
| reference ;
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||
Numbers-Stil "::" sind
|
||||
austauschbar *)
|
||||
|
||||
NUMBER = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/ | /"." [0-9]+/ ;
|
||||
STRING = /" ... "/ (Backslash-Escapes) ;
|
||||
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
|
||||
QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *)
|
||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
```
|
||||
|
||||
Präzedenz, schwach → stark: `+ -` < `* /` < unäres `+ -` < `^`.
|
||||
|
||||
`$` in Referenzen wird toleriert, aber ignoriert (es gibt kein Kopieren/Füllen,
|
||||
alle Bezüge sind relativ). Tabellennamen werden **case-insensitiv** und
|
||||
whitespace-tolerant aufgelöst: `Kosten!A2`, `'Kosten'!A2`, `kosten!a2`,
|
||||
`Kosten::A2` sind identisch.
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
||||
- **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!`.
|
||||
- `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`.
|
||||
|
||||
## Neuberechnung
|
||||
|
||||
1. `setCell` reparst **eine** Zelle, aktualisiert ihre Graph-Kanten.
|
||||
2. `graph.affected(dirty)` = geänderte Zellen + alle transitiven Abhängigen.
|
||||
3. `graph.topoOrder(affected)` liefert eine sichere Auswertungsreihenfolge
|
||||
(Präzedenzen zuerst) **und** die Menge der Zellen auf einem Zyklus.
|
||||
4. Zyklus-Zellen bekommen direkt `#CIRC`; der Rest wird in topologischer
|
||||
Reihenfolge ausgewertet. Nicht betroffene Zellen behalten ihren Wert
|
||||
(„nur betroffene Zellen neu rechnen“).
|
||||
|
||||
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
||||
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|
||||
## Fehlermodell
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|------------|-----------|
|
||||
| `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`) |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar). *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern,
|
||||
`Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle.
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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. */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
S2: { cols: 10, rows: 10 },
|
||||
};
|
||||
const idFor = (name: string): string | null => {
|
||||
const n = name.trim().toLowerCase();
|
||||
if (n === 's1') return 'S1';
|
||||
if (n === 's2') return 'S2';
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
const v = data[tableId]?.[cell];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ev = (src: string, host: EngineHost) => evaluate(parseFormula(src), host);
|
||||
|
||||
describe('evaluator arithmetic', () => {
|
||||
const host = makeHost({ S1: { B2: 10, C2: 5, D2: 8 } });
|
||||
|
||||
it('respects precedence and associativity', () => {
|
||||
expect(ev('=(B2+C2)*2-D2/4', host)).toBe(28); // (15)*2 - 2
|
||||
expect(ev('=2^3^2', host)).toBe(512);
|
||||
expect(ev('=-2^2', host)).toBe(-4);
|
||||
expect(ev('=2+3*4', host)).toBe(14);
|
||||
});
|
||||
|
||||
it('treats an empty cell as 0', () => {
|
||||
expect(ev('=B2+Z9', host)).toBe(10);
|
||||
});
|
||||
|
||||
it('division by zero -> #DIV/0!', () => {
|
||||
expect(ev('=1/0', host)).toEqual(new FormulaError('#DIV/0!'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator functions', () => {
|
||||
const host = makeHost({
|
||||
S1: { B2: 1200, B3: 980, B4: 1550, B5: 1340, B6: 1105 },
|
||||
});
|
||||
|
||||
it('SUM / AVG / MIN / MAX / COUNT over a range', () => {
|
||||
expect(ev('=SUM(B2:B6)', host)).toBe(6175);
|
||||
expect(ev('=AVG(B2:B6)', host)).toBe(1235);
|
||||
expect(ev('=MIN(B2:B6)', host)).toBe(980);
|
||||
expect(ev('=MAX(B2:B6)', host)).toBe(1550);
|
||||
expect(ev('=COUNT(B2:B6)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVERAGE is an alias of AVG', () => {
|
||||
expect(ev('=AVERAGE(B2:B6)', host)).toBe(1235);
|
||||
});
|
||||
|
||||
it('mixes ranges, literals and single cells', () => {
|
||||
expect(ev('=SUM(B2:B3; 100; B4)', host)).toBe(1200 + 980 + 100 + 1550);
|
||||
});
|
||||
|
||||
it('COUNT ignores empty cells', () => {
|
||||
expect(ev('=COUNT(B2:B10)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVG of no numbers -> #DIV/0!', () => {
|
||||
const r = ev('=AVG(Z1:Z5)', host);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('unknown function -> #NAME?', () => {
|
||||
const r = ev('=FOO(1)', host);
|
||||
expect(isError(r) && r.code).toBe('#NAME?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator cross-sheet & errors', () => {
|
||||
const host = makeHost({
|
||||
S1: { A1: 3 },
|
||||
S2: { A1: 7, B1: 100 },
|
||||
});
|
||||
|
||||
it('resolves Name!Ref and is case-insensitive', () => {
|
||||
expect(ev('=S2!A1 + S2!B1', host)).toBe(107);
|
||||
expect(ev('=s2!a1', host)).toBe(7);
|
||||
});
|
||||
|
||||
it('accepts "::" the same as "!"', () => {
|
||||
expect(ev('=S2::A1 + S2::B1', host)).toBe(107);
|
||||
expect(ev('=s2::a1', host)).toBe(ev('=S2!A1', host));
|
||||
});
|
||||
|
||||
it('unknown sheet -> #REF', () => {
|
||||
const r = ev('=Nix!A1', host);
|
||||
expect(isError(r) && r.code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('propagates an error from a referenced cell', () => {
|
||||
const h = makeHost({ S1: { A1: new FormulaError('#DIV/0!') } });
|
||||
const r = ev('=A1 + 1', h);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
const r2 = ev('=SUM(A1; 5)', h);
|
||||
expect(isError(r2) && r2.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('a bare range in scalar position -> #VALUE!', () => {
|
||||
const r = ev('=A1:A3 + 1', host);
|
||||
expect(isError(r) && r.code).toBe('#VALUE!');
|
||||
});
|
||||
|
||||
it('text in arithmetic -> #VALUE!, numeric text is coerced', () => {
|
||||
const h = makeHost({ S1: { A1: 'abc', A2: '12' } });
|
||||
expect(isError(ev('=A1*2', h))).toBe(true);
|
||||
expect(ev('=A2*2', h)).toBe(24);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DependencyGraph } from '../graph';
|
||||
|
||||
/**
|
||||
* Helper: build a graph from "cell <- precedent, precedent" lines.
|
||||
* Node names are arbitrary strings here (real code uses `keyOf`).
|
||||
*/
|
||||
function build(edges: Record<string, string[]>): DependencyGraph {
|
||||
const g = new DependencyGraph();
|
||||
for (const [cell, precs] of Object.entries(edges)) g.setPrecedents(cell, precs);
|
||||
return g;
|
||||
}
|
||||
|
||||
describe('DependencyGraph.affected', () => {
|
||||
it('returns dirty cells plus their transitive dependents', () => {
|
||||
// A -> B -> C ; A -> D
|
||||
const g = build({ B: ['A'], C: ['B'], D: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B', 'C', 'D']);
|
||||
expect([...g.affected(['B'])].sort()).toEqual(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DependencyGraph.topoOrder', () => {
|
||||
it('orders precedents before dependents', () => {
|
||||
const g = build({ B: ['A'], C: ['A', 'B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect(circular.size).toBe(0);
|
||||
const pos = (n: string) => order.indexOf(n);
|
||||
expect(pos('A')).toBeLessThan(pos('B'));
|
||||
expect(pos('B')).toBeLessThan(pos('C'));
|
||||
expect(pos('C')).toBeLessThan(pos('D'));
|
||||
});
|
||||
|
||||
it('detects a direct self-reference', () => {
|
||||
const g = build({ A: ['A'] });
|
||||
const { circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular]).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('detects a 2-cycle and flags both nodes', () => {
|
||||
const g = build({ A: ['B'], B: ['A'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B']);
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects a longer cycle, leaves acyclic tail evaluable', () => {
|
||||
// cycle A->B->C->A ; plus D reads C (D is not on the cycle)
|
||||
const g = build({ A: ['C'], B: ['A'], C: ['B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B', 'C']);
|
||||
expect(order).toEqual(['D']);
|
||||
});
|
||||
|
||||
it('rewiring precedents removes stale edges', () => {
|
||||
const g = build({ B: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B']);
|
||||
g.setPrecedents('B', []); // B no longer reads A
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tokenize } from '../lexer';
|
||||
|
||||
const types = (s: string) => tokenize(s).map((t) => t.type);
|
||||
const values = (s: string) => tokenize(s).map((t) => t.value);
|
||||
|
||||
describe('lexer', () => {
|
||||
it('tokenizes numbers incl. decimals and exponents', () => {
|
||||
expect(values('1 2.5 .5 1e3 1.2E-3')).toEqual([
|
||||
'1',
|
||||
'2.5',
|
||||
'.5',
|
||||
'1e3',
|
||||
'1.2E-3',
|
||||
'',
|
||||
]);
|
||||
expect(types('42')).toEqual(['NUMBER', 'EOF']);
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
'SEMICOLON',
|
||||
'BANG',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads bare words as IDENT (cell refs, functions, sheet names alike)', () => {
|
||||
expect(types('SUM(A1:B2)')).toEqual([
|
||||
'IDENT',
|
||||
'LPAREN',
|
||||
'IDENT',
|
||||
'COLON',
|
||||
'IDENT',
|
||||
'RPAREN',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads single-quoted sheet names with spaces', () => {
|
||||
const t = tokenize("'Kosten Q3'!B2");
|
||||
expect(t[0]).toMatchObject({ type: 'QUOTED', value: 'Kosten Q3' });
|
||||
expect(t[1].type).toBe('BANG');
|
||||
expect(t[2]).toMatchObject({ type: 'IDENT', value: 'B2' });
|
||||
});
|
||||
|
||||
it('handles escaped double-quoted strings', () => {
|
||||
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
|
||||
});
|
||||
|
||||
it('skips whitespace', () => {
|
||||
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 & B2')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, collectRefs, ParseError, type Node } from '../parser';
|
||||
|
||||
/** Compact s-expression rendering of an AST for precedence assertions. */
|
||||
function sexpr(n: Node): string {
|
||||
switch (n.kind) {
|
||||
case 'number':
|
||||
return String(n.value);
|
||||
case 'string':
|
||||
return JSON.stringify(n.value);
|
||||
case 'unary':
|
||||
return `(${n.op} ${sexpr(n.operand)})`;
|
||||
case 'binary':
|
||||
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
|
||||
case 'func':
|
||||
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
|
||||
case 'cell':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
case 'range':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
|
||||
describe('parser precedence & associativity', () => {
|
||||
it('* / bind tighter than + -', () => {
|
||||
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
||||
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
|
||||
});
|
||||
|
||||
it('+ - are left-associative', () => {
|
||||
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
|
||||
});
|
||||
|
||||
it('^ binds tighter than * / and is right-associative', () => {
|
||||
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
|
||||
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
|
||||
});
|
||||
|
||||
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
|
||||
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
|
||||
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
|
||||
});
|
||||
|
||||
it('parentheses override precedence', () => {
|
||||
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
|
||||
expect(parse('=(B2+C2)*2-D2/4')).toBe(
|
||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser references', () => {
|
||||
it('parses local cells and ranges', () => {
|
||||
expect(parse('=A1')).toBe('cell(0,0)');
|
||||
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
|
||||
});
|
||||
|
||||
it('parses table-qualified refs, bare and quoted', () => {
|
||||
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
});
|
||||
|
||||
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
|
||||
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
|
||||
});
|
||||
|
||||
it('parses function calls with ; separator and mixed args', () => {
|
||||
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
|
||||
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
|
||||
);
|
||||
});
|
||||
|
||||
it('collectRefs finds every reference', () => {
|
||||
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
|
||||
expect(refs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser errors', () => {
|
||||
it('throws ParseError on a dangling operator', () => {
|
||||
expect(() => parseFormula('=A1+')).toThrow(ParseError);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
|
||||
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
|
||||
function fixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A2: 'Nord', B2: '1200', C2: '1450', D2: '1600', E2: '1720',
|
||||
A3: 'Süd', B3: '980', C3: '1100', D3: '1250', E3: '1310',
|
||||
A4: 'Ost', B4: '1550', C4: '1480', D4: '1620', E4: '1700',
|
||||
A5: 'West', B5: '1340', C5: '1390', D5: '1500', E5: '1580',
|
||||
A6: 'Zentral', B6: '1105', C6: '1205', D6: '1298', E6: '1402',
|
||||
B8: '=SUM(B2:B6)', B9: '=AVG(B2:B6)', B10: '=MIN(B2:B6)',
|
||||
B11: '=MAX(B2:B6)', B12: '=COUNT(B2:B6)',
|
||||
H2: '=SUM(B2:E2)', H3: '=SUM(B2:E2)/4', H4: '=E2-B2',
|
||||
});
|
||||
const kosten = sheet('s-kosten', 'Kosten', {
|
||||
A2: 'Nord', B2: '800', C2: '820', D2: '900', E2: '880',
|
||||
});
|
||||
const uebersicht = sheet('s-ueber', 'Übersicht', {
|
||||
B2: '=SUM(Umsatz!B2:E2)', C2: '=SUM(Kosten!B2:E2)',
|
||||
D2: '=B2-C2', E2: '=D2/B2*100',
|
||||
B8: '=SUM(B2:B6)', D8: '=SUM(D2:D6)',
|
||||
});
|
||||
return new Workbook([umsatz, kosten, uebersicht]);
|
||||
}
|
||||
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
describe('Workbook aggregates', () => {
|
||||
const wb = fixture();
|
||||
it('SUM/AVG/MIN/MAX/COUNT of Umsatz!B2:B6', () => {
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(6175);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1235);
|
||||
expect(val(wb, 's-umsatz', 'B10')).toBe(980);
|
||||
expect(val(wb, 's-umsatz', 'B11')).toBe(1550);
|
||||
expect(val(wb, 's-umsatz', 'B12')).toBe(5);
|
||||
});
|
||||
it('row helpers', () => {
|
||||
expect(val(wb, 's-umsatz', 'H2')).toBe(5970); // 1200+1450+1600+1720
|
||||
expect(val(wb, 's-umsatz', 'H3')).toBe(1492.5);
|
||||
expect(val(wb, 's-umsatz', 'H4')).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook cross-sheet', () => {
|
||||
it('resolves Name!Range across sheets', () => {
|
||||
const wb = fixture();
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
expect(val(wb, 's-ueber', 'C2')).toBe(3400);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(2570);
|
||||
expect(val(wb, 's-ueber', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
|
||||
});
|
||||
|
||||
it('editing Umsatz!B2 propagates to Übersicht B2/D2/E2 and the Gesamt row', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '2200'); // B2: 1200 -> 2200 (+1000)
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(7175);
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(6970);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(3570);
|
||||
expect(val(wb, 's-ueber', 'B8')).toBe(6970); // only row 2 populated in fixture
|
||||
expect(val(wb, 's-ueber', 'D8')).toBe(3570);
|
||||
});
|
||||
|
||||
it('is tolerant about sheet-name case and quoting', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-kosten', 0, 8, "='Kosten'!B2"); // A9 = 'Kosten'!B2
|
||||
wb.setCell('s-kosten', 1, 8, '=kosten!b2'); // B9 lower-case
|
||||
expect(val(wb, 's-kosten', 'A9')).toBe(800);
|
||||
expect(val(wb, 's-kosten', 'B9')).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook error values', () => {
|
||||
const wb = fixture();
|
||||
it('classifies each error kind', () => {
|
||||
wb.setCell('s-umsatz', 6, 20, '=1/0');
|
||||
wb.setCell('s-umsatz', 6, 21, '=FOO(1)');
|
||||
wb.setCell('s-umsatz', 6, 22, '=A1+');
|
||||
wb.setCell('s-umsatz', 6, 23, '=Nix!A1');
|
||||
expect((val(wb, 's-umsatz', 'G21') as FormulaError).code).toBe('#DIV/0!');
|
||||
expect((val(wb, 's-umsatz', 'G22') as FormulaError).code).toBe('#NAME?');
|
||||
expect((val(wb, 's-umsatz', 'G23') as FormulaError).code).toBe('#ERR');
|
||||
expect((val(wb, 's-umsatz', 'G24') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('detects a direct circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, '=A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('detects a cross-sheet circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, "='Kosten'!Z9");
|
||||
wb2.setCell('s-kosten', 25, 8, '=Umsatz!A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
expect((val(wb2, 's-kosten', 'Z9') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('errors propagate to dependents', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 1, 1, '=1/0');
|
||||
expect(isError(val(wb2, 's-umsatz', 'B8'))).toBe(true);
|
||||
expect(isError(val(wb2, 's-ueber', 'B2'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook structural changes', () => {
|
||||
it('deleting a referenced sheet turns dependents into #REF', () => {
|
||||
const wb = fixture();
|
||||
wb.tables = wb.tables.filter((t) => t.id !== 's-kosten');
|
||||
wb.rebuild();
|
||||
expect((val(wb, 's-ueber', 'C2') as FormulaError).code).toBe('#REF');
|
||||
// unrelated cells still fine
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
// chain: Umsatz!B2 -> Übersicht!B2 -> Übersicht!D2 -> Übersicht!E2
|
||||
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 1, 1)]);
|
||||
expect(circular).toEqual([]);
|
||||
const pos = (k: string) => order.indexOf(k);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeGreaterThan(-1);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeLessThan(pos(keyOf('s-ueber', 3, 1)));
|
||||
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* AST evaluator.
|
||||
*
|
||||
* `evaluate(ast, host)` walks the tree and returns a {@link CellValue}. It never
|
||||
* recurses into other cells directly -- it asks the {@link EngineHost} for their
|
||||
* *already computed* values, which the recalculation driver guarantees are ready
|
||||
* (topological order). Circular cells are detected up front by the graph module
|
||||
* and never reach the evaluator.
|
||||
*
|
||||
* Evaluation model
|
||||
* ----------------
|
||||
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
|
||||
* string is coerced; any other string yields `#VALUE!`.
|
||||
* - 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!`.
|
||||
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
|
||||
* - Division by zero -> `#DIV/0!`.
|
||||
*/
|
||||
|
||||
import type { Node } from './parser';
|
||||
import { getFunction } from './functions';
|
||||
import { expandRange } from './refs';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
|
||||
|
||||
export function evaluate(ast: Node, host: EngineHost): CellValue {
|
||||
return evalScalar(ast, host);
|
||||
}
|
||||
|
||||
function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
switch (node.kind) {
|
||||
case 'number':
|
||||
return node.value;
|
||||
case 'string':
|
||||
return node.value;
|
||||
|
||||
case 'unary': {
|
||||
const v = toNumber(evalScalar(node.operand, host));
|
||||
if (isError(v)) return v;
|
||||
return node.op === '-' ? -v : v;
|
||||
}
|
||||
|
||||
case 'binary': {
|
||||
if (node.op === '^') {
|
||||
const l = toNumber(evalScalar(node.left, host));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
if (isError(r)) return r;
|
||||
return Math.pow(l, r);
|
||||
}
|
||||
const l = toNumber(evalScalar(node.left, host));
|
||||
if (isError(l)) return l;
|
||||
const r = toNumber(evalScalar(node.right, host));
|
||||
if (isError(r)) return r;
|
||||
switch (node.op) {
|
||||
case '+':
|
||||
return l + r;
|
||||
case '-':
|
||||
return l - r;
|
||||
case '*':
|
||||
return l * r;
|
||||
case '/':
|
||||
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
|
||||
}
|
||||
return new FormulaError('#ERR', `unknown operator ${node.op}`);
|
||||
}
|
||||
|
||||
case 'cell': {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return host.getComputed(tableId, node.col, node.row);
|
||||
}
|
||||
|
||||
case 'range':
|
||||
// A bare range can't collapse to a scalar.
|
||||
return new FormulaError('#VALUE!', 'range used where a value is expected');
|
||||
|
||||
case 'func': {
|
||||
const fn = getFunction(node.name);
|
||||
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
|
||||
const values: CellValue[] = [];
|
||||
for (const arg of node.args) {
|
||||
const part = evalArg(arg, host);
|
||||
if (isError(part)) return part;
|
||||
for (const v of part) {
|
||||
if (isError(v)) return v;
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
return fn(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate one function argument into a flat list of values (ranges expand). */
|
||||
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
if (node.kind === 'range') {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return expandRange(node.start, node.end).map((c) => host.getComputed(tableId, c.col, c.row));
|
||||
}
|
||||
const v = evalScalar(node, host);
|
||||
return [v];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
if (v === null) return 0;
|
||||
if (typeof v === 'number') return v;
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') return 0;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Built-in functions: a plain table { NAME -> fn(values) }.
|
||||
*
|
||||
* By the time a function runs, the evaluator has already:
|
||||
* - resolved every range argument into a flat list of cell values,
|
||||
* - flattened scalar arguments into that same list,
|
||||
* - checked for errors among the values (an error short-circuits and is
|
||||
* returned instead of calling the function).
|
||||
*
|
||||
* So a function only ever sees `(number | string | null)[]`. Each function
|
||||
* decides for itself how to treat text and empty cells. Per spec:
|
||||
* - empty cell -> ignored by every aggregate, never counted by COUNT
|
||||
* - text -> ignored by the numeric aggregates
|
||||
*/
|
||||
|
||||
import { FormulaError, type CellValue } from './types';
|
||||
|
||||
export type FunctionFn = (values: CellValue[]) => CellValue;
|
||||
|
||||
/** Keep only real numbers (finite). */
|
||||
function nums(values: CellValue[]): number[] {
|
||||
const out: number[] = [];
|
||||
for (const v of values) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
SUM(values) {
|
||||
return nums(values).reduce((a, b) => a + b, 0);
|
||||
},
|
||||
|
||||
AVG(values) {
|
||||
const n = nums(values);
|
||||
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
|
||||
return n.reduce((a, b) => a + b, 0) / n.length;
|
||||
},
|
||||
|
||||
MIN(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.min(...n) : 0;
|
||||
},
|
||||
|
||||
MAX(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.max(...n) : 0;
|
||||
},
|
||||
|
||||
COUNT(values) {
|
||||
return nums(values).length;
|
||||
},
|
||||
};
|
||||
|
||||
// AVERAGE is an alias of AVG.
|
||||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||||
|
||||
export function hasFunction(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
||||
}
|
||||
|
||||
export function getFunction(name: string): FunctionFn | undefined {
|
||||
return FUNCTIONS[name.toUpperCase()];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Dependency graph over *all* sheets.
|
||||
*
|
||||
* Nodes are cell keys (see `keyOf` in ./types). An edge P -> C means
|
||||
* "cell C reads cell P" (P is a precedent of C, C is a dependent of P).
|
||||
*
|
||||
* The graph offers two things the recalculation driver needs:
|
||||
* 1. `affected(dirty)` -- every cell that must be revisited after `dirty`
|
||||
* changed (the dirty cells plus their transitive dependents).
|
||||
* 2. `topoOrder(nodes)` -- a safe evaluation order for a set of nodes, plus
|
||||
* the subset that sits on a dependency cycle (`#CIRC`).
|
||||
*
|
||||
* Circular references -- including cross-sheet ones -- are found by an iterative
|
||||
* DFS with white/grey/black colouring. When a back-edge to a grey node is seen,
|
||||
* every node currently on the DFS stack from that node onwards is flagged
|
||||
* circular. Flagged nodes are dropped from the returned order; the driver writes
|
||||
* `#CIRC` into them directly and their dependents pick that up through normal
|
||||
* error propagation.
|
||||
*/
|
||||
|
||||
export class DependencyGraph {
|
||||
/** precedents: key -> set of cells it reads */
|
||||
private readonly precedents = new Map<string, Set<string>>();
|
||||
/** dependents: key -> set of cells that read it */
|
||||
private readonly dependents = new Map<string, Set<string>>();
|
||||
|
||||
/** Replace the precedent set of `cell` (called after (re)parsing a formula). */
|
||||
setPrecedents(cell: string, precedents: Iterable<string>): void {
|
||||
// unlink old edges
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) {
|
||||
for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
}
|
||||
// Keep a self-reference if present: `=A1` inside A1 must surface as #CIRC.
|
||||
const next = new Set(precedents);
|
||||
this.precedents.set(cell, next);
|
||||
for (const p of next) {
|
||||
let d = this.dependents.get(p);
|
||||
if (!d) this.dependents.set(p, (d = new Set()));
|
||||
d.add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a cell entirely (e.g. it became a literal or was cleared). */
|
||||
clear(cell: string): void {
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
this.precedents.delete(cell);
|
||||
// keep `dependents.get(cell)` -- other cells may still point here (=> #REF/0),
|
||||
// that entry is harmless and gets cleaned as those cells are re-parsed.
|
||||
}
|
||||
|
||||
/** Drop every edge (used on structural rebuilds). */
|
||||
reset(): void {
|
||||
this.precedents.clear();
|
||||
this.dependents.clear();
|
||||
}
|
||||
|
||||
getPrecedents(cell: string): Set<string> {
|
||||
return this.precedents.get(cell) ?? new Set();
|
||||
}
|
||||
getDependents(cell: string): Set<string> {
|
||||
return this.dependents.get(cell) ?? new Set();
|
||||
}
|
||||
|
||||
/** Dirty cells + all their transitive dependents. */
|
||||
affected(dirty: Iterable<string>): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...dirty];
|
||||
while (stack.length) {
|
||||
const k = stack.pop()!;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
const deps = this.dependents.get(k);
|
||||
if (deps) for (const d of deps) if (!seen.has(d)) stack.push(d);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topologically sort `nodes` using precedent edges that stay inside `nodes`.
|
||||
* Returns the evaluation order (precedents first) and the set of nodes that
|
||||
* lie on a cycle.
|
||||
*/
|
||||
topoOrder(nodes: Set<string>): { order: string[]; circular: Set<string> } {
|
||||
const WHITE = 0;
|
||||
const GREY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
const order: string[] = [];
|
||||
const circular = new Set<string>();
|
||||
for (const n of nodes) color.set(n, WHITE);
|
||||
|
||||
for (const root of nodes) {
|
||||
if (color.get(root) !== WHITE) continue;
|
||||
|
||||
// iterative DFS; frame = { node, iterator over its in-scope precedents }
|
||||
const stack: Array<{ node: string; iter: Iterator<string> }> = [];
|
||||
const pathIndex = new Map<string, number>(); // node -> position on stack
|
||||
|
||||
const push = (node: string) => {
|
||||
color.set(node, GREY);
|
||||
pathIndex.set(node, stack.length);
|
||||
const precs = [...this.getPrecedents(node)].filter((p) => nodes.has(p));
|
||||
stack.push({ node, iter: precs[Symbol.iterator]() });
|
||||
};
|
||||
push(root);
|
||||
|
||||
while (stack.length) {
|
||||
const frame = stack[stack.length - 1];
|
||||
const step = frame.iter.next();
|
||||
if (step.done) {
|
||||
color.set(frame.node, BLACK);
|
||||
pathIndex.delete(frame.node);
|
||||
order.push(frame.node);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const p = step.value;
|
||||
const c = color.get(p);
|
||||
if (c === WHITE) {
|
||||
push(p);
|
||||
} else if (c === GREY) {
|
||||
// back-edge -> cycle. Flag everything from p to the current frame.
|
||||
const from = pathIndex.get(p)!;
|
||||
for (let k = from; k < stack.length; k++) circular.add(stack[k].node);
|
||||
}
|
||||
// BLACK -> already settled, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// A node that depends on a circular node is not itself circular, but it must
|
||||
// be evaluated *after* the driver writes #CIRC. Keeping it in `order` is
|
||||
// fine; drop only the truly circular nodes.
|
||||
return { order: order.filter((n) => !circular.has(n)), circular };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Public surface of the formula engine.
|
||||
*
|
||||
* Nothing in `./` imports Vue, the DOM or any spreadsheet library. See
|
||||
* ./README.md for the grammar (EBNF), the evaluation model and the error model.
|
||||
*/
|
||||
|
||||
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
||||
export {
|
||||
parseFormula,
|
||||
collectRefs,
|
||||
ParseError,
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
} from './parser';
|
||||
export { evaluate } from './evaluator';
|
||||
export { DependencyGraph } from './graph';
|
||||
export { FUNCTIONS, getFunction, hasFunction } from './functions';
|
||||
export {
|
||||
colToNumber,
|
||||
numberToCol,
|
||||
parseA1,
|
||||
toA1,
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
literalValue,
|
||||
type TableModel,
|
||||
} from './workbook';
|
||||
export {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
ERROR_CODES,
|
||||
type ErrorCode,
|
||||
type CellValue,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
} from './types';
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tokenizer for the formula language.
|
||||
*
|
||||
* The lexer is deliberately dumb: it splits the input into a flat token stream
|
||||
* and leaves *all* structural decisions to the parser. In particular it does not
|
||||
* try to tell a cell reference ("A1") from a function name ("SUM") or a sheet
|
||||
* name ("Umsatz") -- they are all `IDENT` tokens and the parser disambiguates
|
||||
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
|
||||
*/
|
||||
|
||||
export type TokenType =
|
||||
| 'NUMBER'
|
||||
| 'STRING'
|
||||
| 'IDENT' // bare word: A1, SUM, Region, Q1 ...
|
||||
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
|
||||
| 'PLUS'
|
||||
| 'MINUS'
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
| 'COLON'
|
||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||
| 'BANG'
|
||||
| 'EOF';
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
/** Raw text of the token (unquoted for STRING/QUOTED). */
|
||||
value: string;
|
||||
/** Character offset in the source, useful for error messages. */
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const SINGLE: Record<string, TokenType> = {
|
||||
'+': 'PLUS',
|
||||
'-': 'MINUS',
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
':': 'COLON',
|
||||
'!': 'BANG',
|
||||
};
|
||||
|
||||
function isDigit(ch: string): boolean {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
function isWordStart(ch: string): boolean {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_';
|
||||
}
|
||||
function isWordPart(ch: string): boolean {
|
||||
return isWordStart(ch) || isDigit(ch);
|
||||
}
|
||||
|
||||
export function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i];
|
||||
|
||||
// whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// number: 12 12.5 .5 1e3 1.2E-3
|
||||
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
|
||||
const start = i;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
if (src[i] === '.') {
|
||||
i++;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
if (src[i] === 'e' || src[i] === 'E') {
|
||||
let j = i + 1;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
if (isDigit(src[j])) {
|
||||
i = j;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: 'NUMBER', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// double-quoted string literal
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n && src[i] !== '"') {
|
||||
if (src[i] === '\\' && i + 1 < n) {
|
||||
out += src[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== '"') throw new LexError(`Unterminated string at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'STRING', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-quoted sheet name: 'Kosten Q3' ('' -> literal quote)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n) {
|
||||
if (src[i] === "'" && src[i + 1] === "'") {
|
||||
out += "'";
|
||||
i += 2;
|
||||
} else if (src[i] === "'") {
|
||||
break;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== "'") throw new LexError(`Unterminated sheet name at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'QUOTED', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// bare word: identifiers, function names, cell refs
|
||||
if (isWordStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n && isWordPart(src[i])) i++;
|
||||
tokens.push({ type: 'IDENT', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// "::" table qualifier -- must be checked before the single ":"
|
||||
if (ch === ':' && src[i + 1] === ':') {
|
||||
tokens.push({ type: 'DBLCOLON', value: '::', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-character punctuation / operators
|
||||
const t = SINGLE[ch];
|
||||
if (t) {
|
||||
tokens.push({ type: t, value: ch, pos: i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${ch}' at ${i}`);
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', pos: n });
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Recursive-descent parser: token stream -> AST.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* GRAMMAR (EBNF)
|
||||
* ---------------------------------------------------------------------------
|
||||
* formula = "=" expression ;
|
||||
*
|
||||
* expression = term { ("+" | "-") term } ; (* left assoc *)
|
||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||
* unary = ("+" | "-") unary
|
||||
* | power ;
|
||||
* power = primary [ "^" unary ] ; (* right assoc, and
|
||||
* binds tighter than
|
||||
* unary minus so that
|
||||
* -2^2 = -(2^2) = -4 *)
|
||||
* primary = NUMBER
|
||||
* | STRING
|
||||
* | "(" expression ")"
|
||||
* | funcall
|
||||
* | reference ;
|
||||
*
|
||||
* funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
*
|
||||
* reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
|
||||
* Numbers-style "::" are
|
||||
* interchangeable *)
|
||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
*
|
||||
* Precedence, lowest to highest: + - < * / < unary +/- < ^
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
||||
import { looksLikeCell, parseA1 } from './refs';
|
||||
|
||||
export type Node =
|
||||
| NumberNode
|
||||
| StringNode
|
||||
| BinaryNode
|
||||
| UnaryNode
|
||||
| FuncNode
|
||||
| CellNode
|
||||
| RangeNode;
|
||||
|
||||
export interface NumberNode {
|
||||
kind: 'number';
|
||||
value: number;
|
||||
}
|
||||
export interface StringNode {
|
||||
kind: 'string';
|
||||
value: string;
|
||||
}
|
||||
export interface BinaryNode {
|
||||
kind: 'binary';
|
||||
op: '+' | '-' | '*' | '/' | '^';
|
||||
left: Node;
|
||||
right: Node;
|
||||
}
|
||||
export interface UnaryNode {
|
||||
kind: 'unary';
|
||||
op: '+' | '-';
|
||||
operand: Node;
|
||||
}
|
||||
export interface FuncNode {
|
||||
kind: 'func';
|
||||
name: string;
|
||||
args: Node[];
|
||||
}
|
||||
/** Single cell reference, optionally qualified with a table name. */
|
||||
export interface CellNode {
|
||||
kind: 'cell';
|
||||
/** raw table name as written ("Umsatz"), or null for "this table" */
|
||||
qualifier: string | null;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
/** Rectangular range reference, optionally qualified with a table name. */
|
||||
export interface RangeNode {
|
||||
kind: 'range';
|
||||
qualifier: string | null;
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly toks: Token[]) {}
|
||||
|
||||
private peek(): Token {
|
||||
return this.toks[this.i];
|
||||
}
|
||||
private next(): Token {
|
||||
return this.toks[this.i++];
|
||||
}
|
||||
private at(t: TokenType): boolean {
|
||||
return this.toks[this.i].type === t;
|
||||
}
|
||||
private expect(t: TokenType): Token {
|
||||
if (!this.at(t)) {
|
||||
throw new ParseError(`Expected ${t} but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
|
||||
parse(): Node {
|
||||
const node = this.expression();
|
||||
if (!this.at('EOF')) {
|
||||
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private expression(): Node {
|
||||
let left = this.term();
|
||||
while (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
left = { kind: 'binary', op, left, right: this.term() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private term(): Node {
|
||||
let left = this.unary();
|
||||
while (this.at('STAR') || this.at('SLASH')) {
|
||||
const op = this.next().type === 'STAR' ? '*' : '/';
|
||||
left = { kind: 'binary', op, left, right: this.unary() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private unary(): Node {
|
||||
if (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
return { kind: 'unary', op, operand: this.unary() };
|
||||
}
|
||||
return this.power();
|
||||
}
|
||||
|
||||
private power(): Node {
|
||||
const base = this.primary();
|
||||
if (this.at('CARET')) {
|
||||
this.next();
|
||||
// right operand is `unary` -> right-associative and allows -exponent
|
||||
return { kind: 'binary', op: '^', left: base, right: this.unary() };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private primary(): Node {
|
||||
const t = this.peek();
|
||||
|
||||
if (t.type === 'NUMBER') {
|
||||
this.next();
|
||||
return { kind: 'number', value: Number(t.value) };
|
||||
}
|
||||
if (t.type === 'STRING') {
|
||||
this.next();
|
||||
return { kind: 'string', value: t.value };
|
||||
}
|
||||
if (t.type === 'LPAREN') {
|
||||
this.next();
|
||||
const inner = this.expression();
|
||||
this.expect('RPAREN');
|
||||
return inner;
|
||||
}
|
||||
if (t.type === 'QUOTED') {
|
||||
// must be a table-qualified reference: 'Kosten Q3'!B2 or 'Kosten Q3'::B2
|
||||
this.next();
|
||||
this.expectQualifierSep();
|
||||
return this.reference(t.value);
|
||||
}
|
||||
if (t.type === 'IDENT') {
|
||||
this.next();
|
||||
// function call?
|
||||
if (this.at('LPAREN')) {
|
||||
this.next();
|
||||
const args: Node[] = [];
|
||||
if (!this.at('RPAREN')) {
|
||||
args.push(this.expression());
|
||||
while (this.at('SEMICOLON')) {
|
||||
this.next();
|
||||
args.push(this.expression());
|
||||
}
|
||||
}
|
||||
this.expect('RPAREN');
|
||||
return { kind: 'func', name: t.value, args };
|
||||
}
|
||||
// table-qualified reference? IDENT ("!" | "::") ...
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return this.reference(t.value);
|
||||
}
|
||||
// otherwise it must be a local cell reference
|
||||
return this.reference(null, t);
|
||||
}
|
||||
|
||||
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cell/range part of a reference. `firstTok` (when given) is an
|
||||
* IDENT already consumed by the caller for the *local* case.
|
||||
*/
|
||||
private reference(qualifier: string | null, firstTok?: Token): Node {
|
||||
const a = firstTok ?? this.expectCellIdent();
|
||||
if (!firstTok && a.type !== 'IDENT') {
|
||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||
}
|
||||
if (!looksLikeCell(a.value)) {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
const start = parseA1(a.value);
|
||||
|
||||
if (this.at('COLON')) {
|
||||
this.next();
|
||||
const b = this.expectCellIdent();
|
||||
if (!looksLikeCell(b.value)) {
|
||||
throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`);
|
||||
}
|
||||
const end = parseA1(b.value);
|
||||
return { kind: 'range', qualifier, start, end };
|
||||
}
|
||||
return { kind: 'cell', qualifier, col: start.col, row: start.row };
|
||||
}
|
||||
|
||||
private expectQualifierSep(): void {
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
throw new ParseError(
|
||||
`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`,
|
||||
);
|
||||
}
|
||||
|
||||
private expectCellIdent(): Token {
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected cell reference but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw formula. The leading "=" is optional (callers usually strip it).
|
||||
* Throws {@link ParseError} / {@link LexError} on malformed input; the store
|
||||
* turns those into the `#ERR` cell value.
|
||||
*/
|
||||
export function parseFormula(src: string): Node {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch (e) {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks).parse();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range reference node it contains. */
|
||||
export function collectRefs(node: Node, out: Array<CellNode | RangeNode> = []): Array<CellNode | RangeNode> {
|
||||
switch (node.kind) {
|
||||
case 'cell':
|
||||
case 'range':
|
||||
out.push(node);
|
||||
break;
|
||||
case 'binary':
|
||||
collectRefs(node.left, out);
|
||||
collectRefs(node.right, out);
|
||||
break;
|
||||
case 'unary':
|
||||
collectRefs(node.operand, out);
|
||||
break;
|
||||
case 'func':
|
||||
node.args.forEach((a) => collectRefs(a, out));
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
|
||||
*
|
||||
* All coordinates the engine uses internally are 0-based. The A1 notation
|
||||
* exposed to the user is 1-based for rows and letter-based for columns
|
||||
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
||||
*/
|
||||
|
||||
const CELL_RE = /^\$?([A-Za-z]{1,3})\$?([0-9]{1,7})$/;
|
||||
|
||||
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
|
||||
export function colToNumber(letters: string): number {
|
||||
let n = 0;
|
||||
const up = letters.toUpperCase();
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
|
||||
export function numberToCol(n: number): string {
|
||||
let s = '';
|
||||
let x = n + 1;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
x = Math.floor((x - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
|
||||
export function looksLikeCell(text: string): boolean {
|
||||
return CELL_RE.test(text);
|
||||
}
|
||||
|
||||
/** Parse "A1" -> { col: 0, row: 0 }. Throws for malformed input. */
|
||||
export function parseA1(text: string): { col: number; row: number } {
|
||||
const m = CELL_RE.exec(text);
|
||||
if (!m) throw new Error(`Bad cell reference: ${text}`);
|
||||
return { col: colToNumber(m[1]), row: Number(m[2]) - 1 };
|
||||
}
|
||||
|
||||
/** Build "A1" from 0-based coordinates. */
|
||||
export function toA1(col: number, row: number): string {
|
||||
return `${numberToCol(col)}${row + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a rectangular range (given by two corners, any order) into the list of
|
||||
* cell coordinates it covers, row-major.
|
||||
*/
|
||||
export function expandRange(
|
||||
a: { col: number; row: number },
|
||||
b: { col: number; row: number },
|
||||
): Array<{ col: number; row: number }> {
|
||||
const c0 = Math.min(a.col, b.col);
|
||||
const c1 = Math.max(a.col, b.col);
|
||||
const r0 = Math.min(a.row, b.row);
|
||||
const r1 = Math.max(a.row, b.row);
|
||||
const out: Array<{ col: number; row: number }> = [];
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
out.push({ col: c, row: r });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared types for the formula engine.
|
||||
*
|
||||
* The engine is intentionally framework-agnostic: it knows nothing about Vue,
|
||||
* the store or the DOM. The host application implements {@link EngineHost} to
|
||||
* expose the current document to the evaluator.
|
||||
*/
|
||||
|
||||
/** All error values the engine can produce. They propagate to dependent cells. */
|
||||
export type ErrorCode =
|
||||
| '#REF' // invalid / deleted reference or unknown sheet name
|
||||
| '#DIV/0!' // division by zero
|
||||
| '#NAME?' // unknown function
|
||||
| '#ERR' // parse error
|
||||
| '#CIRC' // circular reference
|
||||
| '#VALUE!'; // wrong value type in arithmetic (documented extension)
|
||||
|
||||
export const ERROR_CODES: ErrorCode[] = [
|
||||
'#REF',
|
||||
'#DIV/0!',
|
||||
'#NAME?',
|
||||
'#ERR',
|
||||
'#CIRC',
|
||||
'#VALUE!',
|
||||
];
|
||||
|
||||
/** A formula error. Instances are immutable and comparable by `code`. */
|
||||
export class FormulaError {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
public readonly detail?: string,
|
||||
) {}
|
||||
toString(): string {
|
||||
return this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isError(v: unknown): v is FormulaError {
|
||||
return v instanceof FormulaError;
|
||||
}
|
||||
|
||||
/** A plain scalar a cell can hold once evaluated. `null` means "empty cell". */
|
||||
export type Scalar = number | string | null;
|
||||
|
||||
/** The fully evaluated value of a cell. */
|
||||
export type CellValue = Scalar | FormulaError;
|
||||
|
||||
/** 0-based cell coordinate inside a single table. */
|
||||
export interface Coord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
/** A cell address that also carries the id of the table it lives on. */
|
||||
export interface CellRef extends Coord {
|
||||
tableId: string;
|
||||
}
|
||||
|
||||
/** Stable string key for a cell across the whole document. */
|
||||
export function keyOf(tableId: string, col: number, row: number): string {
|
||||
return `${tableId}␟${col}␟${row}`;
|
||||
}
|
||||
|
||||
export function parseKey(key: string): CellRef {
|
||||
const [tableId, col, row] = key.split('␟');
|
||||
return { tableId, col: Number(col), row: Number(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as seen by the engine. Implemented by the Vue store.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). Table names are unique across the whole document, so a qualified
|
||||
* reference like `Umsatz!B2` / `Umsatz::B2` resolves the same no matter which
|
||||
* sheet the formula or the target table sits on.
|
||||
*/
|
||||
export interface EngineHost {
|
||||
/** Id of the table the formula currently being evaluated lives on. */
|
||||
homeTableId: string;
|
||||
/**
|
||||
* Resolve a table *name* (as written in a formula) to its id. Matching is
|
||||
* case-insensitive and tolerant of surrounding whitespace. Returns `null`
|
||||
* for an unknown / deleted table.
|
||||
*/
|
||||
resolveTableName(name: string): string | null;
|
||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
* Already-computed value of another cell. The recalculation driver guarantees
|
||||
* precedents are evaluated first (topological order), so this never triggers
|
||||
* a nested evaluation.
|
||||
*/
|
||||
getComputed(tableId: string, col: number, row: number): CellValue;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Framework-agnostic spreadsheet model that ties the engine modules together.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). A `Workbook` owns a flat list of tables and, across all of them:
|
||||
* - the raw text of every non-empty cell,
|
||||
* - the parsed AST of every formula cell (or an `#ERR` marker),
|
||||
* - the computed value of every cell,
|
||||
* - the document-wide {@link DependencyGraph}.
|
||||
*
|
||||
* It implements {@link EngineHost} so the evaluator can read neighbouring cell
|
||||
* values through it. The Vue store wraps an instance of this class in `reactive`
|
||||
* and mirrors its computed values into a reactive map, which makes every formula
|
||||
* cell behave like a `computed` value in the UI.
|
||||
*
|
||||
* Recalculation is incremental: `setCell` reparses one cell, updates its graph
|
||||
* edges and recomputes only the affected sub-graph in topological order.
|
||||
* Structural edits (rename / delete table, add rows, ...) call `rebuild()` for a
|
||||
* full, still-cheap pass.
|
||||
*/
|
||||
|
||||
import { DependencyGraph } from './graph';
|
||||
import { collectRefs, parseFormula, type Node } from './parser';
|
||||
import { expandRange } from './refs';
|
||||
import {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
export interface TableModel {
|
||||
id: string;
|
||||
/** document-unique table name, used as the qualifier in formulas */
|
||||
name: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
|
||||
inputs: Map<string, string>;
|
||||
}
|
||||
|
||||
const NUMBER_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
||||
|
||||
function localKey(col: number, row: number): string {
|
||||
return `${col},${row}`;
|
||||
}
|
||||
|
||||
/** Interpret a raw literal (non-formula) cell input. */
|
||||
export function literalValue(raw: string): CellValue {
|
||||
const t = raw.trim();
|
||||
if (t === '') return null;
|
||||
if (NUMBER_RE.test(t)) return Number(t);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export class Workbook implements EngineHost {
|
||||
tables: TableModel[];
|
||||
/** transiently set to the home table while a formula is being evaluated */
|
||||
homeTableId = '';
|
||||
|
||||
private readonly graph = new DependencyGraph();
|
||||
/** cellKey -> parsed formula AST, or an error marker for un-parsable input */
|
||||
private readonly asts = new Map<string, Node | FormulaError>();
|
||||
/** cellKey -> computed value */
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
|
||||
constructor(tables: TableModel[]) {
|
||||
this.tables = tables;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
// ---- EngineHost ---------------------------------------------------------
|
||||
|
||||
resolveTableName(name: string): string | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const hit = this.tables.find((t) => t.name.trim().toLowerCase() === needle);
|
||||
return hit ? hit.id : null;
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
}
|
||||
|
||||
getComputed(tableId: string, col: number, row: number): CellValue {
|
||||
if (col < 0 || row < 0) return new FormulaError('#REF');
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- reads ------------------------------------------------------------
|
||||
|
||||
tableById(id: string): TableModel | undefined {
|
||||
return this.tables.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
getInput(tableId: string, col: number, row: number): string {
|
||||
return this.tableById(tableId)?.inputs.get(localKey(col, row)) ?? '';
|
||||
}
|
||||
|
||||
/** Public computed-value read used by the store / UI. */
|
||||
cellValue(tableId: string, col: number, row: number): CellValue {
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------
|
||||
|
||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||
setCell(tableId: string, col: number, row: number, raw: string): void {
|
||||
const table = this.tableById(tableId);
|
||||
if (!table) return;
|
||||
const lk = localKey(col, row);
|
||||
if (raw === '') table.inputs.delete(lk);
|
||||
else table.inputs.set(lk, raw);
|
||||
|
||||
const key = keyOf(tableId, col, row);
|
||||
this.parseCell(key, tableId, raw);
|
||||
this.recalc([key]);
|
||||
}
|
||||
|
||||
/** Reparse every formula, rebuild the graph, recompute everything. */
|
||||
rebuild(): void {
|
||||
this.graph.reset();
|
||||
this.asts.clear();
|
||||
const all: string[] = [];
|
||||
for (const table of this.tables) {
|
||||
for (const [lk, raw] of table.inputs) {
|
||||
const [c, r] = lk.split(',').map(Number);
|
||||
const key = keyOf(table.id, c, r);
|
||||
all.push(key);
|
||||
this.parseCell(key, table.id, raw);
|
||||
}
|
||||
}
|
||||
this.recalcAll(all);
|
||||
}
|
||||
|
||||
// ---- internals ------------------------------------------------------
|
||||
|
||||
/** Parse one cell's raw input and (re)wire its graph edges. */
|
||||
private parseCell(key: string, tableId: string, raw: string): void {
|
||||
if (!raw.startsWith('=')) {
|
||||
this.asts.delete(key);
|
||||
this.graph.clear(key);
|
||||
return;
|
||||
}
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = parseFormula(raw);
|
||||
} catch (e) {
|
||||
// a bad formula is a cell-level error, never a crash
|
||||
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
|
||||
this.graph.setPrecedents(key, []);
|
||||
return;
|
||||
}
|
||||
this.asts.set(key, ast);
|
||||
this.graph.setPrecedents(key, this.precedentsOf(ast, tableId));
|
||||
}
|
||||
|
||||
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
|
||||
private precedentsOf(ast: Node, tableId: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
const target = ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if (ref.kind === 'cell') {
|
||||
out.push(keyOf(target, ref.col, ref.row));
|
||||
} else {
|
||||
for (const c of expandRange(ref.start, ref.end)) {
|
||||
out.push(keyOf(target, c.col, c.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Incremental recalculation starting from `dirty`. */
|
||||
private recalc(dirty: string[]): void {
|
||||
this.evaluateSet(this.graph.affected(dirty));
|
||||
}
|
||||
|
||||
/** Full recalculation of the given (complete) cell set. */
|
||||
private recalcAll(all: string[]): void {
|
||||
this.values.clear();
|
||||
this.evaluateSet(new Set(all));
|
||||
}
|
||||
|
||||
private evaluateSet(affected: Set<string>): void {
|
||||
const { order, circular } = this.graph.topoOrder(affected);
|
||||
for (const key of circular) this.values.set(key, new FormulaError('#CIRC'));
|
||||
for (const key of order) this.values.set(key, this.evaluateCell(key));
|
||||
}
|
||||
|
||||
private evaluateCell(key: string): CellValue {
|
||||
const ast = this.asts.get(key);
|
||||
if (ast === undefined) {
|
||||
const { tableId, col, row } = parseKey(key);
|
||||
return literalValue(this.getInput(tableId, col, row));
|
||||
}
|
||||
if (isError(ast)) return ast;
|
||||
this.homeTableId = parseKey(key).tableId;
|
||||
try {
|
||||
return evaluate(ast, this);
|
||||
} catch {
|
||||
return new FormulaError('#ERR', 'evaluation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute order for a hand-picked dirty set -- exposed for tests. */
|
||||
debugOrder(dirty: string[]): { order: string[]; circular: string[] } {
|
||||
const { order, circular } = this.graph.topoOrder(this.graph.affected(dirty));
|
||||
return { order, circular: [...circular] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user