// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts. // // Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %), // comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c), // 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: // // {ds:name} a data-source signal value (brace content split on FIRST ':'). // bareIdent a graph-local state variable (data source "local"). // // Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The // evaluator never uses reflection or eval; it walks a parsed AST against a // caller-supplied Resolver. package controllogic import ( "fmt" "math" "strconv" "strings" "sync" ) // Resolver returns the current value of a signal/local reference. type Resolver func(ds, name string) Value // RefLite identifies one signal/local reference read by an expression. type RefLite struct { DS string Name string } // ── AST ────────────────────────────────────────────────────────────────────── type exprNode interface{ eval(R Resolver) Value } type numNode struct{ v float64 } type sigNode struct{ ds, name string } type varNode struct{ name string } type arrNode struct{ items []exprNode } type indexNode struct{ a, i exprNode } type unNode struct { op string a exprNode } type binNode struct { op string a, b exprNode } type ternNode struct{ c, a, b exprNode } type callNode struct { fn string args []exprNode } func mustNum(v Value) float64 { f, err := asNum(v) if err != nil { panic(err) } return f } func mustArr(v Value) []Value { a, err := asArr(v) if err != nil { panic(err) } return a } func (n numNode) eval(R Resolver) Value { return n.v } func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) } func (n varNode) eval(R Resolver) Value { return R("local", n.name) } func (n arrNode) eval(R Resolver) Value { out := make([]Value, len(n.items)) for i, it := range n.items { out[i] = it.eval(R) } return out } func (n indexNode) eval(R Resolver) Value { arr := mustArr(n.a.eval(R)) k, err := idxResolve(mustNum(n.i.eval(R)), len(arr)) if err != nil { panic(err) } return arr[k] } func (n unNode) eval(R Resolver) Value { if n.op == "-" { return -mustNum(n.a.eval(R)) } if mustNum(n.a.eval(R)) == 0 { return 1.0 } return 0.0 } func (n ternNode) eval(R Resolver) Value { if mustNum(n.c.eval(R)) != 0 { return n.a.eval(R) } return n.b.eval(R) } func (n callNode) eval(R Resolver) Value { args := make([]Value, len(n.args)) for i, a := range n.args { args[i] = a.eval(R) } // min/max: scalar-variadic OR single-array form. if n.fn == "min" || n.fn == "max" { if len(args) == 1 { if arr, ok := args[0].([]Value); ok { return reduceMinMax(n.fn, arr) } } nums := make([]Value, len(args)) copy(nums, args) return reduceMinMax(n.fn, nums) } if af, ok := arrFuncs[n.fn]; ok { return af(args) } if sf, ok := scalarFuncs[n.fn]; ok { nums := make([]float64, len(args)) for i, a := range args { nums[i] = mustNum(a) } return sf(nums) } panic(fmt.Errorf("unknown function %q", n.fn)) } func (n binNode) eval(R Resolver) Value { a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R)) switch n.op { case "+": return a + b case "-": return a - b case "*": return a * b case "/": return a / b case "%": return math.Mod(a, b) case "<": return boolf(a < b) case "<=": return boolf(a <= b) case ">": return boolf(a > b) case ">=": return boolf(a >= b) case "==": return boolf(a == b) case "!=": return boolf(a != b) case "&&": return boolf(a != 0 && b != 0) case "||": return boolf(a != 0 || b != 0) } panic(fmt.Errorf("unknown operator %q", n.op)) } func boolf(b bool) float64 { if b { return 1 } return 0 } func reduceMinMax(fn string, arr []Value) Value { if len(arr) == 0 { if fn == "min" { return math.Inf(1) } return math.Inf(-1) } m := mustNum(arr[0]) for _, x := range arr[1:] { v := mustNum(x) if fn == "min" { m = math.Min(m, v) } else { m = math.Max(m, v) } } return m } // ── Functions ──────────────────────────────────────────────────────────────── var scalarFuncs = map[string]func([]float64) float64{ "abs": func(a []float64) float64 { return math.Abs(a[0]) }, "sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) }, "floor": func(a []float64) float64 { return math.Floor(a[0]) }, "ceil": func(a []float64) float64 { return math.Ceil(a[0]) }, "round": func(a []float64) float64 { return math.Round(a[0]) }, "sign": func(a []float64) float64 { return float64(signOf(a[0])) }, "pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) }, "log": func(a []float64) float64 { return math.Log(a[0]) }, "exp": func(a []float64) float64 { return math.Exp(a[0]) }, "sin": func(a []float64) float64 { return math.Sin(a[0]) }, "cos": func(a []float64) float64 { return math.Cos(a[0]) }, } var arrFuncs = map[string]func([]Value) Value{ "len": func(a []Value) Value { return float64(len(mustArr(a[0]))) }, "sum": func(a []Value) Value { s := 0.0 for _, x := range mustArr(a[0]) { s += mustNum(x) } return s }, "mean": func(a []Value) Value { r := mustArr(a[0]) if len(r) == 0 { return 0.0 } s := 0.0 for _, x := range r { s += mustNum(x) } return s / float64(len(r)) }, "slice": func(a []Value) Value { r := mustArr(a[0]) s := 0 e := len(r) if len(a) > 1 { s = clampIdx(int(mustNum(a[1])), len(r)) } if len(a) > 2 { e = clampIdx(int(mustNum(a[2])), len(r)) } if s > e { s = e } out := make([]Value, 0, e-s) out = append(out, r[s:e]...) return out }, "concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) }, "reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r }, "sort": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...) sortNum(r) return r }, "scale": func(a []Value) Value { r := mustArr(a[0]) k := mustNum(a[1]) out := make([]Value, len(r)) for i, x := range r { out[i] = mustNum(x) * k } return out }, "add": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y }) }, "sub": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y }) }, "push": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), a[1]) }, "set": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...) k, err := idxResolve(mustNum(a[1]), len(r)) if err != nil { panic(err) } r[k] = a[2] return r }, "insert": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...) k := int(mustNum(a[1])) if k < 0 { k = 0 } if k > len(r) { k = len(r) } r = append(r, nil) copy(r[k+1:], r[k:]) r[k] = a[2] return r }, "remove": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...) k, err := idxResolve(mustNum(a[1]), len(r)) if err != nil { panic(err) } return append(r[:k], r[k+1:]...) }, "pop": func(a []Value) Value { r := mustArr(a[0]) if len(r) == 0 { return []Value{} } return append([]Value{}, r[:len(r)-1]...) }, "shift": func(a []Value) Value { r := mustArr(a[0]) if len(r) == 0 { return []Value{} } return append([]Value{}, r[1:]...) }, "indexOf": func(a []Value) Value { r := mustArr(a[0]) for i, x := range r { if valEq(x, a[1]) { return float64(i) } } return -1.0 }, "contains": func(a []Value) Value { r := mustArr(a[0]) for _, x := range r { if valEq(x, a[1]) { return 1.0 } } return 0.0 }, "fill": func(a []Value) Value { n := int(mustNum(a[0])) if n < 0 { n = 0 } out := make([]Value, n) for i := range out { out[i] = a[1] } return out }, } func signOf(x float64) int { switch { case x > 0: return 1 case x < 0: return -1 default: return 0 } } func clampIdx(i, length int) int { if i < 0 { i = length + i } if i < 0 { i = 0 } if i > length { i = length } return i } func reverse(r []Value) { for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } } func sortNum(r []Value) { for i := 1; i < len(r); i++ { for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- { r[j-1], r[j] = r[j], r[j-1] } } } func zipNum(x, y []Value, f func(a, b float64) float64) []Value { n := len(x) if len(y) < n { n = len(y) } out := make([]Value, 0, n) for i := 0; i < n; i++ { out = append(out, f(mustNum(x[i]), mustNum(y[i]))) } return out } func valEq(a, b Value) bool { af, aok := a.(float64) bf, bok := b.(float64) return aok && bok && af == bf } // ── Tokenizer ──────────────────────────────────────────────────────────────── type tok struct { k string v string } func tokenize(src string) ([]tok, error) { var toks []tok two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true} r := []rune(src) i := 0 for i < len(r) { c := r[i] switch { case c == ' ' || c == '\t' || c == '\n' || c == '\r': i++ continue case c == '{': end := -1 for j := i + 1; j < len(r); j++ { if r[j] == '}' { end = j break } } if end < 0 { return nil, fmt.Errorf("unterminated { in expression") } toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])}) i = end + 1 continue } if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) { j := i + 1 for j < len(r) && (isDigit(r[j]) || r[j] == '.') { j++ } toks = append(toks, tok{k: "num", v: string(r[i:j])}) i = j continue } if isIdentStart(c) { j := i + 1 for j < len(r) && isIdentPart(r[j]) { j++ } toks = append(toks, tok{k: "ident", v: string(r[i:j])}) i = j continue } if i+1 < len(r) { pair := string(r[i : i+2]) if two[pair] { toks = append(toks, tok{k: pair}) i += 2 continue } } if strings.ContainsRune("+-*/%<>!()?:,[]", c) { toks = append(toks, tok{k: string(c)}) i++ continue } return nil, fmt.Errorf("unexpected character %q in expression", string(c)) } return toks, nil } func isDigit(c rune) bool { return c >= '0' && c <= '9' } func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') } func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) } // ── Parser (recursive descent) ──────────────────────────────────────────────── type parser struct { toks []tok p int } func (ps *parser) peek() (tok, bool) { if ps.p < len(ps.toks) { return ps.toks[ps.p], true } return tok{}, false } func (ps *parser) eat(k string) (tok, error) { if ps.p >= len(ps.toks) { return tok{}, fmt.Errorf("unexpected end of expression") } t := ps.toks[ps.p] if k != "" && t.k != k { return tok{}, fmt.Errorf("expected %q in expression", k) } ps.p++ return t, nil } func parse(src string) (exprNode, error) { toks, err := tokenize(src) if err != nil { return nil, err } ps := &parser{toks: toks} root, err := ps.ternary() if err != nil { return nil, err } if ps.p < len(ps.toks) { return nil, fmt.Errorf("trailing tokens in expression") } return root, nil } func (ps *parser) atom() (exprNode, error) { t, ok := ps.peek() if !ok { return nil, fmt.Errorf("unexpected end of expression") } switch t.k { case "num": ps.eat("") v, err := strconv.ParseFloat(t.v, 64) if err != nil { return nil, fmt.Errorf("bad number %q", t.v) } return numNode{v: v}, nil case "[": ps.eat("[") var items []exprNode if nx, ok := ps.peek(); ok && nx.k != "]" { a, err := ps.ternary() if err != nil { return nil, err } items = append(items, a) for { nx2, ok := ps.peek() if !ok || nx2.k != "," { break } ps.eat(",") a, err := ps.ternary() if err != nil { return nil, err } items = append(items, a) } } if _, err := ps.eat("]"); err != nil { return nil, err } return arrNode{items: items}, nil case "sig": ps.eat("") idx := strings.IndexByte(t.v, ':') if idx < 0 { return sigNode{ds: t.v, name: ""}, nil } return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil case "ident": ps.eat("") id := t.v if id == "true" { return numNode{v: 1}, nil } if id == "false" { return numNode{v: 0}, nil } if nx, ok := ps.peek(); ok && nx.k == "(" { ps.eat("(") var args []exprNode if nx2, ok := ps.peek(); ok && nx2.k != ")" { a, err := ps.ternary() if err != nil { return nil, err } args = append(args, a) for { nx3, ok := ps.peek() if !ok || nx3.k != "," { break } ps.eat(",") a, err := ps.ternary() if err != nil { return nil, err } args = append(args, a) } } if _, err := ps.eat(")"); err != nil { return nil, err } if !knownFunc(id) { return nil, fmt.Errorf("unknown function %q", id) } return callNode{fn: id, args: args}, nil } return varNode{name: id}, nil case "(": ps.eat("(") e, err := ps.ternary() if err != nil { return nil, err } if _, err := ps.eat(")"); err != nil { return nil, err } return e, nil } return nil, fmt.Errorf("unexpected token %q in expression", t.k) } func knownFunc(id string) bool { if id == "min" || id == "max" { return true } if _, ok := arrFuncs[id]; ok { return true } _, ok := scalarFuncs[id] return ok } func (ps *parser) primary() (exprNode, error) { n, err := ps.atom() if err != nil { return nil, err } for { nx, ok := ps.peek() if !ok || nx.k != "[" { return n, nil } ps.eat("[") i, err := ps.ternary() if err != nil { return nil, err } if _, err := ps.eat("]"); err != nil { return nil, err } n = indexNode{a: n, i: i} } } func (ps *parser) unary() (exprNode, error) { if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") { ps.eat("") a, err := ps.unary() if err != nil { return nil, err } return unNode{op: t.k, a: a}, nil } return ps.primary() } func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) { a, err := next() if err != nil { return nil, err } for { t, ok := ps.peek() if !ok || !contains(ops, t.k) { return a, nil } op, _ := ps.eat("") b, err := next() if err != nil { return nil, err } a = binNode{op: op.k, a: a, b: b} } } func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") } func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") } func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") } func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") } func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") } func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") } func (ps *parser) ternary() (exprNode, error) { c, err := ps.or() if err != nil { return nil, err } if t, ok := ps.peek(); ok && t.k == "?" { ps.eat("?") a, err := ps.ternary() if err != nil { return nil, err } if _, err := ps.eat(":"); err != nil { return nil, err } b, err := ps.ternary() if err != nil { return nil, err } return ternNode{c: c, a: a, b: b}, nil } return c, nil } func contains(s []string, v string) bool { for _, x := range s { if x == v { return true } } return false } // ── Cache + public API ───────────────────────────────────────────────────────── type cacheEntry struct { node exprNode err error } var ( cacheMu sync.Mutex cache = map[string]cacheEntry{} ) func parseCached(src string) (exprNode, error) { cacheMu.Lock() e, ok := cache[src] cacheMu.Unlock() if ok { return e.node, e.err } n, err := parse(src) cacheMu.Lock() cache[src] = cacheEntry{node: n, err: err} cacheMu.Unlock() return n, err } // EvalValue evaluates an expression, returning the full Value (number or array). // Returns NaN on parse/eval failure. func EvalValue(src string, resolve Resolver) Value { n, err := parseCached(src) if err != nil { return math.NaN() } return safeEval(n, resolve) } func safeEval(n exprNode, resolve Resolver) (out Value) { defer func() { if recover() != nil { out = math.NaN() } }() return n.eval(resolve) } // EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval // failure OR when the result is an array. func EvalExpr(src string, resolve Resolver) float64 { v := EvalValue(src, resolve) if f, ok := v.(float64); ok { return f } return math.NaN() } // EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar. func EvalBool(src string, resolve Resolver) bool { v := EvalExpr(src, resolve) return !math.IsNaN(v) && v != 0 } // CollectRefs returns every signal/local reference an expression reads. func CollectRefs(src string) []RefLite { root, err := parseCached(src) if err != nil { return nil } var out []RefLite seen := map[string]bool{} add := func(ds, name string) { k := ds + "\x00" + name if !seen[k] { seen[k] = true out = append(out, RefLite{DS: ds, Name: name}) } } var walk func(n exprNode) walk = func(n exprNode) { switch t := n.(type) { case sigNode: add(t.ds, t.name) case varNode: add("local", t.name) case arrNode: for _, it := range t.items { walk(it) } case indexNode: walk(t.a) walk(t.i) case unNode: walk(t.a) case binNode: walk(t.a) walk(t.b) case ternNode: walk(t.c) walk(t.a) walk(t.b) case callNode: for _, a := range t.args { walk(a) } } } walk(root) return out } // CheckExpr validates an expression; returns an error message or "" if it parses. func CheckExpr(src string) string { if strings.TrimSpace(src) == "" { return "" } if _, err := parse(src); err != nil { return err.Error() } return "" }