diff --git a/apps/server/src/core/base/formula/__tests__/eval.spec.ts b/apps/server/src/core/base/formula/__tests__/eval.spec.ts new file mode 100644 index 000000000..0dbdb94f7 --- /dev/null +++ b/apps/server/src/core/base/formula/__tests__/eval.spec.ts @@ -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): EvalContext => ({ + registry, + properties: new Map( + 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): 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"); + }); +}); diff --git a/packages/base-formula/src/eval.ts b/packages/base-formula/src/eval.ts new file mode 100644 index 000000000..58a2c030f --- /dev/null +++ b/packages/base-formula/src/eval.ts @@ -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, + 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, 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, + 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); + } +} diff --git a/packages/base-formula/src/index.server.ts b/packages/base-formula/src/index.server.ts index 4dcc81e51..10c4b0284 100644 --- a/packages/base-formula/src/index.server.ts +++ b/packages/base-formula/src/index.server.ts @@ -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";