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:
Martino Ferrari
2026-06-24 10:54:43 +02:00
parent 2f40a9d1a0
commit 5578bceea2
+108 -30
View File
@@ -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);