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 (+ - * / %),
|
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||||
// parentheses, and a handful of math functions. Two kinds of variable
|
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]),
|
||||||
// reference are resolved live at evaluation time:
|
// 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
|
// {ds:name} a data-source signal value (the brace content is split on the
|
||||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||||
// bareIdent a panel-local state variable (data source 'local').
|
// 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;
|
// and any nonzero value is truthy. The evaluator never touches the DOM or eval;
|
||||||
// it walks a parsed AST against a caller-supplied resolver.
|
// it walks a parsed AST against a caller-supplied resolver.
|
||||||
|
|
||||||
|
import type { ArrVal } from './arraypolicy';
|
||||||
|
|
||||||
export interface RefLite { ds: string; name: string }
|
export interface RefLite { ds: string; name: string }
|
||||||
|
|
||||||
type Node =
|
type Node =
|
||||||
@@ -21,12 +24,12 @@ type Node =
|
|||||||
| { t: 'un'; op: string; a: Node }
|
| { t: 'un'; op: string; a: Node }
|
||||||
| { t: 'bin'; op: string; a: Node; b: Node }
|
| { t: 'bin'; op: string; a: Node; b: Node }
|
||||||
| { t: 'tern'; c: Node; 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]),
|
abs: a => Math.abs(a[0]),
|
||||||
min: a => Math.min(...a),
|
|
||||||
max: a => Math.max(...a),
|
|
||||||
sqrt: a => Math.sqrt(a[0]),
|
sqrt: a => Math.sqrt(a[0]),
|
||||||
floor: a => Math.floor(a[0]),
|
floor: a => Math.floor(a[0]),
|
||||||
ceil: a => Math.ceil(a[0]),
|
ceil: a => Math.ceil(a[0]),
|
||||||
@@ -77,8 +80,8 @@ function tokenize(src: string): Tok[] {
|
|||||||
// two-char operators
|
// two-char operators
|
||||||
const pair = src.slice(i, i + 2);
|
const pair = src.slice(i, i + 2);
|
||||||
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
||||||
// single-char operators / punctuation
|
// single-char operators / punctuation (including brackets)
|
||||||
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||||
throw new Error(`unexpected character '${c}' in expression`);
|
throw new Error(`unexpected character '${c}' in expression`);
|
||||||
}
|
}
|
||||||
return toks;
|
return toks;
|
||||||
@@ -97,10 +100,20 @@ function parse(src: string): Node {
|
|||||||
return t;
|
return t;
|
||||||
};
|
};
|
||||||
|
|
||||||
function primary(): Node {
|
function atom(): Node {
|
||||||
const t = peek();
|
const t = peek();
|
||||||
if (!t) throw new Error('unexpected end of expression');
|
if (!t) throw new Error('unexpected end of expression');
|
||||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
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') {
|
if (t.k === 'sig') {
|
||||||
eat();
|
eat();
|
||||||
const raw = t.v!;
|
const raw = t.v!;
|
||||||
@@ -130,6 +143,17 @@ function parse(src: string): Node {
|
|||||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
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 {
|
function unary(): Node {
|
||||||
const t = peek();
|
const t = peek();
|
||||||
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
|
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 ─────────────────────────────────────────────────────────────
|
// ── 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) {
|
switch (n.t) {
|
||||||
case 'num': return n.v;
|
case 'num': return n.v;
|
||||||
|
case 'arr': return n.items.map(it => ev(it, R));
|
||||||
case 'sig': return R(n.ds, n.name);
|
case 'sig': return R(n.ds, n.name);
|
||||||
case 'var': return R('local', 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 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
|
||||||
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
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': {
|
case 'call': {
|
||||||
const fn = FUNCS[n.fn];
|
const args = n.args.map(a => ev(a, R));
|
||||||
if (!fn) throw new Error(`unknown function '${n.fn}'`);
|
// min/max keep scalar-variadic form, plus 1-arg array form
|
||||||
return fn(n.args.map(a => ev(a, R)));
|
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': {
|
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) {
|
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; case '%': return a % b;
|
||||||
case '*': return a * b;
|
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
|
||||||
case '/': return a / b;
|
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
|
||||||
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 ? 1 : 0;
|
|
||||||
case '!=': return a !== b ? 1 : 0;
|
|
||||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
case '&&': return (a !== 0 && b !== 0) ? 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}'`);
|
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 {
|
export function evalExpr(src: string, resolve: Resolver): number {
|
||||||
try {
|
try {
|
||||||
return ev(parseCached(src), resolve);
|
const v = ev(parseCached(src), resolve);
|
||||||
|
return typeof v === 'number' ? v : NaN;
|
||||||
} catch {
|
} catch {
|
||||||
return NaN;
|
return NaN;
|
||||||
}
|
}
|
||||||
@@ -255,6 +331,8 @@ export function collectRefs(src: string): RefLite[] {
|
|||||||
case 'bin': walk(n.a); walk(n.b); break;
|
case 'bin': walk(n.a); walk(n.b); break;
|
||||||
case 'tern': walk(n.c); 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 'call': n.args.forEach(walk); break;
|
||||||
|
case 'arr': n.items.forEach(walk); break;
|
||||||
|
case 'index': walk(n.a); walk(n.i); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(root);
|
walk(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user