feat(logic): array-aware expression engine (literals, indexing, array funcs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+108
-30
@@ -2,8 +2,9 @@
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]),
|
||||
// and a set of math + array functions. Two kinds of variable reference are
|
||||
// resolved live at evaluation time:
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// bareIdent a panel-local state variable (data source 'local').
|
||||
@@ -12,6 +13,8 @@
|
||||
// and any nonzero value is truthy. The evaluator never touches the DOM or eval;
|
||||
// it walks a parsed AST against a caller-supplied resolver.
|
||||
|
||||
import type { ArrVal } from './arraypolicy';
|
||||
|
||||
export interface RefLite { ds: string; name: string }
|
||||
|
||||
type Node =
|
||||
@@ -21,12 +24,12 @@ type Node =
|
||||
| { t: 'un'; op: string; a: Node }
|
||||
| { t: 'bin'; op: string; a: Node; b: Node }
|
||||
| { t: 'tern'; c: Node; a: Node; b: Node }
|
||||
| { t: 'call'; fn: string; args: Node[] };
|
||||
| { t: 'call'; fn: string; args: Node[] }
|
||||
| { t: 'arr'; items: Node[] }
|
||||
| { t: 'index'; a: Node; i: Node };
|
||||
|
||||
const FUNCS: Record<string, (a: number[]) => number> = {
|
||||
const SCALAR_FUNCS: Record<string, (a: number[]) => number> = {
|
||||
abs: a => Math.abs(a[0]),
|
||||
min: a => Math.min(...a),
|
||||
max: a => Math.max(...a),
|
||||
sqrt: a => Math.sqrt(a[0]),
|
||||
floor: a => Math.floor(a[0]),
|
||||
ceil: a => Math.ceil(a[0]),
|
||||
@@ -77,8 +80,8 @@ function tokenize(src: string): Tok[] {
|
||||
// two-char operators
|
||||
const pair = src.slice(i, i + 2);
|
||||
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
||||
// single-char operators / punctuation
|
||||
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
// single-char operators / punctuation (including brackets)
|
||||
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
throw new Error(`unexpected character '${c}' in expression`);
|
||||
}
|
||||
return toks;
|
||||
@@ -97,10 +100,20 @@ function parse(src: string): Node {
|
||||
return t;
|
||||
};
|
||||
|
||||
function primary(): Node {
|
||||
function atom(): Node {
|
||||
const t = peek();
|
||||
if (!t) throw new Error('unexpected end of expression');
|
||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||
if (t.k === '[') {
|
||||
eat('[');
|
||||
const items: Node[] = [];
|
||||
if (peek()?.k !== ']') {
|
||||
items.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); items.push(ternary()); }
|
||||
}
|
||||
eat(']');
|
||||
return { t: 'arr', items };
|
||||
}
|
||||
if (t.k === 'sig') {
|
||||
eat();
|
||||
const raw = t.v!;
|
||||
@@ -130,6 +143,17 @@ function parse(src: string): Node {
|
||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||
}
|
||||
|
||||
function primary(): Node {
|
||||
let n = atom();
|
||||
while (peek()?.k === '[') {
|
||||
eat('[');
|
||||
const i = ternary();
|
||||
eat(']');
|
||||
n = { t: 'index', a: n, i };
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function unary(): Node {
|
||||
const t = peek();
|
||||
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
|
||||
@@ -186,34 +210,80 @@ function parseCached(src: string): Node {
|
||||
|
||||
// ── Evaluation ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Resolver = (ds: string, name: string) => number;
|
||||
export type Resolver = (ds: string, name: string) => ArrVal;
|
||||
|
||||
function ev(n: Node, R: Resolver): number {
|
||||
function asNum(v: ArrVal): number {
|
||||
if (typeof v !== 'number') throw new Error('expected a number, got an array');
|
||||
return v;
|
||||
}
|
||||
function asArr(v: ArrVal): ArrVal[] {
|
||||
if (!Array.isArray(v)) throw new Error('expected an array, got a number');
|
||||
return v;
|
||||
}
|
||||
// resolve a possibly-negative index against length
|
||||
function idx(i: number, len: number): number {
|
||||
const k = Math.trunc(i) < 0 ? len + Math.trunc(i) : Math.trunc(i);
|
||||
if (k < 0 || k >= len) throw new Error(`index ${i} out of range (len ${len})`);
|
||||
return k;
|
||||
}
|
||||
|
||||
const ARR_FUNCS: Record<string, (a: ArrVal[]) => ArrVal> = {
|
||||
len: a => asArr(a[0]).length,
|
||||
sum: a => asArr(a[0]).reduce((s, x) => s + asNum(x), 0),
|
||||
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce((s, x) => s + asNum(x), 0) / r.length : 0; },
|
||||
// min/max: scalar-variadic OR single-array — see ev() dispatch below
|
||||
slice: a => { const r = asArr(a[0]); const s = a[1] === undefined ? 0 : asNum(a[1]); const e = a[2] === undefined ? r.length : asNum(a[2]); return r.slice(s, e); },
|
||||
concat: a => asArr(a[0]).concat(asArr(a[1])),
|
||||
reverse: a => asArr(a[0]).slice().reverse(),
|
||||
sort: a => asArr(a[0]).slice().sort((x, y) => asNum(x) - asNum(y)),
|
||||
scale: a => asArr(a[0]).map(x => asNum(x) * asNum(a[1])),
|
||||
add: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) + asNum(y[k])); return o; },
|
||||
sub: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) - asNum(y[k])); return o; },
|
||||
push: a => asArr(a[0]).concat([a[1]]),
|
||||
set: a => { const r = asArr(a[0]).slice(); r[idx(asNum(a[1]), r.length)] = a[2]; return r; },
|
||||
insert: a => { const r = asArr(a[0]).slice(); const k = Math.max(0, Math.min(r.length, Math.trunc(asNum(a[1])))); r.splice(k, 0, a[2]); return r; },
|
||||
remove: a => { const r = asArr(a[0]).slice(); r.splice(idx(asNum(a[1]), r.length), 1); return r; },
|
||||
pop: a => { const r = asArr(a[0]).slice(); r.pop(); return r; },
|
||||
shift: a => { const r = asArr(a[0]).slice(); r.shift(); return r; },
|
||||
indexOf: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return k; return -1; },
|
||||
contains: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return 1; return 0; },
|
||||
fill: a => new Array(Math.max(0, Math.trunc(asNum(a[0])))).fill(a[1]),
|
||||
};
|
||||
|
||||
function ev(n: Node, R: Resolver): ArrVal {
|
||||
switch (n.t) {
|
||||
case 'num': return n.v;
|
||||
case 'arr': return n.items.map(it => ev(it, R));
|
||||
case 'sig': return R(n.ds, n.name);
|
||||
case 'var': return R('local', n.name);
|
||||
case 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0);
|
||||
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
|
||||
case 'un': return n.op === '-' ? -asNum(ev(n.a, R)) : (asNum(ev(n.a, R)) === 0 ? 1 : 0);
|
||||
case 'tern': return asNum(ev(n.c, R)) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'call': {
|
||||
const fn = FUNCS[n.fn];
|
||||
if (!fn) throw new Error(`unknown function '${n.fn}'`);
|
||||
return fn(n.args.map(a => ev(a, R)));
|
||||
const args = n.args.map(a => ev(a, R));
|
||||
// min/max keep scalar-variadic form, plus 1-arg array form
|
||||
if ((n.fn === 'min' || n.fn === 'max') && !(args.length === 1 && Array.isArray(args[0]))) {
|
||||
const nums = args.map(asNum);
|
||||
return n.fn === 'min' ? Math.min(...nums) : Math.max(...nums);
|
||||
}
|
||||
if (n.fn === 'min' || n.fn === 'max') {
|
||||
const r = asArr(args[0]).map(asNum);
|
||||
return n.fn === 'min' ? Math.min(...r) : Math.max(...r);
|
||||
}
|
||||
const af = ARR_FUNCS[n.fn];
|
||||
if (af) return af(args);
|
||||
const sf = SCALAR_FUNCS[n.fn];
|
||||
if (sf) return sf(args.map(asNum));
|
||||
throw new Error(`unknown function '${n.fn}'`);
|
||||
}
|
||||
case 'bin': {
|
||||
const a = ev(n.a, R), b = ev(n.b, R);
|
||||
const a = asNum(ev(n.a, R)), b = asNum(ev(n.b, R));
|
||||
switch (n.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return a / b;
|
||||
case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0;
|
||||
case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0;
|
||||
case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0;
|
||||
case '!=': return a !== b ? 1 : 0;
|
||||
case '+': return a + b; case '-': return a - b; case '*': return a * b;
|
||||
case '/': return a / b; case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0; case '!=': return a !== b ? 1 : 0;
|
||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||
default: throw new Error(`unknown operator '${n.op}'`);
|
||||
@@ -222,10 +292,16 @@ function ev(n: Node, R: Resolver): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */
|
||||
/** Evaluate an expression string; returns the full value (number or array). */
|
||||
export function evalValue(src: string, resolve: Resolver): ArrVal {
|
||||
return ev(parseCached(src), resolve);
|
||||
}
|
||||
|
||||
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated or the result is an array. */
|
||||
export function evalExpr(src: string, resolve: Resolver): number {
|
||||
try {
|
||||
return ev(parseCached(src), resolve);
|
||||
const v = ev(parseCached(src), resolve);
|
||||
return typeof v === 'number' ? v : NaN;
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
@@ -255,6 +331,8 @@ export function collectRefs(src: string): RefLite[] {
|
||||
case 'bin': walk(n.a); walk(n.b); break;
|
||||
case 'tern': walk(n.c); walk(n.a); walk(n.b); break;
|
||||
case 'call': n.args.forEach(walk); break;
|
||||
case 'arr': n.items.forEach(walk); break;
|
||||
case 'index': walk(n.a); walk(n.i); break;
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
Reference in New Issue
Block a user