keep evaluateDocument's error boundary around tokenize/parse, not just evaluate

tokenize() and parseLine() ran outside the try/catch, and the parser
has no recursion-depth guard for nested parens. A pathologically
nested line overflowed the stack with an uncaught RangeError, killing
the whole document instead of nulling just that line — the exact
thing evaluateDocument is supposed to prevent.
This commit is contained in:
Horizontal42 2026-08-12 20:02:09 +03:00
parent aa29c12b37
commit 741ea16e40
2 changed files with 42 additions and 29 deletions

View file

@ -282,3 +282,13 @@ describe("review fixes", () => {
});
});
});
describe("regression 2026-08-12", () => {
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
const r = eng.evaluateDocument(`1+1\n${deep}\n2+2`).map((x) => x.text);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
});
});

View file

@ -3,7 +3,7 @@
import { DateOrder, 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 { parseLine, ParsedLine } from "./parser";
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime";
@ -118,39 +118,42 @@ export class SumEngine {
continue;
}
const tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys());
const parsed = parseLine(tokens, knownVars, line);
// 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.
let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null };
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);
try {
tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys());
parsed = parseLine(tokens, knownVars, line);
if (parsed.expr) {
const ctx: EvalCtx = {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates,
resolveXRef,
};
// a line without a result beats a dead sheet.
// surface unexpected bugs so the user knows why it failed
} catch (e) {
if (e instanceof XRefError) {
lineError = e.message;
} else if (e instanceof EvalError) {
// Expected evaluation failure; remain silent
} else {
// Surface unexpected bugs/BigInt errors and log them
lineError = e instanceof Error ? e.message : String(e);
console.warn("evaluate failed:", e);
}
value = null;
value = evaluate(parsed.expr, ctx);
}
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
} catch (e) {
if (e instanceof XRefError) {
lineError = e.message;
} else if (e instanceof EvalError) {
// Expected evaluation failure; remain silent
} else {
// Surface unexpected bugs/BigInt errors/RangeErrors and log them
lineError = e instanceof Error ? e.message : String(e);
console.warn("evaluate failed:", e);
}
value = null;
}
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
lineValues.push(value);
lineKinds.push("normal");