1
12
13import type { AstNode, FormulaContext, FormulaType } from "./types.js";
14import { FormulaError } from "./types.js";
15import { FUNCTIONS } from "./functions.js";
16
17function typeOfProperty(name: string, ctx: FormulaContext): FormulaType {
18 const pt = ctx.propertyTypes?.[name];
19 const v = ctx.properties[name];
20 if (v === undefined)
21 throw new FormulaError("TYPE", `unknown property '${name}'`, undefined, name);
22 if (pt === "number" || pt === "created_time" || pt === "last_edited_time" || pt === "unique_id")
23 return "number";
24 if (pt === "date") return "date";
25 if (pt === "checkbox") return "bool";
26 if (
27 pt === "select" ||
28 pt === "status" ||
29 pt === "text" ||
30 pt === "rich_text" ||
31 pt === "title" ||
32 pt === "email" ||
33 pt === "url" ||
34 pt === "created_by" ||
35 pt === "last_edited_by"
36 )
37 return "string";
38 if (pt === "date_range") return "date";
39 if (pt === "multi_select" || pt === "person" || pt === "relation" || pt === "files")
40 return "array";
41 if (typeof v === "number") return "number";
42 if (typeof v === "boolean") return "bool";
43 if (typeof v === "string") return "string";
44 if (Array.isArray(v)) return "array";
45 return "unknown";
46}
47
48
49export function inferType(ast: AstNode, ctx: FormulaContext): FormulaType {
50 switch (ast.type) {
51 case "Literal":
52 return ast.literalType === "number"
53 ? "number"
54 : ast.literalType === "string"
55 ? "string"
56 : ast.literalType === "date"
57 ? "date"
58 : "bool";
59 case "NullLiteral":
60 return "null";
61 case "ArrayLiteral":
62 return "array";
63 case "Prop":
64 return typeOfProperty(ast.name, ctx);
65 case "Call": {
66 if (ast.name === "prop") {
67 const a = ast.args[0];
68 if (!a || a.type !== "Literal" || a.literalType !== "string") {
69 throw new FormulaError("TYPE", "prop() requires a string literal");
70 }
71 return typeOfProperty(a.value as string, ctx);
72 }
73 const fn = FUNCTIONS[ast.name];
74 if (!fn) throw new FormulaError("UNKNOWN_FUNCTION", `unknown function "${ast.name}"`);
75 const [min, max] = fn.arity;
76 if (ast.args.length < min || (Number.isFinite(max) && ast.args.length > max)) {
77 throw new FormulaError("TYPE", `${ast.name}() bad arity`);
78 }
79 const argTypes = ast.args.map((a) => inferType(a, ctx));
80 return fn.returns(argTypes);
81 }
82 case "UnaryOp": {
83 const t = inferType(ast.operand, ctx);
84 if (ast.op === "-") {
85 if (t !== "number" && t !== "null") throw new FormulaError("TYPE", `-x requires a number`);
86 return "number";
87 }
88 if (t !== "bool" && t !== "null") throw new FormulaError("TYPE", `not requires a bool`);
89 return "bool";
90 }
91 case "BinaryOp": {
92 const l = inferType(ast.left, ctx);
93 const r = inferType(ast.right, ctx);
94 const op = ast.op;
95 if (op === "and" || op === "or") {
96 if (l !== "bool" && l !== "null")
97 throw new FormulaError("TYPE", `${op} requires bool operands`);
98 if (r !== "bool" && r !== "null")
99 throw new FormulaError("TYPE", `${op} requires bool operands`);
100 return "bool";
101 }
102 if (op === "==" || op === "!=") {
103 if (l !== "null" && r !== "null" && l !== r)
104 throw new FormulaError("TYPE", `cannot compare ${l} with ${r}`);
105 return "bool";
106 }
107 if (op === "<" || op === "<=" || op === ">" || op === ">=") {
108 if (l !== "null" && r !== "null" && l !== r)
109 throw new FormulaError("TYPE", `cannot compare ${l} with ${r}`);
110 if (l !== "number" && l !== "string" && l !== "date")
111 throw new FormulaError("TYPE", `ordering requires number/string/date`);
112 return "bool";
113 }
114
115 if (op === "+" && (l === "date" || r === "date"))
116 throw new FormulaError("TYPE", "date arithmetic requires dateAdd");
117 if (l === "null" || r === "null") return "null";
118 if (l !== "number" || r !== "number")
119 throw new FormulaError("TYPE", `arithmetic requires number operands, got ${l} and ${r}`);
120 return "number";
121 }
122 case "If": {
123 const c = inferType(ast.cond, ctx);
124 if (c !== "bool" && c !== "null") throw new FormulaError("TYPE", `if(cond) requires a bool`);
125 const t = inferType(ast.then, ctx);
126 const e = inferType(ast.else, ctx);
127 if (t !== "null" && e !== "null" && t !== e) {
128 throw new FormulaError("TYPE", `if branches diverge: ${t} vs ${e}`);
129 }
130 return t === "null" ? e : e === "null" ? t : t;
131 }
132 }
133}
134
135
136export function extractDependencies(ast: AstNode): string[] {
137 const set = new Set<string>();
138 visit(ast, (n) => {
139 if (n.type === "Prop") set.add(n.name);
140 });
141 return [...set].sort();
142}
143
144
145export function usesNow(ast: AstNode): boolean {
146 let found = false;
147 visit(ast, (n) => {
148 if (n.type === "Call" && n.name === "now") found = true;
149 });
150 return found;
151}
152
153function propDependency(node: Extract<AstNode, { type: "Call" }>): string | null {
154 if (node.name !== "prop") return null;
155 const a = node.args[0];
156 return a && a.type === "Literal" && a.literalType === "string" ? (a.value as string) : null;
157}
158
159function visit(node: AstNode, fn: (n: AstNode) => void): void {
160 fn(node);
161 const dep = node.type === "Call" ? propDependency(node) : null;
162 if (dep !== null) fn({ type: "Prop", name: dep });
163 switch (node.type) {
164 case "Call":
165 for (const a of node.args) visit(a, fn);
166 break;
167 case "BinaryOp":
168 visit(node.left, fn);
169 visit(node.right, fn);
170 break;
171 case "UnaryOp":
172 visit(node.operand, fn);
173 break;
174 case "If":
175 visit(node.cond, fn);
176 visit(node.then, fn);
177 visit(node.else, fn);
178 break;
179 default:
180 break;
181 }
182}
183