workspace: sheet dependency graph, export cache, cycle guard, rename rewrite

This commit is contained in:
Horizontal42 2026-07-02 05:32:12 +03:00
parent 677f940043
commit 8fb9ee3a31
2 changed files with 272 additions and 0 deletions

128
src/workspace.test.ts Normal file
View file

@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { SumEngine } from "./engine";
import { Workspace, SheetSource } from "./workspace";
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
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([]);
});
});

144
src/workspace.ts Normal file
View file

@ -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<string, Value>;
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<string, SheetExports>();
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<string>([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<string, Value>();
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;
}
}