feat(base-formula): add type checker

This commit is contained in:
Philipinho
2026-04-23 23:52:11 +01:00
parent 216a4a99e1
commit 77897733de
5 changed files with 140 additions and 2 deletions
@@ -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<string, "number" | "string" | "boolean" | "date">) => {
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");
});
});
@@ -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<string, FormulaFn> = new Map();
@@ -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";
@@ -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";
+88
View File
@@ -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<string, FormulaResultType>;
export type TypecheckResult = { resultType: FormulaResultType };
const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"];
const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="];
export function typecheck(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): TypecheckResult {
return { resultType: infer(ast, propertyTypes, registry) };
}
function infer(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): 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 } }]);
}