From 677f94004373dc87d6c636e45747486eac4bb102 Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:26:25 +0300 Subject: [PATCH] engine: @Sheet.key cross-sheet reference syntax and resolver hook --- src/engine/engine.test.ts | 79 +++++++++++++++++++++++++++++++++++++++ src/engine/evaluator.ts | 14 ++++++- src/engine/index.ts | 34 ++++++++++++----- src/engine/lexer.ts | 19 +++++++++- src/engine/parser.ts | 4 ++ src/engine/tokenizer.ts | 7 ++++ src/engine/types.ts | 1 + 7 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 4082618..3ac0b42 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeAll } from "vitest"; import { SumEngine } from "./index"; +import { qty } from "./types"; +import type { XRefResolution } from "./evaluator"; +import { formatValue } from "./index"; let eng: SumEngine; @@ -334,3 +337,79 @@ describe("extension API", () => { expect(calc("myvar * 2")).toBe("$10"); }); }); + +function calcWithResolver( + expr: string, + resolver: (sheet: string, key: string) => XRefResolution, +): string | null { + const r = eng.evaluateDocument(expr, resolver); + return r[r.length - 1].text; +} + +describe("grand total", () => { + it("totalValueOf's raw value formats identically to totalOf's string", () => { + const results = eng.evaluateDocument("$100\n$50"); + const raw = eng.totalValueOf(results); + expect(raw).not.toBeNull(); + expect(formatValue(raw!, eng.settings)).toBe(eng.totalOf(results)); + }); +}); + +describe("cross-sheet references", () => { + function resolver(sheet: string, key: string): XRefResolution { + const s = sheet.toLowerCase(); + if (s === "budget") { + if (key === "total") return { ok: true, value: qty(150) }; + if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) }; + if (key === "last") return { ok: true, value: qty(7) }; + return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; + } + if (s === "trip to lisbon") { + if (key === "food") return { ok: true, value: qty(25) }; + return { ok: false, reason: `no variable "${key}" in "${sheet}"` }; + } + return { ok: false, reason: `sheet "${sheet}" not found` }; + } + + it("resolves @Sheet.total", () => { + expect(calcWithResolver("@Budget.total", resolver)).toBe("150"); + }); + it("resolves @Sheet.var and participates in arithmetic", () => { + expect(calcWithResolver("@Budget.rent + 1 m", resolver)).toBe("501 m"); + }); + it("resolves @Sheet.last", () => { + expect(calcWithResolver("@Budget.last", resolver)).toBe("7"); + }); + it("unit conversion over a resolved reference", () => { + expect(calcWithResolver("@Budget.rent in cm", resolver)).toBe("50,000 cm"); + }); + it("bracket form for titles with spaces", () => { + expect(calcWithResolver("@[Trip to Lisbon].food", resolver)).toBe("25"); + }); + it("unresolved sheet sets a line error instead of crashing", () => { + const r = eng.evaluateDocument("@Nope.total", resolver); + expect(r[0].value).toBeNull(); + expect(r[0].error).toBe('sheet "Nope" not found'); + }); + it("unresolved key sets a line error", () => { + const r = eng.evaluateDocument("@Budget.nope", resolver); + expect(r[0].value).toBeNull(); + expect(r[0].error).toBe('no variable "nope" in "Budget"'); + }); + it("propagates a resolver-reported circular reference", () => { + const cyc = (): XRefResolution => ({ ok: false, reason: "circular reference" }); + const r = eng.evaluateDocument("@A.total", cyc); + expect(r[0].value).toBeNull(); + expect(r[0].error).toBe("circular reference"); + }); + it("no resolver given: resolves to null without throwing", () => { + expect(() => eng.evaluateDocument("@Budget.total")).not.toThrow(); + const r = eng.evaluateDocument("@Budget.total"); + expect(r[0].value).toBeNull(); + expect(r[0].error).toBe('sheet "Budget" not found'); + }); + it("an assignment line reports the assigned name via LineResult.assign", () => { + const r = eng.evaluateDocument("rent = $500"); + expect(r[0].assign).toBe("rent"); + }); +}); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index bb364f6..2431251 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -1,6 +1,6 @@ // Walks the AST with the document context (variables, line results for // sum/avg/prev) and produces unit-aware values. -import { Decimal, EvalError, Quantity, Unit, Value, qty, pct } from "./types"; +import { Decimal, EvalError, Quantity, Unit, Value, XRefError, qty, pct } from "./types"; import { Node, ConvTarget } from "./parser"; import { Registry } from "./registry"; import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime"; @@ -21,8 +21,13 @@ export interface EvalCtx { line: LineCtx; /** date → { code → rate-per-usd } */ historicalRates?: Map>; + /** cross-sheet `@Sheet.key` lookup; absent outside the app (tests, extensions) */ + resolveXRef?: XRefResolver; } +export type XRefResolution = { ok: true; value: Value } | { ok: false; reason: string }; +export type XRefResolver = (sheet: string, key: string) => XRefResolution; + const PI = new Decimal("3.14159265358979323846264338327950288419716939937510"); const E = new Decimal("2.71828182845904523536028747135266249775724709369995"); @@ -46,6 +51,13 @@ export function evaluate(node: Node, ctx: EvalCtx): Value { return evalDateWord(node.word); case "datelit": return { kind: "date", ms: node.ms, hasTime: false }; + case "xref": { + const res = ctx.resolveXRef?.(node.sheet, node.key); + if (!res || !res.ok) { + throw new XRefError(res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`); + } + return res.value; + } case "unknown": { const xv = ctx.vars.get("__x__"); if (!xv) throw new EvalError("? used outside goal seek"); diff --git a/src/engine/index.ts b/src/engine/index.ts index 3c77169..35e1bd0 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1,10 +1,10 @@ // Public facade: evaluates whole documents line by line and exposes the // extension API (numi.addUnit / addFunction / setVariable). -import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, defaultSettings, qty } from "./types"; +import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import { Registry, buildRegistry, Completion } from "./registry"; import { tokenize, Token } from "./tokenizer"; import { parseLine } from "./parser"; -import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator"; +import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; import { formatValue } from "./formatter"; import snapshot from "./rates-snapshot.json"; import { CRYPTO } from "./extraunits"; @@ -21,6 +21,10 @@ export interface LineResult { tokens: Token[]; /** offset of "//" comment start within the line, if any */ commentStart: number | null; + /** name this line assigns, if any (`rent = $500` → "rent") */ + assign?: string; + /** set when the line references an unresolved cross-sheet value */ + error?: string; } export interface ExtensionUnitSpec { @@ -70,7 +74,7 @@ export class SumEngine { return this.reg.completions; } - evaluateDocument(text: string): LineResult[] { + evaluateDocument(text: string, resolveXRef?: XRefResolver): LineResult[] { const lines = text.split("\n"); const results: LineResult[] = []; const vars = new Map(this.globals); @@ -102,19 +106,22 @@ export class SumEngine { const parsed = parseLine(tokens, knownVars, line); let value: Value | null = null; + let lineError: string | undefined; if (parsed.expr) { const ctx: EvalCtx = { reg: this.reg, vars, line: { lineValues, lineKinds, index: i, lineText: rawLine }, historicalRates: this.historicalRates, + resolveXRef, }; try { value = evaluate(parsed.expr, ctx); // a line without a result beats a dead sheet — swallow everything, // including extension bugs and BigInt conversion errors } catch (e) { - if (!(e instanceof EvalError)) console.warn("evaluate failed:", e); + if (e instanceof XRefError) lineError = e.message; + else if (!(e instanceof EvalError)) console.warn("evaluate failed:", e); value = null; } if (value?.kind === "quantity" && !value.value.isFinite()) value = null; @@ -129,6 +136,8 @@ export class SumEngine { kind: "normal", tokens, commentStart: commentStart >= 0 ? commentStart : null, + assign: parsed.assign, + error: lineError, }); } return results; @@ -141,11 +150,11 @@ export class SumEngine { } /** - * Grand total of the sheet for the status bar: sums every line result - * compatible with the first united result's dimension (converted to its - * unit). Returns null when fewer than two lines contribute. + * Grand total of the sheet: sums every line result compatible with the + * first united result's dimension (converted to its unit). Returns null + * when fewer than two lines contribute. */ - totalOf(results: LineResult[]): string | null { + totalValueOf(results: LineResult[]): Quantity | null { const qs = results .map((r) => r.value) .filter((v): v is Quantity => v !== null && v.kind === "quantity"); @@ -164,7 +173,13 @@ export class SumEngine { } if (count < 2) return null; const value = anchor.unit ? fromBase(acc, anchor.unit) : acc; - return formatValue(qty(value, anchor.unit ?? null), this.settings); + return qty(value, anchor.unit ?? null); + } + + /** Formatted string for the status bar; see totalValueOf for the raw value. */ + totalOf(results: LineResult[]): string | null { + const v = this.totalValueOf(results); + return v ? formatValue(v, this.settings) : null; } // ---------- extension API (numi.*) @@ -229,3 +244,4 @@ export class SumEngine { export { formatValue } from "./formatter"; export type { Value, EngineSettings } from "./types"; export type { Token } from "./tokenizer"; +export type { XRefResolution, XRefResolver } from "./evaluator"; diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index 8fa9f3b..ecfc822 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -3,13 +3,15 @@ import { Decimal, NumeralRepr } from "./types"; export interface Lex { - type: "num" | "word" | "sym" | "date"; + type: "num" | "word" | "sym" | "date" | "xref"; raw: string; start: number; end: number; value?: Decimal; repr?: NumeralRepr; dateMs?: number; + sheet?: string; + key?: string; } const WORD_RE = /^[\p{L}_]+/u; @@ -98,6 +100,21 @@ export function lexLine(text: string): Lex[] { continue; } + if (ch === "@") { + const bracket = /^@\[([^\]]+)\]\.([\p{L}_][\p{L}\d_]*)/u.exec(rest); + if (bracket) { + out.push({ type: "xref", raw: bracket[0], start: i, end: i + bracket[0].length, sheet: bracket[1].trim(), key: bracket[2] }); + i += bracket[0].length; + continue; + } + const bare = /^@([\p{L}_][\p{L}\d_]*)\.([\p{L}_][\p{L}\d_]*)/u.exec(rest); + if (bare) { + out.push({ type: "xref", raw: bare[0], start: i, end: i + bare[0].length, sheet: bare[1], key: bare[2] }); + i += bare[0].length; + continue; + } + } + const word = WORD_RE.exec(rest); if (word) { out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length }); diff --git a/src/engine/parser.ts b/src/engine/parser.ts index effe694..d0bded3 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -20,6 +20,7 @@ export type Node = | { k: "var"; name: string } | { k: "date"; word: DateWord } | { k: "datelit"; ms: number } + | { k: "xref"; sheet: string; key: string } | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node } @@ -395,6 +396,9 @@ class Parser { case "datelit": this.i++; return { k: "datelit", ms: tk.ms }; + case "xref": + this.i++; + return { k: "xref", sheet: tk.sheet, key: tk.key }; case "agg": this.i++; return { k: "agg", name: tk.name }; diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts index fefd526..51668f0 100644 --- a/src/engine/tokenizer.ts +++ b/src/engine/tokenizer.ts @@ -20,6 +20,7 @@ export type Token = | { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "date"; word: DateWord; start: number; end: number } | { t: "datelit"; ms: number; start: number; end: number } + | { t: "xref"; sheet: string; key: string; start: number; end: number } | { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number } | { t: "lparen"; start: number; end: number } | { t: "rparen"; start: number; end: number } @@ -53,6 +54,12 @@ export function tokenize(line: string, reg: Registry): Token[] { continue; } + if (lx.type === "xref") { + tokens.push({ t: "xref", sheet: lx.sheet!, key: lx.key!, ...span }); + i++; + continue; + } + const m = reg.match(lexes, lowers, i); if (m) { const end = lexes[i + m.length - 1].end; diff --git a/src/engine/types.ts b/src/engine/types.ts index 37a967d..e1f84ac 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -79,6 +79,7 @@ export function pct(value: Decimal.Value): PercentVal { } export class EvalError extends Error {} +export class XRefError extends EvalError {} export interface EngineSettings { /** max decimal places shown */