/** * 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 { rangeCells } from './refs'; import { parseDateTimeLiteral } from './dates'; import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions'; import { FormulaError, isError, keyOf, parseKey, type CellRef, type CellValue, type EngineHost, type NamedRangeDef, } from './types'; import { evaluate } from './evaluator'; export interface TableModel { id: string; /** table name, used as the qualifier in formulas -- unique *per sheet* * only; the same name may occur on more than one sheet (see * `sheetName` and {@link EngineHost.resolveTableName}). */ name: string; /** name of the sheet this table lives on. */ sheetName: string; cols: number; rows: number; /** key `${col},${row}` -> raw user input. Absent = empty cell. */ inputs: Map; } 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. A recognized date/time * (see dates.ts's parseDateTimeLiteral) becomes its serial number -- dates * are just numbers in this engine, so `+`/`-`/comparisons work on them for * free. Checked before NUMBER_RE; the two never collide (dates use `-`/`.` * multi-part separators or `:`, which NUMBER_RE's single-decimal-point * shape never matches). */ export function literalValue(raw: string): CellValue { const t = raw.trim(); if (t === '') return null; const dt = parseDateTimeLiteral(t); if (dt) return dt.serial; 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(); /** cellKey -> computed value */ private readonly values = new Map(); /** user-defined function library for this document, name.toUpperCase() -> def. */ private readonly functionIndex: Map; /** document-wide named ranges (see NamedRangeDef's doc comment). */ private readonly namedRanges: NamedRangeDef[]; constructor( tables: TableModel[], functionDefs: FunctionDef[] = [], namedRanges: NamedRangeDef[] = [], ) { this.tables = tables; this.functionIndex = buildFunctionIndex(functionDefs); this.namedRanges = namedRanges; this.rebuild(); } // ---- EngineHost --------------------------------------------------------- resolveTableName(name: string, sheetName?: string | null): string | null { const needle = name.trim().toLowerCase(); const candidates = this.tables.filter((t) => t.name.trim().toLowerCase() === needle); if (candidates.length === 0) return null; if (sheetName != null) { const sheetNeedle = sheetName.trim().toLowerCase(); const hit = candidates.find((t) => t.sheetName.trim().toLowerCase() === sheetNeedle); return hit ? hit.id : null; } if (candidates.length === 1) return candidates[0].id; // ambiguous (same name on more than one sheet) -- prefer a table on the // same sheet as the formula doing the referencing, if there is one const home = this.tableById(this.homeTableId); if (home) { const sameSheet = candidates.find((t) => t.sheetName === home.sheetName); if (sameSheet) return sameSheet.id; } return null; // still ambiguous -> #REF, needs an explicit sheet qualifier } resolveNamedRange( name: string, ): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null { const needle = name.trim().toLowerCase(); const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle); if (!def) return null; const table = this.tableById(def.tableId); if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end }; } tableSize(tableId: string): { cols: number; rows: number } | null { const t = this.tableById(tableId); return t ? { cols: t.cols, rows: t.rows } : null; } 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; } /** Direct precedents of a cell -- the cells its own formula reads (a * formula's `#REF`/named-range/etc. resolution already baked into the * graph edges, see `precedentsOf()` above). Public wrapper around the * internal `DependencyGraph`, for UI features (the dependency-graph * view) that need to *query* the graph -- evaluation itself never calls * this, it only calls `setPrecedents`/`affected`/`topoOrder`. */ graphPrecedents(tableId: string, col: number, row: number): CellRef[] { return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey); } /** Direct dependents of a cell -- every cell whose formula reads *this* * one. See graphPrecedents() above. */ graphDependents(tableId: string, col: number, row: number): CellRef[] { return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey); } // ---- 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); ast = expandUserFunctions(ast, this.functionIndex); } 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[] { // resolveTableName()'s "prefer the home sheet" ambiguity fallback reads // this.homeTableId -- set it here too (not just in evaluateCell) so graph // edges are wired using the *same* resolution an evaluation would use. this.homeTableId = tableId; const out: string[] = []; for (const ref of collectRefs(ast)) { // a named range isn't itself a qualifier/coords pair -- resolve it to // one first (same shape as a 'cell'/'range' ref), then fall through to // the identical edge-wiring below. const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref; if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME? const target = resolved.qualifier == null ? tableId : this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null); if (!target) continue; // unknown table -> no edge; evaluator emits #REF if ('col' in resolved) { out.push(keyOf(target, resolved.col, resolved.row)); } else { for (const c of rangeCells(resolved, this.tableSize(target))) { 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): 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] }; } }