diff --git a/builtin/lib.go b/builtin/lib.go index 61748da0..ba00e438 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -560,11 +560,10 @@ func get(params ...any) (out any, err error) { } // Methods can be defined on any type. - if v.NumMethod() > 0 { + if runtime.MethodByNameHook != nil && v.NumMethod() > 0 { if methodName, ok := i.(string); ok { - method := v.MethodByName(methodName) - if method.IsValid() { - return method.Interface(), nil + if m, ok := runtime.MethodByNameHook(v, methodName); ok { + return m, nil } } } diff --git a/checker/info.go b/checker/info.go index 57202e95..fcba787b 100644 --- a/checker/info.go +++ b/checker/info.go @@ -37,10 +37,10 @@ func MethodIndex(c *Cache, env Nature, node ast.Node) (bool, int, string) { } case *ast.MemberNode: if name, ok := n.Property.(*ast.StringNode); ok { - base := n.Node.Type() - if base != nil && base.Kind() != reflect.Interface { - if m, ok := base.MethodByName(name.Value); ok { - return true, m.Index, name.Value + base := n.Node.Nature() + if base != nil && base.Kind != reflect.Interface { + if m, ok := base.MethodByName(c, name.Value); ok && m.TypeData != nil { + return true, m.MethodIndex, name.Value } } } diff --git a/checker/nature/nature.go b/checker/nature/nature.go index c96f28c4..fefd4592 100644 --- a/checker/nature/nature.go +++ b/checker/nature/nature.go @@ -213,7 +213,7 @@ func ArrayFromType(c *Cache, t reflect.Type) Nature { } func (n *Nature) IsAny(c *Cache) bool { - return n.Type != nil && n.Kind == reflect.Interface && n.NumMethods(c) == 0 + return n.Type != nil && n.Kind == reflect.Interface && n.Type.NumMethod() == 0 } func (n *Nature) IsUnknown(c *Cache) bool { @@ -294,7 +294,20 @@ func (n *Nature) NumMethods(c *Cache) int { return 0 } +// MethodByNameHook, when non-nil, looks up a method on a Nature by name. If +// nil, expressions must use Function-registered callables instead. +var MethodByNameHook func(c *Cache, n *Nature, name string) (Nature, bool) + func (n *Nature) MethodByName(c *Cache, name string) (Nature, bool) { + if MethodByNameHook == nil { + return Nature{}, false + } + return MethodByNameHook(c, n, name) +} + +// LookupMethod is the reference implementation of MethodByNameHook. It +// transitively reaches reflect.Type.Method via the methodset cache. +func LookupMethod(c *Cache, n *Nature, name string) (Nature, bool) { if s := n.getMethodset(c); s != nil { if m := s.method(c, name); m != nil { return m.nature, true diff --git a/compiler/compiler.go b/compiler/compiler.go index 68517535..8a48067d 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -783,9 +783,12 @@ func (c *compiler) CallNode(node *ast.CallNode) { switch callee := node.Callee.(type) { case *ast.MemberNode: if prop, ok := callee.Property.(*ast.StringNode); ok { - if _, ok = callee.Node.Type().MethodByName(prop.Value); ok && callee.Node.Type().Kind() != reflect.Interface { - fnInOffset = 1 - fnNumIn-- + base := callee.Node.Nature() + if base != nil && base.Kind != reflect.Interface { + if _, ok := base.MethodByName(c.ntCache, prop.Value); ok { + fnInOffset = 1 + fnNumIn-- + } } } case *ast.IdentifierNode: diff --git a/expr.go b/expr.go index 76fbd426..d41b2fcd 100644 --- a/expr.go +++ b/expr.go @@ -13,7 +13,6 @@ import ( "github.com/expr-lang/expr/conf" "github.com/expr-lang/expr/file" "github.com/expr-lang/expr/optimizer" - "github.com/expr-lang/expr/parser" "github.com/expr-lang/expr/patcher" "github.com/expr-lang/expr/vm" ) @@ -25,7 +24,8 @@ type Option func(c *conf.Config) // If struct is passed, all fields will be treated as variables, // as well as all fields of embedded structs and struct itself. // If map is passed, all items will be treated as variables. -// Methods defined on this type will be available as functions. +// Methods defined on this type will be available as functions, +// unless imported through github.com/expr-lang/expr/static. func Env(env any) Option { return func(c *conf.Config) { c.WithEnv(env) @@ -264,27 +264,3 @@ func Compile(input string, ops ...Option) (*vm.Program, error) { func Run(program *vm.Program, env any) (any, error) { return vm.Run(program, env) } - -// Eval parses, compiles and runs given input. -func Eval(input string, env any) (any, error) { - if _, ok := env.(Option); ok { - return nil, fmt.Errorf("misused expr.Eval: second argument (env) should be passed without expr.Env") - } - - tree, err := parser.Parse(input) - if err != nil { - return nil, err - } - - program, err := compiler.Compile(tree, nil) - if err != nil { - return nil, err - } - - output, err := Run(program, env) - if err != nil { - return nil, err - } - - return output, nil -} diff --git a/reflectmethods.go b/reflectmethods.go new file mode 100644 index 00000000..9fcc6010 --- /dev/null +++ b/reflectmethods.go @@ -0,0 +1,71 @@ +package expr + +import ( + "fmt" + "reflect" + + "github.com/expr-lang/expr/checker/nature" + "github.com/expr-lang/expr/compiler" + "github.com/expr-lang/expr/parser" + "github.com/expr-lang/expr/vm/runtime" +) + +// This file installs the method-dispatch hooks that the shared packages +// (vm/runtime, checker/nature, builtin) consult to perform reflective method +// lookups. The hooks are installed only when the parent expr package is +// imported. +// +// All four reflect.* method-resolution call sites that the linker treats as +// REFLECTMETHOD live exclusively in this file (or in functions reachable +// only from this file's hooks): +// +// - reflect.Value.MethodByName in fetchMethodByName +// - reflect.Value.Method in fetchMethodIndexed +// - reflect.Type.Method in nature.LookupMethod (transitively) +// - reflect.Type.MethodByName not used + +func init() { + runtime.MethodByNameHook = fetchMethodByName + runtime.MethodIndexedHook = fetchMethodIndexed + nature.MethodByNameHook = nature.LookupMethod +} + +func fetchMethodByName(v reflect.Value, name string) (any, bool) { + method := v.MethodByName(name) + if method.IsValid() { + return method.Interface(), true + } + return nil, false +} + +func fetchMethodIndexed(v reflect.Value, index int) (any, bool) { + method := v.Method(index) + if method.IsValid() { + return method.Interface(), true + } + return nil, false +} + +// Eval parses, compiles and runs given input. +func Eval(input string, env any) (any, error) { + if _, ok := env.(Option); ok { + return nil, fmt.Errorf("misused expr.Eval: second argument (env) should be passed without expr.Env") + } + + tree, err := parser.Parse(input) + if err != nil { + return nil, err + } + + program, err := compiler.Compile(tree, nil) + if err != nil { + return nil, err + } + + output, err := Run(program, env) + if err != nil { + return nil, err + } + + return output, nil +} diff --git a/static/docs.go b/static/docs.go new file mode 100644 index 00000000..c242b6e8 --- /dev/null +++ b/static/docs.go @@ -0,0 +1,11 @@ +// Package expr (imported as github.com/expr-lang/expr/static) is a +// dead-code-elimination-friendly entry point for expr. +// +// It exposes the same API as the parent expr, without the `Eval()` function. It +// does not trigger the Go linker's REFLECTMETHOD analysis and methods of every +// reachable type can be eliminated by dead-code elimination. Calling a method +// defined on the Env type does NOT work. Compilation fails with "unknown name +// ". To expose a function, use the Function option. +package expr + +//go:generate cp ../expr.go expr.go diff --git a/static/expr.go b/static/expr.go new file mode 100644 index 00000000..d41b2fcd --- /dev/null +++ b/static/expr.go @@ -0,0 +1,266 @@ +package expr + +import ( + "errors" + "fmt" + "reflect" + "time" + + "github.com/expr-lang/expr/ast" + "github.com/expr-lang/expr/builtin" + "github.com/expr-lang/expr/checker" + "github.com/expr-lang/expr/compiler" + "github.com/expr-lang/expr/conf" + "github.com/expr-lang/expr/file" + "github.com/expr-lang/expr/optimizer" + "github.com/expr-lang/expr/patcher" + "github.com/expr-lang/expr/vm" +) + +// Option for configuring config. +type Option func(c *conf.Config) + +// Env specifies expected input of env for type checks. +// If struct is passed, all fields will be treated as variables, +// as well as all fields of embedded structs and struct itself. +// If map is passed, all items will be treated as variables. +// Methods defined on this type will be available as functions, +// unless imported through github.com/expr-lang/expr/static. +func Env(env any) Option { + return func(c *conf.Config) { + c.WithEnv(env) + } +} + +// AllowUndefinedVariables allows to use undefined variables inside expressions. +// This can be used with expr.Env option to partially define a few variables. +func AllowUndefinedVariables() Option { + return func(c *conf.Config) { + c.Strict = false + } +} + +// Operator allows to replace a binary operator with a function. +func Operator(operator string, fn ...string) Option { + return func(c *conf.Config) { + p := &patcher.OperatorOverloading{ + Operator: operator, + Overloads: fn, + Env: &c.Env, + Functions: c.Functions, + NtCache: &c.NtCache, + } + c.Visitors = append(c.Visitors, p) + } +} + +// ConstExpr defines func expression as constant. If all argument to this function is constants, +// then it can be replaced by result of this func call on compile step. +func ConstExpr(fn string) Option { + return func(c *conf.Config) { + c.ConstExpr(fn) + } +} + +// AsAny tells the compiler to expect any result. +func AsAny() Option { + return func(c *conf.Config) { + c.ExpectAny = true + } +} + +// AsKind tells the compiler to expect kind of the result. +func AsKind(kind reflect.Kind) Option { + return func(c *conf.Config) { + c.Expect = kind + c.ExpectAny = true + } +} + +// AsBool tells the compiler to expect a boolean result. +func AsBool() Option { + return func(c *conf.Config) { + c.Expect = reflect.Bool + c.ExpectAny = true + } +} + +// AsInt tells the compiler to expect an int result. +func AsInt() Option { + return func(c *conf.Config) { + c.Expect = reflect.Int + c.ExpectAny = true + } +} + +// AsInt64 tells the compiler to expect an int64 result. +func AsInt64() Option { + return func(c *conf.Config) { + c.Expect = reflect.Int64 + c.ExpectAny = true + } +} + +// AsFloat64 tells the compiler to expect a float64 result. +func AsFloat64() Option { + return func(c *conf.Config) { + c.Expect = reflect.Float64 + c.ExpectAny = true + } +} + +// DisableIfOperator disables the `if ... else ...` operator syntax so a custom +// function named `if(...)` can be used without conflicts. +func DisableIfOperator() Option { + return func(c *conf.Config) { + c.DisableIfOperator = true + } +} + +// WarnOnAny tells the compiler to warn if expression return any type. +func WarnOnAny() Option { + return func(c *conf.Config) { + if c.Expect == reflect.Invalid { + panic("WarnOnAny() works only with combination with AsInt(), AsBool(), etc. options") + } + c.ExpectAny = false + } +} + +// Optimize turns optimizations on or off. +func Optimize(b bool) Option { + return func(c *conf.Config) { + c.Optimize = b + } +} + +// DisableShortCircuit turns short circuit off. +func DisableShortCircuit() Option { + return func(c *conf.Config) { + c.ShortCircuit = false + } +} + +// Patch adds visitor to list of visitors what will be applied before compiling AST to bytecode. +func Patch(visitor ast.Visitor) Option { + return func(c *conf.Config) { + c.Visitors = append(c.Visitors, visitor) + } +} + +// Function adds function to list of functions what will be available in expressions. +func Function(name string, fn func(params ...any) (any, error), types ...any) Option { + return func(c *conf.Config) { + ts := make([]reflect.Type, len(types)) + for i, t := range types { + t := reflect.TypeOf(t) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Func { + panic(fmt.Sprintf("expr: type of %s is not a function", name)) + } + ts[i] = t + } + c.Functions[name] = &builtin.Function{ + Name: name, + Func: fn, + Types: ts, + } + } +} + +// DisableAllBuiltins disables all builtins. +func DisableAllBuiltins() Option { + return func(c *conf.Config) { + for name := range c.Builtins { + c.Disabled[name] = true + } + } +} + +// DisableBuiltin disables builtin function. +func DisableBuiltin(name string) Option { + return func(c *conf.Config) { + c.Disabled[name] = true + } +} + +// EnableBuiltin enables builtin function. +func EnableBuiltin(name string) Option { + return func(c *conf.Config) { + delete(c.Disabled, name) + } +} + +// WithContext passes context to all functions calls with a context.Context argument. +func WithContext(name string) Option { + return func(c *conf.Config) { + c.Visitors = append(c.Visitors, patcher.WithContext{ + Name: name, + Functions: c.Functions, + Env: &c.Env, + NtCache: &c.NtCache, + }) + } +} + +// Timezone sets default timezone for date() and now() builtin functions. +func Timezone(name string) Option { + tz, err := time.LoadLocation(name) + if err != nil { + panic(err) + } + return Patch(patcher.WithTimezone{ + Location: tz, + }) +} + +// MaxNodes sets the maximum number of nodes allowed in the expression. +// By default, the maximum number of nodes is conf.DefaultMaxNodes. +// If MaxNodes is set to 0, the node budget check is disabled. +func MaxNodes(n uint) Option { + return func(c *conf.Config) { + c.MaxNodes = n + } +} + +// Compile parses and compiles given input expression to bytecode program. +func Compile(input string, ops ...Option) (*vm.Program, error) { + config := conf.CreateNew() + for _, op := range ops { + op(config) + } + for name := range config.Disabled { + delete(config.Builtins, name) + } + config.Check() + + tree, err := checker.ParseCheck(input, config) + if err != nil { + return nil, err + } + + if config.Optimize { + err = optimizer.Optimize(&tree.Node, config) + if err != nil { + var fileError *file.Error + if errors.As(err, &fileError) { + return nil, fileError.Bind(tree.Source) + } + return nil, err + } + } + + program, err := compiler.Compile(tree, config) + if err != nil { + return nil, err + } + + return program, nil +} + +// Run evaluates given bytecode program. +func Run(program *vm.Program, env any) (any, error) { + return vm.Run(program, env) +} diff --git a/static/expr_test.go b/static/expr_test.go new file mode 100644 index 00000000..8be1cd37 --- /dev/null +++ b/static/expr_test.go @@ -0,0 +1,53 @@ +package expr_test + +import ( + "testing" + + "github.com/expr-lang/expr/static" +) + +type env struct { + X, Y int +} + +// Sum should not be callable. +func (e env) Sum() int { return e.X + e.Y } + +func TestCompileRun(t *testing.T) { + prog, err := expr.Compile(`X + Y`, expr.Env(env{})) + if err != nil { + t.Fatalf("compile: %v", err) + } + out, err := expr.Run(prog, env{X: 3, Y: 4}) + if err != nil { + t.Fatalf("run: %v", err) + } + if out != 7 { + t.Fatalf("X+Y = %v, want 7", out) + } +} + +func TestEnvMethodNotSurfaced(t *testing.T) { + _, err := expr.Compile(`Sum()`, expr.Env(env{})) + if err == nil { + t.Fatal("expected Compile to fail for env-defined method, got nil") + } +} + +func TestFunctionOption(t *testing.T) { + prog, err := expr.Compile(`Add(X, Y)`, + expr.Env(env{}), + expr.Function("Add", + func(p ...any) (any, error) { return p[0].(int) + p[1].(int), nil }, + new(func(int, int) int))) + if err != nil { + t.Fatalf("compile: %v", err) + } + out, err := expr.Run(prog, env{X: 10, Y: 20}) + if err != nil { + t.Fatalf("run: %v", err) + } + if out != 30 { + t.Fatalf("Add(X,Y) = %v, want 30", out) + } +} diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index bc6f2b4d..5fa1429f 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -18,6 +18,18 @@ type fieldCacheKey struct { f string } +// MethodByNameHook, when non-nil, is consulted by Fetch (and the get builtin) +// to resolve a method by name on a value at runtime. The +// github.com/expr-lang/expr/static package leaves this hook nil so the linker can +// perform full method dead-code elimination. +var MethodByNameHook func(v reflect.Value, name string) (any, bool) + +// MethodIndexedHook, when non-nil, is consulted by FetchMethod to dispatch a +// method by index on a value at runtime. The github.com/expr-lang/expr/static +// package leaves this hook nil so the linker can perform full method dead-code +// elimination. +var MethodIndexedHook func(v reflect.Value, index int) (any, bool) + func Fetch(from, i any) any { v := reflect.ValueOf(from) if v.Kind() == reflect.Invalid { @@ -25,11 +37,10 @@ func Fetch(from, i any) any { } // Methods can be defined on any type. - if v.NumMethod() > 0 { + if MethodByNameHook != nil && v.NumMethod() > 0 { if methodName, ok := i.(string); ok { - method := v.MethodByName(methodName) - if method.IsValid() { - return method.Interface() + if m, ok := MethodByNameHook(v, methodName); ok { + return m } } } @@ -149,13 +160,13 @@ type Method struct { } func FetchMethod(from any, method *Method) any { - v := reflect.ValueOf(from) - kind := v.Kind() - if kind != reflect.Invalid { - // Methods can be defined on any type, no need to dereference. - method := v.Method(method.Index) - if method.IsValid() { - return method.Interface() + if MethodIndexedHook != nil { + v := reflect.ValueOf(from) + if v.Kind() != reflect.Invalid { + // Methods can be defined on any type, no need to dereference. + if m, ok := MethodIndexedHook(v, method.Index); ok { + return m + } } } panic(fmt.Sprintf("cannot fetch %v from %T", method.Name, from))