engine: @Sheet.key cross-sheet reference syntax and resolver hook

This commit is contained in:
Horizontal42 2026-07-02 05:26:25 +03:00
parent b509a65fc5
commit 677f940043
7 changed files with 147 additions and 11 deletions

View file

@ -1,5 +1,8 @@
import { describe, it, expect, beforeAll } from "vitest"; import { describe, it, expect, beforeAll } from "vitest";
import { SumEngine } from "./index"; import { SumEngine } from "./index";
import { qty } from "./types";
import type { XRefResolution } from "./evaluator";
import { formatValue } from "./index";
let eng: SumEngine; let eng: SumEngine;
@ -334,3 +337,79 @@ describe("extension API", () => {
expect(calc("myvar * 2")).toBe("$10"); 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");
});
});

View file

@ -1,6 +1,6 @@
// Walks the AST with the document context (variables, line results for // Walks the AST with the document context (variables, line results for
// sum/avg/prev) and produces unit-aware values. // 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 { Node, ConvTarget } from "./parser";
import { Registry } from "./registry"; import { Registry } from "./registry";
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime"; import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
@ -21,8 +21,13 @@ export interface EvalCtx {
line: LineCtx; line: LineCtx;
/** date → { code → rate-per-usd } */ /** date → { code → rate-per-usd } */
historicalRates?: Map<string, Map<string, number>>; historicalRates?: Map<string, Map<string, number>>;
/** 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 PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
const E = new Decimal("2.71828182845904523536028747135266249775724709369995"); const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
@ -46,6 +51,13 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
return evalDateWord(node.word); return evalDateWord(node.word);
case "datelit": case "datelit":
return { kind: "date", ms: node.ms, hasTime: false }; 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": { case "unknown": {
const xv = ctx.vars.get("__x__"); const xv = ctx.vars.get("__x__");
if (!xv) throw new EvalError("? used outside goal seek"); if (!xv) throw new EvalError("? used outside goal seek");

View file

@ -1,10 +1,10 @@
// Public facade: evaluates whole documents line by line and exposes the // Public facade: evaluates whole documents line by line and exposes the
// extension API (numi.addUnit / addFunction / setVariable). // 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 { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer"; import { tokenize, Token } from "./tokenizer";
import { parseLine } from "./parser"; import { parseLine } from "./parser";
import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator"; import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import snapshot from "./rates-snapshot.json"; import snapshot from "./rates-snapshot.json";
import { CRYPTO } from "./extraunits"; import { CRYPTO } from "./extraunits";
@ -21,6 +21,10 @@ export interface LineResult {
tokens: Token[]; tokens: Token[];
/** offset of "//" comment start within the line, if any */ /** offset of "//" comment start within the line, if any */
commentStart: number | null; 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 { export interface ExtensionUnitSpec {
@ -70,7 +74,7 @@ export class SumEngine {
return this.reg.completions; return this.reg.completions;
} }
evaluateDocument(text: string): LineResult[] { evaluateDocument(text: string, resolveXRef?: XRefResolver): LineResult[] {
const lines = text.split("\n"); const lines = text.split("\n");
const results: LineResult[] = []; const results: LineResult[] = [];
const vars = new Map<string, Value>(this.globals); const vars = new Map<string, Value>(this.globals);
@ -102,19 +106,22 @@ export class SumEngine {
const parsed = parseLine(tokens, knownVars, line); const parsed = parseLine(tokens, knownVars, line);
let value: Value | null = null; let value: Value | null = null;
let lineError: string | undefined;
if (parsed.expr) { if (parsed.expr) {
const ctx: EvalCtx = { const ctx: EvalCtx = {
reg: this.reg, reg: this.reg,
vars, vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine }, line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates, historicalRates: this.historicalRates,
resolveXRef,
}; };
try { try {
value = evaluate(parsed.expr, ctx); value = evaluate(parsed.expr, ctx);
// a line without a result beats a dead sheet — swallow everything, // a line without a result beats a dead sheet — swallow everything,
// including extension bugs and BigInt conversion errors // including extension bugs and BigInt conversion errors
} catch (e) { } 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; value = null;
} }
if (value?.kind === "quantity" && !value.value.isFinite()) value = null; if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
@ -129,6 +136,8 @@ export class SumEngine {
kind: "normal", kind: "normal",
tokens, tokens,
commentStart: commentStart >= 0 ? commentStart : null, commentStart: commentStart >= 0 ? commentStart : null,
assign: parsed.assign,
error: lineError,
}); });
} }
return results; return results;
@ -141,11 +150,11 @@ export class SumEngine {
} }
/** /**
* Grand total of the sheet for the status bar: sums every line result * Grand total of the sheet: sums every line result compatible with the
* compatible with the first united result's dimension (converted to its * first united result's dimension (converted to its unit). Returns null
* unit). Returns null when fewer than two lines contribute. * when fewer than two lines contribute.
*/ */
totalOf(results: LineResult[]): string | null { totalValueOf(results: LineResult[]): Quantity | null {
const qs = results const qs = results
.map((r) => r.value) .map((r) => r.value)
.filter((v): v is Quantity => v !== null && v.kind === "quantity"); .filter((v): v is Quantity => v !== null && v.kind === "quantity");
@ -164,7 +173,13 @@ export class SumEngine {
} }
if (count < 2) return null; if (count < 2) return null;
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc; 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.*) // ---------- extension API (numi.*)
@ -229,3 +244,4 @@ export class SumEngine {
export { formatValue } from "./formatter"; export { formatValue } from "./formatter";
export type { Value, EngineSettings } from "./types"; export type { Value, EngineSettings } from "./types";
export type { Token } from "./tokenizer"; export type { Token } from "./tokenizer";
export type { XRefResolution, XRefResolver } from "./evaluator";

View file

@ -3,13 +3,15 @@
import { Decimal, NumeralRepr } from "./types"; import { Decimal, NumeralRepr } from "./types";
export interface Lex { export interface Lex {
type: "num" | "word" | "sym" | "date"; type: "num" | "word" | "sym" | "date" | "xref";
raw: string; raw: string;
start: number; start: number;
end: number; end: number;
value?: Decimal; value?: Decimal;
repr?: NumeralRepr; repr?: NumeralRepr;
dateMs?: number; dateMs?: number;
sheet?: string;
key?: string;
} }
const WORD_RE = /^[\p{L}_]+/u; const WORD_RE = /^[\p{L}_]+/u;
@ -98,6 +100,21 @@ export function lexLine(text: string): Lex[] {
continue; 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); const word = WORD_RE.exec(rest);
if (word) { if (word) {
out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length }); out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length });

