Files
numolus/src/engine/workbook.ts
T
2026-08-29 17:47:12 +02:00

218 lines
7.2 KiB
TypeScript

/**
* 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] };
}
}