1
16
17import type { EValue, FormulaType, FormulaContext } from "./types.js";
18import { FormulaError } from "./types.js";
19import { addCalendarUnit, daysBetween, DAY_MS } from "../values/dates.js";
20
21
22export interface Budget {
23 steps: number;
24 charge(n: number): void;
25}
26
27export interface FunctionDef {
28 arity: [min: number, max: number];
29
30 returns: (argTypes: FormulaType[]) => FormulaType;
31 impl: (args: EValue[], ctx: FormulaContext, budget: Budget) => EValue;
32}
33
34const err = (code: "TYPE" | "RUNTIME", message: string): never => {
35 throw new FormulaError(code, message);
36};
37
38function asBool(v: EValue, what: string): boolean {
39 if (v.t !== "bool") err("TYPE", `${what} must be a bool, got ${v.t}`);
40 return v.v as boolean;
41}
42function asNumber(v: EValue, what: string): number {
43 if (v.t !== "number") err("TYPE", `${what} must be a number, got ${v.t}`);
44 return v.v as number;
45}
46function asDate(v: EValue, what: string): string {
47 if (v.t !== "date") err("TYPE", `${what} must be a date, got ${v.t}`);
48 return v.v as string;
49}
50function asString(v: EValue, what: string): string {
51 if (v.t !== "string") err("TYPE", `${what} must be a string, got ${v.t}`);
52 return v.v as string;
53}
54function asArray(v: EValue, what: string): EValue[] {
55 if (v.t !== "array") err("TYPE", `${what} must be an array, got ${v.t}`);
56 return v.v as EValue[];
57}
58function numDateKey(v: EValue): number {
59 if (v.t === "number") return v.v as number;
60 const d = v.v as string;
61 return new Date(d.length === 10 ? d + "T00:00:00Z" : d).getTime();
62}
63
64
65export function stringify(v: EValue): string {
66 switch (v.t) {
67 case "null":
68 return "";
69 case "array":
70 return (v.v as EValue[]).map(stringify).join(",");
71 default:
72 return String(v.v);
73 }
74}
75
76function numList(args: EValue[], name: string): number[] {
77 const arr = asArray(args[0]!, `${name}(list)`);
78 const out: number[] = [];
79 for (const x of arr) {
80 if (x.t === "null") continue;
81 out.push(asNumber(x, `${name}(…)`));
82 }
83 return out;
84}
85
86export const FUNCTIONS: Record<string, FunctionDef> = {
87 if: {
88 arity: [3, 3],
89 returns: (ts) => ts[1]!,
90 impl: (args, _ctx, budget) => {
91 const cond = asBool(args[0]!, "if(cond)");
92 budget.charge(1);
93 return cond ? args[1]! : args[2]!;
94 },
95 },
96 and: {
97 arity: [2, Infinity],
98 returns: () => "bool",
99 impl: (args) => {
100 for (const a of args) if (!asBool(a, "and(…)")) return { v: false, t: "bool" };
101 return { v: true, t: "bool" };
102 },
103 },
104 or: {
105 arity: [2, Infinity],
106 returns: () => "bool",
107 impl: (args) => {
108 for (const a of args) if (asBool(a, "or(…)")) return { v: true, t: "bool" };
109 return { v: false, t: "bool" };
110 },
111 },
112 not: {
113 arity: [1, 1],
114 returns: () => "bool",
115
116 impl: (args) => {
117 const a = args[0]!;
118 if (a.t === "null") return { v: null, t: "null" };
119 return { v: !asBool(a, "not(x)"), t: "bool" };
120 },
121 },
122 sum: {
123 arity: [1, 1],
124 returns: () => "number",
125 impl: (args, _ctx, budget) => {
126 const nums = numList(args, "sum");
127 budget.charge(nums.length);
128 return { v: nums.reduce((a, b) => a + b, 0), t: "number" };
129 },
130 },
131 average: {
132 arity: [1, 1],
133 returns: () => "number",
134 impl: (args, _ctx, budget) => {
135 const nums = numList(args, "average");
136 budget.charge(nums.length);
137 if (nums.length === 0) return { v: null, t: "null" };
138 return { v: nums.reduce((a, b) => a + b, 0) / nums.length, t: "number" };
139 },
140 },
141 min: {
142 arity: [1, 1],
143 returns: (ts) => (ts[0] === "date" ? "date" : "number"),
144 impl: (args) => minMax(args, false),
145 },
146 max: {
147 arity: [1, 1],
148 returns: (ts) => (ts[0] === "date" ? "date" : "number"),
149 impl: (args) => minMax(args, true),
150 },
151 dateAdd: {
152 arity: [3, 3],
153 returns: () => "date",
154 impl: (args) => {
155 const date = asDate(args[0]!, "dateAdd(date)");
156 const amount = asNumber(args[1]!, "dateAdd(amount)");
157 const unit = asString(args[2]!, "dateAdd(unit)");
158 if (!["days", "weeks", "months", "years"].includes(unit))
159 err("TYPE", `dateAdd unit must be days|weeks|months|years, got "${unit}"`);
160 return {
161 v: addCalendarUnit(date, amount, unit as "days" | "weeks" | "months" | "years"),
162 t: "date",
163 };
164 },
165 },
166 dateBetween: {
167 arity: [2, 2],
168 returns: () => "number",
169 impl: (args) => {
170 const a = asDate(args[0]!, "dateBetween(a)");
171 const b = asDate(args[1]!, "dateBetween(b)");
172 return { v: daysBetween(a, b), t: "number" };
173 },
174 },
175 now: {
176 arity: [0, 0],
177 returns: () => "date",
178 impl: (_args, ctx) => ({ v: new Date(ctx.now).toISOString().slice(0, 10), t: "date" }),
179 },
180 concat: {
181 arity: [2, Infinity],
182 returns: () => "string",
183 impl: (args, _ctx, budget) => {
184 budget.charge(args.length);
185 return { v: args.map(stringify).join(""), t: "string" };
186 },
187 },
188 replace: {
189 arity: [3, 3],
190 returns: () => "string",
191 impl: (args) => {
192 const s = asString(args[0]!, "replace(s)");
193 const from = asString(args[1]!, "replace(from)");
194 const to = asString(args[2]!, "replace(to)");
195
196 if (from === "") return { v: s, t: "string" };
197 return { v: s.split(from).join(to), t: "string" };
198 },
199 },
200 contains: {
201 arity: [2, 2],
202 returns: () => "bool",
203 impl: (args) => {
204 const hay = asString(args[0]!, "contains(hay)");
205 const needle = asString(args[1]!, "contains(needle)");
206 return { v: hay.includes(needle), t: "bool" };
207 },
208 },
209 length: {
210 arity: [1, 1],
211 returns: () => "number",
212 impl: (args): EValue => {
213 const v = args[0]!;
214 if (v.t === "null") return { v: 0, t: "number" };
215 if (v.t === "string") return { v: (v.v as string).length, t: "number" };
216 if (v.t === "array") return { v: (v.v as EValue[]).length, t: "number" };
217 return err("TYPE", `length() accepts a string or array, got ${v.t}`);
218 },
219 },
220
221 prop: {
222 arity: [1, 1],
223 returns: (ts) => ts[0]!,
224 impl: (args, ctx) => {
225 const name = asString(args[0]!, "prop(name)");
226 const v = ctx.properties[name];
227 if (v === undefined) {
228 throw new FormulaError("TYPE", unknownPropertyMessage(name, ctx), undefined, name);
229 }
230 return normalizeProperty(v, ctx, name);
231 },
232 },
233};
234
235function minMax(args: EValue[], isMax: boolean): EValue {
236 const name = isMax ? "max" : "min";
237 const arr = asArray(args[0]!, `${name}(list)`);
238 const typed = arr.filter((x) => x.t === "number" || x.t === "date");
239 if (typed.length === 0) return { v: null, t: "null" };
240 let hasNumber = false;
241 let hasDate = false;
242 for (const x of typed) {
243 if (x.t === "number") hasNumber = true;
244 else hasDate = true;
245 }
246 if (hasNumber && hasDate) err("TYPE", `${name}() list mixes numbers and dates`);
247 let best = typed[0]!;
248 for (const x of typed) {
249 const better = isMax ? numDateKey(x) > numDateKey(best) : numDateKey(x) < numDateKey(best);
250 if (better) best = x;
251 }
252 return best;
253}
254
255
256export function unknownPropertyMessage(name: string, ctx: FormulaContext): string {
257 const keys = Object.keys(ctx.properties);
258 let best: string | null = null;
259 let bestDist = 3;
260 for (const k of keys) {
261 const d = levenshtein(name, k);
262 if (d < bestDist) {
263 bestDist = d;
264 best = k;
265 }
266 }
267 return best
268 ? `Unknown property '${name}'. Did you mean '${best}'?`
269 : `Unknown property '${name}'. Available: ${keys.join(", ") || "(none)"}`;
270}
271
272function levenshtein(a: string, b: string): number {
273 if (Math.abs(a.length - b.length) > 2) return 3;
274 const dp: number[][] = [];
275 for (let i = 0; i <= a.length; i++) {
276 dp[i] = [i];
277 }
278 for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
279 for (let i = 1; i <= a.length; i++) {
280 for (let j = 1; j <= b.length; j++) {
281 const cost = a[i - 1] === b[j - 1] ? 0 : 1;
282 dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost);
283 }
284 }
285 return dp[a.length]![b.length]!;
286}
287
288
289export const FUNCTION_NAMES: ReadonlySet<string> = new Set(Object.keys(FUNCTIONS));
290
291
292export function normalizeProperty(v: unknown, ctx: FormulaContext, name: string): EValue {
293 const propType = ctx.propertyTypes?.[name];
294 if (v === null || v === undefined) return { v: null, t: "null" };
295 if (typeof v === "number") return { v, t: "number" };
296 if (typeof v === "boolean") return { v, t: "bool" };
297 if (typeof v === "string") {
298 if (propType === "date") return { v, t: "date" };
299 if (propType === "date_range") return { v: v, t: "date" };
300 return { v, t: "string" };
301 }
302 if (Array.isArray(v)) {
303 return {
304 v: v.map((x): EValue =>
305 x === null || x === undefined ? { v: null, t: "null" } : { v: String(x), t: "string" },
306 ),
307 t: "array",
308 };
309 }
310 if (typeof v === "object") {
311 const o = v as Record<string, unknown>;
312 if (typeof o["start"] === "string") return { v: o["start"], t: "date" };
313 }
314 return err("TYPE", `property '${name}' has an unsupported value type`);
315}
316
317export { DAY_MS };
318