View file

@ -20,6 +20,7 @@ export type Node =
| { k: "var"; name: string } | { k: "var"; name: string }
| { k: "date"; word: DateWord } | { k: "date"; word: DateWord }
| { k: "datelit"; ms: number } | { k: "datelit"; ms: number }
| { k: "xref"; sheet: string; key: string }
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { 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: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
| { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node }
@ -395,6 +396,9 @@ class Parser {
case "datelit": case "datelit":
this.i++; this.i++;
return { k: "datelit", ms: tk.ms }; return { k: "datelit", ms: tk.ms };
case "xref":
this.i++;
return { k: "xref", sheet: tk.sheet, key: tk.key };
case "agg": case "agg":
this.i++; this.i++;
return { k: "agg", name: tk.name }; return { k: "agg", name: tk.name };

View file

@ -20,6 +20,7 @@ export type Token =
| { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; start: number; end: number } | { t: "date"; word: DateWord; start: number; end: number }
| { t: "datelit"; ms: number; 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: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number } | { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number } | { t: "rparen"; start: number; end: number }
@ -53,6 +54,12 @@ export function tokenize(line: string, reg: Registry): Token[] {
continue; 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); const m = reg.match(lexes, lowers, i);
if (m) { if (m) {
const end = lexes[i + m.length - 1].end; const end = lexes[i + m.length - 1].end;

View file

@ -79,6 +79,7 @@ export function pct(value: Decimal.Value): PercentVal {
} }
export class EvalError extends Error {} export class EvalError extends Error {}
export class XRefError extends EvalError {}
export interface EngineSettings { export interface EngineSettings {
/** max decimal places shown */ /** max decimal places shown */