mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +03:00
workspace: sheet dependency graph, export cache, cycle guard, rename rewrite
This commit is contained in:
parent
677f940043
commit
8fb9ee3a31
2 changed files with 272 additions and 0 deletions
128
src/workspace.test.ts
Normal file
128
src/workspace.test.ts
Normal 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
144
src/workspace.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue