mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 23:51:59 +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