internal/filter/expr/eval.go

package expr

import (
	"fmt"
	"regexp"
	"strings"
	"sync"
	"unicode"
)

// Value is a dynamically-typed expression value.
type Value struct {
	isStr bool
	s     string
	n     float64
}

// S constructs a string value.
func S(s string) Value { return Value{isStr: true, s: s} }

// N constructs a numeric value.
func N(n float64) Value { return Value{n: n} }

// String returns the underlying string (numbers are formatted).
func (v Value) String() string {
	if v.isStr {
		return v.s
	}
	return fmt.Sprintf("%g", v.n)
}

// Env is the evaluation environment supplying identifier lookups.
type Env interface {
	Lookup(name string) (Value, bool)
}

// Eval walks the tree. Unknown identifiers resolve to empty string. A
// regex operator compiles to a package-level cache.
func Eval(n *Node, env Env) bool {
	switch n.Kind {
	case "and":
		return Eval(n.Children[0], env) && Eval(n.Children[1], env)
	case "or":
		return Eval(n.Children[0], env) || Eval(n.Children[1], env)
	case "not":
		return !Eval(n.Children[0], env)
	case "cmp":
		return evalCmp(n, env)
	}
	return false
}

func evalCmp(n *Node, env Env) bool {
	left := resolve(n.Children[0], env)
	right := resolve(n.Children[1], env)
	switch n.Op {
	case "=":
		return left.String() == right.String()
	case "!=":
		return left.String() != right.String()
	case "~":
		re := compileRegex(right.String())
		return re != nil && re.MatchString(left.String())
	case "<", "<=", ">", ">=":
		return numericCmp(left, right, n.Op)
	}
	return false
}

func numericCmp(a, b Value, op string) bool {
	// Coerce both to float; non-parsable strings become NaN which always
	// fails the comparison.
	fa, okA := toFloat(a)
	fb, okB := toFloat(b)
	if !okA || !okB {
		return false
	}
	switch op {
	case "<":
		return fa < fb
	case "<=":
		return fa <= fb
	case ">":
		return fa > fb
	case ">=":
		return fa >= fb
	}
	return false
}

func toFloat(v Value) (float64, bool) {
	if !v.isStr {
		return v.n, true
	}
	var f float64
	_, err := fmt.Sscanf(strings.TrimSpace(v.s), "%f", &f)
	return f, err == nil
}

func resolve(n *Node, env Env) Value {
	switch n.Kind {
	case "ident":
		if v, ok := env.Lookup(n.Str); ok {
			return v
		}
		return S("")
	case "string":
		return S(n.Str)
	case "number":
		return N(n.Num)
	}
	return S("")
}

var (
	reMu    sync.Mutex
	reCache = map[string]*regexp.Regexp{}
)

func compileRegex(pat string) *regexp.Regexp {
	reMu.Lock()
	defer reMu.Unlock()
	if re, ok := reCache[pat]; ok {
		return re
	}
	re, err := regexp.Compile(pat)
	if err != nil {
		return nil
	}
	reCache[pat] = re
	return re
}

// IsPrintableASCII is a shared helper used by both parser.go and eval.go
// when deciding whether a filter literal needs quoting in error output.
func IsPrintableASCII(s string) bool {
	for _, r := range s {
		if r > unicode.MaxASCII || !unicode.IsPrint(r) {
			return false
		}
	}
	return true
}