This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+103
View File
@@ -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.
+129
View File
@@ -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);
});
});
+61
View File
@@ -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']);
});
});
+66
View File
@@ -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();
});
});
+92
View File
@@ -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);
});
});
+146
View File
@@ -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)));
});
});
+117
View File
@@ -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`);
}
+64
View File
@@ -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()];
}
+137
View File
@@ -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 };
}
}
+43
View File
@@ -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';
+165
View File
@@ -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;
}
+284
View File
@@ -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;
}
+69
View File
@@ -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;
}
+94
View File
@@ -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;
}
+217
View File
@@ -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] };
}
}