From 77897733de48287591d4e8003219357916d2d048 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:52:11 +0100 Subject: [PATCH] feat(base-formula): add type checker --- .../base/formula/__tests__/typecheck.spec.ts | 33 +++++++ .../base-formula/src/functions/registry.ts | 16 +++- packages/base-formula/src/index.client.ts | 2 + packages/base-formula/src/index.server.ts | 3 + packages/base-formula/src/typecheck.ts | 88 +++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/core/base/formula/__tests__/typecheck.spec.ts create mode 100644 packages/base-formula/src/typecheck.ts diff --git a/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts b/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts new file mode 100644 index 000000000..86a1617ba --- /dev/null +++ b/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts @@ -0,0 +1,33 @@ +// TODO: unskip after Task 15 lands date functions and populates the registry. +import { parseRaw, resolve, typecheck, registry } from "@docmost/base-formula/server"; +import type { FormulaAST } from "@docmost/base-formula/server"; + +const mk = (src: string, propTypes: Record) => { + const names = new Map(Object.keys(propTypes).map((n) => [n, `prop_${n.toLowerCase()}`])); + const resolved = resolve(parseRaw(src), names); + const typeMap = new Map(Object.entries(propTypes).map(([n, t]) => [`prop_${n.toLowerCase()}`, t])); + return { ast: resolved.ast, typeMap }; +}; + +describe.skip("typecheck", () => { + it("infers number for arithmetic", () => { + const { ast, typeMap } = mk('prop("Price") * 2', { Price: "number" }); + expect(typecheck(ast, typeMap, registry).resultType).toBe("number"); + }); + it("rejects string * number", () => { + const { ast, typeMap } = mk('prop("Name") * 2', { Name: "string" }); + expect(() => typecheck(ast, typeMap, registry)).toThrow(/TYPE_MISMATCH/); + }); + it("infers boolean for comparison", () => { + const { ast, typeMap } = mk('prop("Price") > 0', { Price: "number" }); + expect(typecheck(ast, typeMap, registry).resultType).toBe("boolean"); + }); + it("infers string for concat()", () => { + const { ast, typeMap } = mk('concat(prop("Name"), "!")', { Name: "string" }); + expect(typecheck(ast, typeMap, registry).resultType).toBe("string"); + }); + it("infers branch-join type for if()", () => { + const { ast, typeMap } = mk('if(true, prop("Price"), 0)', { Price: "number" }); + expect(typecheck(ast, typeMap, registry).resultType).toBe("number"); + }); +}); diff --git a/packages/base-formula/src/functions/registry.ts b/packages/base-formula/src/functions/registry.ts index 6ed1e27d3..c4e18248d 100644 --- a/packages/base-formula/src/functions/registry.ts +++ b/packages/base-formula/src/functions/registry.ts @@ -1,2 +1,14 @@ -// Minimal stub: FormulaFn type will be expanded in Task 10. -export type FormulaFn = unknown; +// packages/base-formula/src/functions/registry.ts +import type { FormulaResultType, Value, EvalContext } from "../types"; + +export type FormulaFn = { + name: string; + arity: { min: number; max: number | null }; + paramTypes: FormulaResultType[] | "any" | "variadic-any"; + returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType); + eval: (args: Value[], ctx: EvalContext) => Value; + doc: string; + category: "logic" | "math" | "string" | "date" | "coercion"; +}; + +export const registry: Map = new Map(); diff --git a/packages/base-formula/src/index.client.ts b/packages/base-formula/src/index.client.ts index 0b6f64117..5b2d44562 100644 --- a/packages/base-formula/src/index.client.ts +++ b/packages/base-formula/src/index.client.ts @@ -6,3 +6,5 @@ export * from "./error"; export * from "./tokenizer"; export * from "./parser"; export * from "./resolver"; +export * from "./typecheck"; +export type { FormulaFn } from "./functions/registry"; diff --git a/packages/base-formula/src/index.server.ts b/packages/base-formula/src/index.server.ts index 96578f560..4684e0ebe 100644 --- a/packages/base-formula/src/index.server.ts +++ b/packages/base-formula/src/index.server.ts @@ -5,3 +5,6 @@ export * from "./error"; export * from "./tokenizer"; export * from "./parser"; export * from "./resolver"; +export * from "./typecheck"; +export { registry } from "./functions/registry"; +export type { FormulaFn } from "./functions/registry"; diff --git a/packages/base-formula/src/typecheck.ts b/packages/base-formula/src/typecheck.ts new file mode 100644 index 000000000..6fd826adf --- /dev/null +++ b/packages/base-formula/src/typecheck.ts @@ -0,0 +1,88 @@ +// packages/base-formula/src/typecheck.ts +import { FormulaParseError } from "./error"; +import type { FormulaAST, OpCode } from "./ast"; +import type { FormulaResultType } from "./types"; +import type { FormulaFn } from "./functions/registry"; + +export type PropertyTypeMap = ReadonlyMap; + +export type TypecheckResult = { resultType: FormulaResultType }; + +const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"]; +const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="]; + +export function typecheck( + ast: FormulaAST, + propertyTypes: PropertyTypeMap, + registry: ReadonlyMap, +): TypecheckResult { + return { resultType: infer(ast, propertyTypes, registry) }; +} + +function infer( + ast: FormulaAST, + propertyTypes: PropertyTypeMap, + registry: ReadonlyMap, +): FormulaResultType { + switch (ast.t) { + case "num": return "number"; + case "str": return "string"; + case "bool": return "boolean"; + case "null": return "null"; + case "prop": return propertyTypes.get(ast.id) ?? "null"; + case "op": { + const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry)); + if (ARITH_OPS.includes(ast.op)) { + if (ast.op === "+" && argTypes.every((t) => t === "string" || t === "null")) return "string"; + const allow = argTypes.every((t) => t === "number" || t === "null"); + if (!allow) throw typeErr(`Operator '${ast.op}' needs numbers`); + return "number"; + } + if (CMP_OPS.includes(ast.op)) return "boolean"; + if (ast.op === "neg") { + if (argTypes[0] !== "number" && argTypes[0] !== "null") throw typeErr("Unary '-' needs number"); + return "number"; + } + if (ast.op === "not") { + if (argTypes[0] !== "boolean" && argTypes[0] !== "null") throw typeErr("'not' needs boolean"); + return "boolean"; + } + return "null"; + } + case "if": { + const thenT = infer(ast.then, propertyTypes, registry); + const elseT = infer(ast.else, propertyTypes, registry); + if (thenT === elseT) return thenT; + if (thenT === "null") return elseT; + if (elseT === "null") return thenT; + throw typeErr(`if() branches have different types: ${thenT} vs ${elseT}`); + } + case "and": case "or": + ast.args.forEach((a) => { + const t = infer(a, propertyTypes, registry); + if (t !== "boolean" && t !== "null") throw typeErr(`'${ast.t}' needs boolean args`); + }); + return "boolean"; + case "call": { + const fn = registry.get(ast.fn); + if (!fn) throw new FormulaParseError([{ + code: "UNKNOWN_FUNCTION", + message: `Unknown function '${ast.fn}'`, + span: { start: 0, end: 0 }, + }]); + const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry)); + if (argTypes.length < fn.arity.min || (fn.arity.max != null && argTypes.length > fn.arity.max)) { + throw new FormulaParseError([{ + code: "ARITY_MISMATCH", + message: `${fn.name}() expects ${fn.arity.min}-${fn.arity.max ?? "∞"} args, got ${argTypes.length}`, + span: { start: 0, end: 0 }, + }]); + } + return typeof fn.returnType === "function" ? fn.returnType(argTypes) : fn.returnType; + } + } +} + +function typeErr(message: string): FormulaParseError { + return new FormulaParseError([{ code: "TYPE_MISMATCH", message, span: { start: 0, end: 0 } }]); +}