summarum/src/workspace.ts
google-labs-jules[bot] c10a13f226 perf(workspace): optimize sheet title caching to reduce string allocations
Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
2026-08-26 14:09:14 +00:00

181 lines
6.3 KiB
TypeScript

// 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;
results: LineResult[];
}
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: Set<string> = new Set();
private parsedRefsCache = new Map<string, { text: string; refs: string[] }>();
private titleCache: Map<string, SheetSource> | null = null;
private lowerTitleCache = new Map<string, string>();
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {}
private doEvaluate(sheetId: string, text: string): LineResult[] {
this.resolving.add(sheetId);
try {
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key));
} finally {
this.resolving.delete(sheetId);
}
}
/** Evaluate a sheet as the user's active/open document — refs resolve live. */
evaluateSheet(sheetId: string, text: string): LineResult[] {
return this.doEvaluate(sheetId, text);
}
getCachedResults(sheet: SheetSource): LineResult[] {
return this.exportsFor(sheet).results;
}
/** Drop cached exports for a sheet and everything that transitively depends on it. */
invalidate(sheetId: string): void {
this.titleCache = null;
const dirty = new Set<string>([sheetId]);
const sheetsList = this.sheets();
let changed = true;
while (changed) {
changed = false;
for (const s of sheetsList) {
if (dirty.has(s.id)) continue;
for (const title of this.referencedTitles(s.text, s.id)) {
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.titleCache = null;
this.cache.clear();
this.parsedRefsCache.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, id?: string): string[] {
if (id) {
const cached = this.parsedRefsCache.get(id);
if (cached && cached.text === text) return cached.refs;
}
const out: string[] = [];
for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim());
if (id) {
this.parsedRefsCache.set(id, { text, refs: out });
}
return out;
}
private findSheetByTitle(title: string): SheetSource | undefined {
const needle = title.trim().toLowerCase();
if (this.titleCache === null) {
this.titleCache = new Map();
const currentSheets = this.sheets();
for (let i = 0, len = currentSheets.length; i < len; i++) {
const s = currentSheets[i];
let key = this.lowerTitleCache.get(s.title);
if (key === undefined) {
key = s.title.trim().toLowerCase();
this.lowerTitleCache.set(s.title, key);
}
if (!this.titleCache.has(key)) {
this.titleCache.set(key, s);
}
}
}
return this.titleCache.get(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.has(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;
const results = this.doEvaluate(sheet.id, sheet.text);
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, results };
// A cycle-interrupted pass produces incomplete/wrong exports (dropped
// assignments, missing total) — don't let it poison the cache. Re-evaluate
// fresh on every query until the cycle is actually broken by an edit.
if (!results.some((r) => r.error === "circular reference")) {
this.cache.set(sheet.id, exports);
}
return exports;
}
}