mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +03:00
engine: @Sheet.key cross-sheet reference syntax and resolver hook
This commit is contained in:
parent
b509a65fc5
commit
677f940043
7 changed files with 147 additions and 11 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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 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");
|
||||
|
|
|
|||
|
|
@ -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<string, Value>(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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue