refactor: extract evaluateLine from evaluateDocument

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-19 11:27:16 +00:00
parent 868a34497b
commit 4c3ee47025

View file

@ -1,7 +1,18 @@
import { logger } from "../logger"; import { logger } from "../logger";
// 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 { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import {
DateOrder,
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, ParsedLine } from "./parser"; import { parseLine, ParsedLine } from "./parser";
@ -85,7 +96,8 @@ export class SumEngine {
updateSettings(patch: Partial<EngineSettings>): void { updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch }; this.settings = { ...this.settings, ...patch };
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat); if (patch.dateFormat)
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
} }
completions(): Completion[] { completions(): Completion[] {
@ -107,25 +119,82 @@ export class SumEngine {
if (trimmed.length === 0) { if (trimmed.length === 0) {
const kind = commentStart >= 0 ? "comment" : "empty"; const kind = commentStart >= 0 ? "comment" : "empty";
results.push({ text: null, value: null, kind, tokens: [], commentStart: commentStart >= 0 ? commentStart : null }); results.push({
text: null,
value: null,
kind,
tokens: [],
commentStart: commentStart >= 0 ? commentStart : null,
});
lineValues.push(null); lineValues.push(null);
lineKinds.push("empty"); lineKinds.push("empty");
continue; continue;
} }
if (trimmed.startsWith("#")) { if (trimmed.startsWith("#")) {
results.push({ text: null, value: null, kind: "header", tokens: [], commentStart: commentStart >= 0 ? commentStart : null }); results.push({
text: null,
value: null,
kind: "header",
tokens: [],
commentStart: commentStart >= 0 ? commentStart : null,
});
lineValues.push(null); lineValues.push(null);
lineKinds.push("header"); lineKinds.push("header");
continue; continue;
} }
// tokenize/parseLine/evaluate all run under one try/catch: pathological let { tokens, parsed, value, lineError } = this.evaluateLine(
// input (e.g. stack-overflowing nested parens in the parser) must null line,
// out this one line, not throw past evaluateDocument and kill the rest. vars,
lineValues,
lineKinds,
i,
rawLine,
resolveXRef,
);
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
lineValues.push(value);
lineKinds.push("normal");
results.push({
text:
value && value.kind !== "chart"
? formatValue(value, this.settings)
: null,
value,
kind: "normal",
tokens,
commentStart: commentStart >= 0 ? commentStart : null,
assign: parsed.assign,
error: lineError,
});
}
return results;
}
private evaluateLine(
line: string,
vars: Map<string, Value>,
lineValues: (Value | null)[],
lineKinds: ("empty" | "header" | "normal")[],
index: number,
rawLine: string,
resolveXRef?: XRefResolver,
): {
tokens: Token[];
parsed: ParsedLine;
value: Value | null;
lineError: string | undefined;
} {
let tokens: Token[] = []; let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null }; let parsed: ParsedLine = { expr: null };
let value: Value | null = null; let value: Value | null = null;
let lineError: string | undefined; let lineError: string | undefined;
// tokenize/parseLine/evaluate all run under one try/catch: pathological
// input (e.g. stack-overflowing nested parens in the parser) must null
// out this one line, not throw past evaluateDocument and kill the rest.
try { try {
tokens = tokenize(line, this.reg, this.dateOrder); tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys()); const knownVars = new Set(vars.keys());
@ -134,7 +203,7 @@ export class SumEngine {
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, lineText: rawLine },
historicalRates: this.historicalRates, historicalRates: this.historicalRates,
resolveXRef, resolveXRef,
}; };
@ -153,22 +222,7 @@ export class SumEngine {
// a line without a result beats a dead sheet. // a line without a result beats a dead sheet.
value = null; value = null;
} }
if (value?.kind === "quantity" && !value.value.isFinite()) value = null; return { tokens, parsed, value, lineError };
if (value && parsed.assign) vars.set(parsed.assign, value);
lineValues.push(value);
lineKinds.push("normal");
results.push({
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null,
value,
kind: "normal",
tokens,
commentStart: commentStart >= 0 ? commentStart : null,
assign: parsed.assign,
error: lineError,
});
}
return results;
} }
/** Evaluate a single expression (used by the extension runtime and tests). */ /** Evaluate a single expression (used by the extension runtime and tests). */
@ -217,9 +271,13 @@ export class SumEngine {
} }
addUnit(spec: ExtensionUnitSpec): void { addUnit(spec: ExtensionUnitSpec): void {
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId); const base =
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`); this.reg.unitsById.get(spec.baseUnitId) ??
if (this.reg.unitsById.has(spec.id)) throw new Error(`numi.addUnit: id "${spec.id}" is already registered`); this.currencyBase(spec.baseUnitId);
if (!base)
throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
if (this.reg.unitsById.has(spec.id))
throw new Error(`numi.addUnit: id "${spec.id}" is already registered`);
const unit: Unit = { const unit: Unit = {
id: spec.id, id: spec.id,
dimension: base.dimension, dimension: base.dimension,
@ -230,12 +288,24 @@ export class SumEngine {
this.reg.unitsById.set(unit.id, unit); this.reg.unitsById.set(unit.id, unit);
for (const p of spec.phrases.split(",")) { for (const p of spec.phrases.split(",")) {
const phrase = p.trim(); const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false }); if (phrase)
this.reg.addPhrase(
phrase,
{ t: "unit", unit },
{ caseSensitive: false },
);
} }
this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" }); this.reg.completions.push({
label: spec.id,
type: "unit",
detail: "extension",
});
} }
addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void { addFunction(
spec: { id: string; phrases: string },
fn: (values: ExtensionValue[]) => ExtensionValue | number,
): void {
const name = spec.id; const name = spec.id;
this.reg.customFuncs.set(name, (args: Value[]) => { this.reg.customFuncs.set(name, (args: Value[]) => {
const mapped = args.map((a) => this.fromValue(a)); const mapped = args.map((a) => this.fromValue(a));
@ -243,26 +313,42 @@ export class SumEngine {
}); });
for (const p of (spec.phrases || spec.id).split(",")) { for (const p of (spec.phrases || spec.id).split(",")) {
const phrase = p.trim(); const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false }); if (phrase)
this.reg.addPhrase(
phrase,
{ t: "func", name },
{ caseSensitive: false },
);
} }
this.reg.completions.push({ label: name, type: "function", detail: "extension" }); this.reg.completions.push({
label: name,
type: "function",
detail: "extension",
});
} }
private currencyBase(code: string): Unit | null { private currencyBase(code: string): Unit | null {
return this.reg.currencyCodes.has(code) ? this.reg.makeCurrencyUnit(code) : null; return this.reg.currencyCodes.has(code)
? this.reg.makeCurrencyUnit(code)
: null;
} }
private toValue(v: number | ExtensionValue): Value { private toValue(v: number | ExtensionValue): Value {
if (typeof v === "number") return qty(v); if (typeof v === "number") return qty(v);
const unit = v.unitId const unit = v.unitId
? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null ? (this.reg.unitsById.get(v.unitId) ??
this.currencyBase(v.unitId) ??
null)
: null; : null;
return qty(new Decimal(v.double), unit); return qty(new Decimal(v.double), unit);
} }
private fromValue(v: Value): ExtensionValue { private fromValue(v: Value): ExtensionValue {
if (v.kind === "quantity") { if (v.kind === "quantity") {
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; return {
double: v.value.toNumber(),
...(v.unit ? { unitId: v.unit.id } : {}),
};
} }
if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
if (v.kind === "date") return { double: v.ms }; if (v.kind === "date") return { double: v.ms };