mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 16:51:59 +03:00
Moved the evaluation of `this.sheets()` outside of the `while (changed)` invalidation loop to avoid redundant evaluations and allocations on deep dependency chains. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
174 lines
6 KiB
TypeScript
174 lines
6 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;
|
|
|
|
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();
|
|
for (const s of this.sheets()) {
|
|
const key = s.title.trim().toLowerCase();
|
|
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;
|
|
}
|
|
}
|