218 lines
7.8 KiB
TypeScript
218 lines
7.8 KiB
TypeScript
// Hand-rolled expression evaluator. No eval, no deps.
|
|
// Supported grammar:
|
|
// expr := ternary
|
|
// ternary := or
|
|
// or := and ("||" and)*
|
|
// and := compare ("&&" compare)*
|
|
// compare := sum ((==|!=|<|>|<=|>=) sum)?
|
|
// sum := mul (("+"|"-") mul)*
|
|
// mul := unary (("*"|"/"|"%") unary)*
|
|
// unary := ("-"|"!") unary | atom
|
|
// atom := number | string | ident | call | "(" expr ")"
|
|
// call := ident "(" (expr ("," expr)*)? ")"
|
|
//
|
|
// Identifiers resolve in this order:
|
|
// 1. functions: if, sum, avg, min, max, count, len, num
|
|
// 2. constants: true, false, null
|
|
// 3. vars[id] from values map
|
|
|
|
import type { Field } from "./types";
|
|
|
|
type Value = number | string | boolean | null | Value[];
|
|
|
|
const FUNCTIONS: Record<string, (args: Value[]) => Value> = {
|
|
if: ([c, a, b]) => (truthy(c) ? (a ?? null) : (b ?? null)),
|
|
sum: (args) => flatNums(args).reduce((a, b) => a + b, 0),
|
|
avg: (args) => {
|
|
const ns = flatNums(args);
|
|
return ns.length === 0 ? 0 : ns.reduce((a, b) => a + b, 0) / ns.length;
|
|
},
|
|
min: (args) => Math.min(...flatNums(args)),
|
|
max: (args) => Math.max(...flatNums(args)),
|
|
count: (args) => flatten(args).filter((v) => !empty(v)).length,
|
|
len: ([v]) => (typeof v === "string" ? v.length : Array.isArray(v) ? v.length : 0),
|
|
num: ([v]) => Number(v ?? 0),
|
|
};
|
|
|
|
function flatten(vs: Value[]): Value[] {
|
|
const out: Value[] = [];
|
|
for (const v of vs) Array.isArray(v) ? out.push(...flatten(v)) : out.push(v);
|
|
return out;
|
|
}
|
|
function flatNums(vs: Value[]): number[] {
|
|
return flatten(vs).map((v) => Number(v)).filter((n) => !Number.isNaN(n));
|
|
}
|
|
function empty(v: Value): boolean {
|
|
return v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
|
|
}
|
|
function truthy(v: Value): boolean {
|
|
if (typeof v === "number") return v !== 0;
|
|
if (typeof v === "string") return v.length > 0;
|
|
if (Array.isArray(v)) return v.length > 0;
|
|
return !!v;
|
|
}
|
|
|
|
// ----- Tokenizer -----
|
|
|
|
type TokenKind = "num" | "str" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
|
type Token = { kind: TokenKind; value: string };
|
|
|
|
function tokenize(src: string): Token[] {
|
|
const out: Token[] = [];
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
if (c === " " || c === "\t" || c === "\n") { i++; continue; }
|
|
if (c >= "0" && c <= "9") {
|
|
let j = i + 1;
|
|
while (j < src.length && /[0-9.]/.test(src[j])) j++;
|
|
out.push({ kind: "num", value: src.slice(i, j) }); i = j; continue;
|
|
}
|
|
if (c === '"' || c === "'") {
|
|
let j = i + 1;
|
|
while (j < src.length && src[j] !== c) j++;
|
|
out.push({ kind: "str", value: src.slice(i + 1, j) }); i = j + 1; continue;
|
|
}
|
|
if (/[A-Za-z_]/.test(c)) {
|
|
let j = i + 1;
|
|
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
|
|
out.push({ kind: "ident", value: src.slice(i, j) }); i = j; continue;
|
|
}
|
|
if (c === "(") { out.push({ kind: "lparen", value: c }); i++; continue; }
|
|
if (c === ")") { out.push({ kind: "rparen", value: c }); i++; continue; }
|
|
if (c === ",") { out.push({ kind: "comma", value: c }); i++; continue; }
|
|
// Multi-char operators first.
|
|
const two = src.slice(i, i + 2);
|
|
if (["==","!=","<=",">=","&&","||"].includes(two)) {
|
|
out.push({ kind: "op", value: two }); i += 2; continue;
|
|
}
|
|
if ("+-*/%<>!".includes(c)) { out.push({ kind: "op", value: c }); i++; continue; }
|
|
throw new Error(`Unexpected character: ${c}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ----- Parser + evaluator (recursive descent) -----
|
|
|
|
class Parser {
|
|
i = 0;
|
|
constructor(public tokens: Token[], public vars: Record<string, Value>) {}
|
|
peek(): Token | undefined { return this.tokens[this.i]; }
|
|
eat(): Token { return this.tokens[this.i++]; }
|
|
expect(kind: TokenKind, value?: string): Token {
|
|
const t = this.eat();
|
|
if (!t || t.kind !== kind || (value && t.value !== value)) throw new Error(`Expected ${kind}${value ? " " + value : ""}`);
|
|
return t;
|
|
}
|
|
|
|
parseExpr(): Value { return this.parseOr(); }
|
|
parseOr(): Value {
|
|
let l = this.parseAnd();
|
|
while (this.peek()?.kind === "op" && this.peek()!.value === "||") {
|
|
this.eat(); const r = this.parseAnd();
|
|
l = truthy(l) || truthy(r);
|
|
}
|
|
return l;
|
|
}
|
|
parseAnd(): Value {
|
|
let l = this.parseCompare();
|
|
while (this.peek()?.kind === "op" && this.peek()!.value === "&&") {
|
|
this.eat(); const r = this.parseCompare();
|
|
l = truthy(l) && truthy(r);
|
|
}
|
|
return l;
|
|
}
|
|
parseCompare(): Value {
|
|
const l = this.parseSum();
|
|
const t = this.peek();
|
|
if (!t || t.kind !== "op" || !["==","!=","<","<=",">",">="].includes(t.value)) return l;
|
|
this.eat();
|
|
const r = this.parseSum();
|
|
switch (t.value) {
|
|
case "==": return l === r;
|
|
case "!=": return l !== r;
|
|
case "<": return Number(l) < Number(r);
|
|
case "<=": return Number(l) <= Number(r);
|
|
case ">": return Number(l) > Number(r);
|
|
case ">=": return Number(l) >= Number(r);
|
|
}
|
|
return false;
|
|
}
|
|
parseSum(): Value {
|
|
let l = this.parseMul();
|
|
while (this.peek()?.kind === "op" && (this.peek()!.value === "+" || this.peek()!.value === "-")) {
|
|
const op = this.eat().value;
|
|
const r = this.parseMul();
|
|
if (op === "+" && (typeof l === "string" || typeof r === "string")) l = String(l ?? "") + String(r ?? "");
|
|
else l = Number(l) + (op === "+" ? 1 : -1) * Number(r);
|
|
}
|
|
return l;
|
|
}
|
|
parseMul(): Value {
|
|
let l = this.parseUnary();
|
|
while (this.peek()?.kind === "op" && ["*","/","%"].includes(this.peek()!.value)) {
|
|
const op = this.eat().value;
|
|
const r = this.parseUnary();
|
|
const a = Number(l); const b = Number(r);
|
|
l = op === "*" ? a * b : op === "/" ? (b === 0 ? 0 : a / b) : a % b;
|
|
}
|
|
return l;
|
|
}
|
|
parseUnary(): Value {
|
|
const t = this.peek();
|
|
if (t?.kind === "op" && t.value === "-") { this.eat(); return -Number(this.parseUnary()); }
|
|
if (t?.kind === "op" && t.value === "!") { this.eat(); return !truthy(this.parseUnary()); }
|
|
return this.parseAtom();
|
|
}
|
|
parseAtom(): Value {
|
|
const t = this.eat();
|
|
if (!t) throw new Error("Unexpected end of expression");
|
|
if (t.kind === "num") return Number(t.value);
|
|
if (t.kind === "str") return t.value;
|
|
if (t.kind === "lparen") { const v = this.parseExpr(); this.expect("rparen"); return v; }
|
|
if (t.kind === "ident") {
|
|
if (this.peek()?.kind === "lparen") {
|
|
this.eat();
|
|
const args: Value[] = [];
|
|
if (this.peek()?.kind !== "rparen") {
|
|
args.push(this.parseExpr());
|
|
while (this.peek()?.kind === "comma") { this.eat(); args.push(this.parseExpr()); }
|
|
}
|
|
this.expect("rparen");
|
|
const fn = FUNCTIONS[t.value];
|
|
if (!fn) throw new Error(`Unknown function: ${t.value}`);
|
|
return fn(args);
|
|
}
|
|
if (t.value === "true") return true;
|
|
if (t.value === "false") return false;
|
|
if (t.value === "null") return null;
|
|
// hasOwnProperty so identifiers like `constructor`, `__proto__`, or
|
|
// `toString` don't leak through the prototype chain.
|
|
if (!Object.prototype.hasOwnProperty.call(this.vars, t.value)) return null;
|
|
const v = this.vars[t.value];
|
|
return v === undefined ? null : (v as Value);
|
|
}
|
|
throw new Error(`Unexpected token: ${t.value}`);
|
|
}
|
|
}
|
|
|
|
export function evalExpr(expr: string, values: Record<string, unknown>): Value {
|
|
try {
|
|
const p = new Parser(tokenize(expr), values as Record<string, Value>);
|
|
return p.parseExpr();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Compute and inject calculated field values into the result map. */
|
|
export function applyCalculations(fields: Field[], values: Record<string, unknown>): Record<string, unknown> {
|
|
const next = { ...values };
|
|
for (const f of fields) {
|
|
if (f.type !== "calculated") continue;
|
|
if (!f.expression) { next[f.id] = null; continue; }
|
|
next[f.id] = evalExpr(f.expression, next);
|
|
}
|
|
return next;
|
|
}
|