diff --git a/src/workspace.test.ts b/src/workspace.test.ts new file mode 100644 index 0000000..8b4255a --- /dev/null +++ b/src/workspace.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest"; +import { SumEngine } from "./engine"; +import { Workspace, SheetSource } from "./workspace"; + +function makeWorkspace(initial: Record) { + const engine = new SumEngine(); + const store = new Map(Object.entries(initial).map(([id, v]) => [id, { id, ...v }])); + const sheets = (): SheetSource[] => [...store.values()]; + const ws = new Workspace(engine, sheets); + return { engine, store, ws }; +} + +describe("Workspace exports", () => { + it("exposes assigned variables, total and last through @Sheet.key", () => { + const { ws } = makeWorkspace({ + budget: { title: "Budget", text: "rent = $500\nfood = $200" }, + }); + const r = ws.evaluateSheet("dash", "@Budget.rent\n@Budget.total\n@Budget.last"); + expect(r[0].text).toBe("$500"); + expect(r[1].text).toBe("$700"); + expect(r[2].text).toBe("$200"); + }); + + it("caches a sheet's exports until invalidated", () => { + const { ws, store } = makeWorkspace({ + budget: { title: "Budget", text: "rent = $500" }, + }); + expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500"); + store.get("budget")!.text = "rent = $900"; + expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500"); + ws.invalidate("budget"); + expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$900"); + }); + + it("evaluates a sheet lazily — only when it's actually referenced", () => { + const { engine, ws } = makeWorkspace({ + never: { title: "Never", text: "1 + 1" }, + }); + const spy = vi.spyOn(engine, "evaluateDocument"); + ws.evaluateSheet("dash", "hello"); + expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(false); + ws.evaluateSheet("dash", "@Never.total"); + expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(true); + }); +}); + +describe("Workspace invalidation", () => { + it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => { + const { ws, store } = makeWorkspace({ + a: { title: "A", text: "x = 10" }, + b: { title: "B", text: "z = @A.x" }, + c: { title: "C", text: "y = 5" }, + }); + expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10"); + expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5"); + + store.get("a")!.text = "x = 99"; + expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10"); + + ws.invalidate("a"); + expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("99"); + expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5"); + }); +}); + +describe("Workspace cycle detection", () => { + it("a true two-sheet cycle terminates instead of looping forever", () => { + const { ws } = makeWorkspace({ + a: { title: "A", text: "z = @B.total" }, + b: { title: "B", text: "z = @A.total" }, + }); + const r = ws.evaluateSheet("dash", "@A.z"); + expect(r[0].value).toBeNull(); + expect(r[0].error).toBeDefined(); + }); + + it("a sheet referencing itself surfaces a circular reference immediately", () => { + const { ws } = makeWorkspace({ self: { title: "Self", text: "" } }); + const text = "a = 5\nb = @Self.a"; + expect(() => ws.evaluateSheet("self", text)).not.toThrow(); + const r = ws.evaluateSheet("self", text); + expect(r[1].value).toBeNull(); + expect(r[1].error).toBe("circular reference"); + }); +}); + +describe("Workspace.renameSheet", () => { + it("rewrites bare-form references to the new title", () => { + const { ws } = makeWorkspace({ + budget: { title: "Budget", text: "rent = $500" }, + dash: { title: "Dashboard", text: "@Budget.rent + 1" }, + }); + expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([{ id: "dash", text: "@Money.rent + 1" }]); + }); + + it("switches to bracket form when the new title needs it", () => { + const { ws } = makeWorkspace({ + budget: { title: "Budget", text: "rent = $500" }, + dash: { title: "Dashboard", text: "@Budget.rent" }, + }); + expect(ws.renameSheet("budget", "Budget", "Monthly Budget")).toEqual([ + { id: "dash", text: "@[Monthly Budget].rent" }, + ]); + }); + + it("rewrites existing bracket-form references too", () => { + const { ws } = makeWorkspace({ + trip: { title: "Trip to Lisbon", text: "food = 25" }, + dash: { title: "Dashboard", text: "@[Trip to Lisbon].food" }, + }); + expect(ws.renameSheet("trip", "Trip to Lisbon", "Lisbon")).toEqual([{ id: "dash", text: "@Lisbon.food" }]); + }); + + it("never rewrites the renamed sheet's own text", () => { + const { ws } = makeWorkspace({ + budget: { title: "Budget", text: "a = 1\nb = @Budget.a" }, + }); + expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]); + }); + + it("leaves unrelated sheets untouched", () => { + const { ws } = makeWorkspace({ + budget: { title: "Budget", text: "rent = $500" }, + other: { title: "Other", text: "just text, no refs" }, + }); + expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]); + }); +}); diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..45c726e --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,144 @@ +// Owns the cross-sheet dependency graph: which sheets a `@Sheet.key` +// reference can see, cached exports, cycle detection, and rename rewrites. +// The engine has no concept of "other sheets" — that lives entirely here. +import type { SumEngine, LineResult } from "./engine"; +import type { Value } from "./engine/types"; +import type { XRefResolution } from "./engine/evaluator"; + +export interface SheetSource { + id: string; + title: string; + text: string; +} + +interface SheetExports { + vars: Map; + total: Value | null; + last: Value | null; +} + +const XREF_SCAN_RE = /@(?:\[([^\]]+)\]|([\p{L}_][\p{L}\d_]*))\./gu; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export class Workspace { + private cache = new Map(); + private resolving: string[] = []; + + constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {} + + /** Evaluate a sheet as the user's active/open document — refs resolve live. */ + evaluateSheet(sheetId: string, text: string): LineResult[] { + this.resolving.push(sheetId); + try { + return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key)); + } finally { + this.resolving.pop(); + } + } + + /** Drop cached exports for a sheet and everything that transitively depends on it. */ + invalidate(sheetId: string): void { + const dirty = new Set([sheetId]); + let changed = true; + while (changed) { + changed = false; + for (const s of this.sheets()) { + if (dirty.has(s.id)) continue; + for (const title of this.referencedTitles(s.text)) { + const target = this.findSheetByTitle(title); + if (target && dirty.has(target.id)) { + dirty.add(s.id); + changed = true; + break; + } + } + } + } + for (const id of dirty) this.cache.delete(id); + } + + invalidateAll(): void { + this.cache.clear(); + } + + /** + * Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name + * in every *other* sheet. Returns only the sheets whose text changed. + */ + renameSheet(renamedId: string, oldTitle: string, newTitle: string): { id: string; text: string }[] { + const oldEsc = escapeRegExp(oldTitle.trim()); + const bareRe = new RegExp(`@${oldEsc}\\.`, "gu"); + const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu"); + const trimmedNew = newTitle.trim(); + const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`; + const out: { id: string; text: string }[] = []; + for (const s of this.sheets()) { + if (s.id === renamedId) continue; + const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement); + if (rewritten !== s.text) out.push({ id: s.id, text: rewritten }); + } + return out; + } + + private referencedTitles(text: string): string[] { + const out: string[] = []; + for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim()); + return out; + } + + private findSheetByTitle(title: string): SheetSource | undefined { + const needle = title.trim().toLowerCase(); + return this.sheets().find((s) => s.title.trim().toLowerCase() === needle); + } + + private resolveXRef(sheetTitle: string, key: string): XRefResolution { + const target = this.findSheetByTitle(sheetTitle); + if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` }; + if (this.resolving.includes(target.id)) { + return { ok: false, reason: "circular reference" }; + } + const exports = this.exportsFor(target); + if (key === "total") { + return exports.total + ? { ok: true, value: exports.total } + : { ok: false, reason: `sheet "${sheetTitle}" has no total` }; + } + if (key === "last") { + return exports.last + ? { ok: true, value: exports.last } + : { ok: false, reason: `sheet "${sheetTitle}" has no result` }; + } + const v = exports.vars.get(key); + if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` }; + return { ok: true, value: v }; + } + + private exportsFor(sheet: SheetSource): SheetExports { + const cached = this.cache.get(sheet.id); + if (cached) return cached; + this.resolving.push(sheet.id); + let results: LineResult[]; + try { + results = this.engine.evaluateDocument(sheet.text, (s, k) => this.resolveXRef(s, k)); + } finally { + this.resolving.pop(); + } + const vars = new Map(); + for (const r of results) { + if (r.assign && r.value) vars.set(r.assign, r.value); + } + let last: Value | null = null; + for (let i = results.length - 1; i >= 0; i--) { + if (results[i].kind === "normal" && results[i].value) { + last = results[i].value; + break; + } + } + const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last }; + this.cache.set(sheet.id, exports); + return exports; + } +}