Files
forms/src/lib/calc.ts
T

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;
}