From 5578bceea2976e7ae9e7e68911972e61d9593b21 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 10:54:43 +0200 Subject: [PATCH] feat(logic): array-aware expression engine (literals, indexing, array funcs) Co-Authored-By: Claude Opus 4.6 --- web/src/lib/expr.ts | 138 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 30 deletions(-) diff --git a/web/src/lib/expr.ts b/web/src/lib/expr.ts index 8a6221d..fc990db 100644 --- a/web/src/lib/expr.ts +++ b/web/src/lib/expr.ts @@ -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 number> = { +const SCALAR_FUNCS: Record 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 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);