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
@@ -0,0 +1,54 @@
import {
parseRaw, resolve, evaluate, registry, isErrorCell, DEFAULT_MAX_DEPTH,
} from "@docmost/base-formula/server";
import "@docmost/base-formula/server"; // ensure functions/index.ts loaded (side-effect)
import type { EvalContext, PropertyLookup, Value } from "@docmost/base-formula/server";
const mkCtx = (props: Record<string, any>): EvalContext => ({
registry,
properties: new Map<string, PropertyLookup>(
Object.keys(props).map((k) => [k, { id: k, type: "number", typeOptions: {} }]),
),
depth: 0,
maxDepth: DEFAULT_MAX_DEPTH,
memo: new Map(),
});
const run = (src: string, cells: Record<string, Value>): Value => {
const names = new Map(Object.keys(cells).map((k) => [k.replace(/^prop_/, ""), k]));
const { ast } = resolve(parseRaw(src), names);
return evaluate(ast, cells, mkCtx(cells));
};
describe("evaluate", () => {
it("evaluates arithmetic", () => {
expect(run('prop("a") + prop("b")', { prop_a: 2, prop_b: 3 })).toBe(5);
});
it("evaluates comparisons", () => {
expect(run('prop("a") > 0', { prop_a: 1 })).toBe(true);
});
it("short-circuits if/then", () => {
expect(run('if(prop("a") > 0, "pos", "neg")', { prop_a: 5 })).toBe("pos");
expect(run('if(prop("a") > 0, "pos", "neg")', { prop_a: -1 })).toBe("neg");
});
it("short-circuits and/or", () => {
expect(run('and(true, false)', {})).toBe(false);
expect(run('or(false, true)', {})).toBe(true);
});
it("returns null for null arithmetic", () => {
expect(run('prop("a") + 1', { prop_a: null })).toBe(null);
});
it("returns DIV_BY_ZERO for 1/0", () => {
const v = run('1 / 0', {});
expect(isErrorCell(v)).toBe(true);
if (isErrorCell(v)) expect(v.__err).toBe("DIV_BY_ZERO");
});
});
// TODO: unskip after Task 15 wires the function registry with round/concat.
describe.skip("evaluate with registered functions", () => {
it("invokes registered functions", () => {
expect(run('round(1.6)', {})).toBe(2);
expect(run('concat("a", "b", "c")', {})).toBe("abc");
});
});
+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";