feat(base-formula): add tree-walking evaluator

This commit is contained in:
Philipinho
2026-04-24 00:00:22 +01:00
parent 1b30de32b5
commit e9e903abe9
3 changed files with 161 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
// packages/base-formula/src/eval.ts
import { makeErrorCell, isErrorCell } from "./error";
import type { FormulaAST, OpCode } from "./ast";
import type { Value, EvalContext } from "./types";
export function evaluate(
ast: FormulaAST,
row: Record<string, unknown>,
ctx: EvalContext,
): Value {
switch (ast.t) {
case "num": return ast.v;
case "str": return ast.v;
case "bool": return ast.v;
case "null": return null;
case "prop": return evalProp(ast.id, row, ctx);
case "op": return evalOp(ast.op, ast.args, row, ctx);
case "if": {
const c = evaluate(ast.cond, row, ctx);
if (isErrorCell(c)) return c;
return evaluate(c === true ? ast.then : ast.else, row, ctx);
}
case "and": {
for (const a of ast.args) {
const v = evaluate(a, row, ctx);
if (isErrorCell(v)) return v;
if (v === false) return false;
if (v == null) return null;
}
return true;
}
case "or": {
for (const a of ast.args) {
const v = evaluate(a, row, ctx);
if (isErrorCell(v)) return v;
if (v === true) return true;
}
return false;
}
case "call": {
const fn = ctx.registry.get(ast.fn);
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
const args = ast.args.map((a) => evaluate(a, row, ctx));
for (const v of args) if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
try { return fn.eval(args, ctx); }
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
}
}
}
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext): Value {
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
const prop = ctx.properties.get(id);
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
if (prop.type !== "formula") return normalize(row[id] ?? null);
// Nested formula: recurse with depth tracking.
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
const opts: any = prop.typeOptions;
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
const v = evaluate(opts.ast, row, nested);
ctx.memo.set(id, v);
return v;
}
function normalize(v: unknown): Value {
if (v === undefined) return null;
if (v === null) return null;
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
if (isErrorCell(v)) return v;
return null;
}
function evalOp(
op: OpCode,
args: FormulaAST[],
row: Record<string, unknown>,
ctx: EvalContext,
): Value {
const vs = args.map((a) => evaluate(a, row, ctx));
for (const v of vs) if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
const [a, b] = vs;
switch (op) {
case "+":
if (typeof a === "string" || typeof b === "string") return (a == null ? "" : String(a)) + (b == null ? "" : String(b));
if (a == null || b == null) return null;
return Number(a) + Number(b);
case "-": return a == null || b == null ? null : Number(a) - Number(b);
case "*": return a == null || b == null ? null : Number(a) * Number(b);
case "/":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return Number(a) / Number(b);
case "%":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return Number(a) % Number(b);
case "==": return a === b;
case "!=": return a !== b;
case ">": return a != null && b != null && (a as any) > (b as any);
case "<": return a != null && b != null && (a as any) < (b as any);
case ">=": return a != null && b != null && (a as any) >= (b as any);
case "<=": return a != null && b != null && (a as any) <= (b as any);
case "neg": return a == null ? null : -Number(a);
case "not": return a == null ? null : !Boolean(a);
}
}
@@ -10,3 +10,4 @@ export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";
export * from "./eval";