diff --git a/apps/server/src/core/base/formula/__tests__/parser.spec.ts b/apps/server/src/core/base/formula/__tests__/parser.spec.ts index a62195371..22ee3ec5e 100644 --- a/apps/server/src/core/base/formula/__tests__/parser.spec.ts +++ b/apps/server/src/core/base/formula/__tests__/parser.spec.ts @@ -1,4 +1,4 @@ -import { parseRaw } from "@docmost/base-formula/server"; +import { parseRaw, resolve } from "@docmost/base-formula/server"; import type { RawFormulaAST } from "@docmost/base-formula/server"; describe("parseRaw", () => { @@ -112,3 +112,52 @@ describe("parseRaw", () => { expect(() => parseRaw("prop()")).toThrow(); }); }); + +describe("resolve", () => { + const names = new Map([ + ["Price", "prop_price"], + ["Qty", "prop_qty"], + ]); + + it("replaces propName with prop (id)", () => { + const raw = parseRaw('prop("Price")'); + const out = resolve(raw, names); + expect(out.ast).toEqual({ t: "prop", id: "prop_price" }); + expect(out.dependencies).toEqual(["prop_price"]); + }); + + it("walks into nested structures", () => { + const raw = parseRaw('prop("Price") * prop("Qty")'); + const out = resolve(raw, names); + expect(out.ast).toEqual({ + t: "op", op: "*", args: [ + { t: "prop", id: "prop_price" }, + { t: "prop", id: "prop_qty" }, + ], + }); + expect([...out.dependencies].sort()).toEqual(["prop_price", "prop_qty"]); + }); + + it("dedupes dependencies", () => { + const raw = parseRaw('prop("Price") + prop("Price")'); + const out = resolve(raw, names); + expect(out.dependencies).toEqual(["prop_price"]); + }); + + it("throws UNKNOWN_PROPERTY with a span", () => { + const raw = parseRaw('prop("Nope")'); + try { + resolve(raw, names); + fail("expected throw"); + } catch (e: any) { + expect(e.errors[0].code).toBe("UNKNOWN_PROPERTY"); + expect(e.errors[0].message).toContain("Nope"); + } + }); + + it("handles if/and/or/call", () => { + const raw = parseRaw('if(prop("Price") > 0, prop("Qty"), 0)'); + const out = resolve(raw, names); + expect(out.ast.t).toBe("if"); + }); +}); diff --git a/packages/base-formula/src/index.client.ts b/packages/base-formula/src/index.client.ts index 595920cf4..0b6f64117 100644 --- a/packages/base-formula/src/index.client.ts +++ b/packages/base-formula/src/index.client.ts @@ -5,3 +5,4 @@ export * from "./types"; export * from "./error"; export * from "./tokenizer"; export * from "./parser"; +export * from "./resolver"; diff --git a/packages/base-formula/src/index.server.ts b/packages/base-formula/src/index.server.ts index 4467c51ea..96578f560 100644 --- a/packages/base-formula/src/index.server.ts +++ b/packages/base-formula/src/index.server.ts @@ -4,3 +4,4 @@ export * from "./types"; export * from "./error"; export * from "./tokenizer"; export * from "./parser"; +export * from "./resolver"; diff --git a/packages/base-formula/src/resolver.ts b/packages/base-formula/src/resolver.ts new file mode 100644 index 000000000..69fce2c18 --- /dev/null +++ b/packages/base-formula/src/resolver.ts @@ -0,0 +1,69 @@ +// packages/base-formula/src/resolver.ts +import { FormulaParseError } from "./error"; +import type { FormulaAST, RawFormulaAST } from "./ast"; + +export type ResolveResult = { + ast: FormulaAST; + dependencies: string[]; +}; + +export function resolve( + raw: RawFormulaAST, + nameToId: ReadonlyMap, +): ResolveResult { + const deps = new Set(); + const ast = walk(raw, nameToId, deps); + return { ast, dependencies: Array.from(deps).sort() }; +} + +function walk( + node: RawFormulaAST, + nameToId: ReadonlyMap, + deps: Set, +): FormulaAST { + switch (node.t) { + case "num": case "str": case "bool": case "null": + return node as FormulaAST; + case "propName": { + const id = nameToId.get(node.name); + if (!id) { + throw new FormulaParseError([{ + code: "UNKNOWN_PROPERTY", + message: `Unknown property '${node.name}'`, + span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse + }]); + } + deps.add(id); + return { t: "prop", id }; + } + case "op": + return { + t: "op", + op: (node as any).op, + args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)), + }; + case "if": + return { + t: "if", + cond: walk((node as any).cond, nameToId, deps), + then: walk((node as any).then, nameToId, deps), + else: walk((node as any).else, nameToId, deps), + }; + case "and": + return { + t: "and", + args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)), + }; + case "or": + return { + t: "or", + args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)), + }; + case "call": + return { + t: "call", + fn: (node as any).fn, + args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)), + }; + } +}