mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 10:13:01 +08:00
feat(base-formula): add type checker
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 } }]);
|
||||
}
|
||||
Reference in New Issue
Block a user