diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90783a6ce..4b17fc6fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,7 @@ jobs: strategy: matrix: go-versions: [ '1.18', '1.19', '1.20', '1.21', '1.22', '1.23', '1.24', '1.25', '1.26' ] + tags: [ '', 'expr_noreflectmethod' ] steps: - uses: actions/checkout@v3 - name: Setup Go ${{ matrix.go-version }} @@ -19,7 +20,7 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: Test - run: go test ./... + run: go test -tags=${{ matrix.tags }} ./... debug: runs-on: ubuntu-latest diff --git a/builtin/builtin_test.go b/builtin/builtin_test.go index 0d0dec357..51543610e 100644 --- a/builtin/builtin_test.go +++ b/builtin/builtin_test.go @@ -19,6 +19,7 @@ import ( ) func TestBuiltin(t *testing.T) { + assert.SkipNoReflectMethod(t) ArrayWithNil := []any{42} env := map[string]any{ "ArrayOfString": []string{"foo", "bar", "baz"}, @@ -345,6 +346,7 @@ func TestBuiltin_types(t *testing.T) { } func TestBuiltin_memory_limits(t *testing.T) { + assert.SkipNoReflectMethod(t) tests := []struct { input string }{ @@ -696,6 +698,7 @@ func Test_int_unwraps_underlying_value(t *testing.T) { } func TestBuiltin_with_deref(t *testing.T) { + assert.SkipNoReflectMethod(t) x := 42 arr := []int{1, 2, 3} arrStr := []string{"1", "2", "3"} diff --git a/builtin/lib.go b/builtin/lib.go index 61748da08..50cb72852 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -562,9 +562,8 @@ func get(params ...any) (out any, err error) { // Methods can be defined on any type. if v.NumMethod() > 0 { if methodName, ok := i.(string); ok { - method := v.MethodByName(methodName) - if method.IsValid() { - return method.Interface(), nil + if m, ok := runtime.MethodByName(v, methodName); ok { + return m, nil } } } diff --git a/checker/checker_test.go b/checker/checker_test.go index 7a581612a..8b9014671 100644 --- a/checker/checker_test.go +++ b/checker/checker_test.go @@ -21,6 +21,7 @@ import ( ) func TestCheck(t *testing.T) { + assert.SkipNoReflectMethod(t) var tests = []struct { input string }{ @@ -155,6 +156,7 @@ func TestCheck(t *testing.T) { } func TestCheck_error(t *testing.T) { + assert.SkipNoReflectMethod(t) errorTests := []struct{ code, err string }{ { `Foo.Bar.Not`, diff --git a/checker/info.go b/checker/info.go index 57202e958..991be2167 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 { + return true, m.MethodIndex, name.Value } } } diff --git a/checker/nature/nature.go b/checker/nature/nature.go index c96f28c43..81b21525b 100644 --- a/checker/nature/nature.go +++ b/checker/nature/nature.go @@ -294,15 +294,6 @@ func (n *Nature) NumMethods(c *Cache) int { return 0 } -func (n *Nature) MethodByName(c *Cache, name string) (Nature, bool) { - if s := n.getMethodset(c); s != nil { - if m := s.method(c, name); m != nil { - return m.nature, true - } - } - return Nature{}, false -} - func (n *Nature) NumIn() int { if n.numInSet { return n.numIn diff --git a/checker/nature/no_reflectmethod.go b/checker/nature/no_reflectmethod.go new file mode 100644 index 000000000..3ffa3d624 --- /dev/null +++ b/checker/nature/no_reflectmethod.go @@ -0,0 +1,11 @@ +//go:build expr_noreflectmethod + +package nature + +// MethodByName is a no-op stub used when building with the +// expr_noreflectmethod tag. It avoids reaching reflect.Type.Method via the +// methodset cache so the Go linker can perform full method dead-code +// elimination on user types. +func (n *Nature) MethodByName(c *Cache, name string) (Nature, bool) { + return Nature{}, false +} diff --git a/checker/nature/reflectmethod.go b/checker/nature/reflectmethod.go new file mode 100644 index 000000000..858323108 --- /dev/null +++ b/checker/nature/reflectmethod.go @@ -0,0 +1,14 @@ +//go:build !expr_noreflectmethod + +package nature + +// MethodByName looks up a method on a Nature by name. It transitively reaches +// reflect.Type.Method via the methodset cache. +func (n *Nature) MethodByName(c *Cache, name string) (Nature, bool) { + if s := n.getMethodset(c); s != nil { + if m := s.method(c, name); m != nil { + return m.nature, true + } + } + return Nature{}, false +} diff --git a/compiler/compiler.go b/compiler/compiler.go index 685175350..8a48067d3 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/compiler/compiler_test.go b/compiler/compiler_test.go index 6efce686f..078e248d0 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -49,6 +49,7 @@ func (e Env) Func() B { } func TestCompile(t *testing.T) { + assert.SkipNoReflectMethod(t) var tests = []struct { code string want vm.Program @@ -436,6 +437,7 @@ func TestCompile_FuncTypes(t *testing.T) { } func TestCompile_FuncTypes_with_Method(t *testing.T) { + assert.SkipNoReflectMethod(t) env := mock.Env{} program, err := expr.Compile("FuncTyped('bar')", expr.Env(env)) require.NoError(t, err) @@ -647,6 +649,7 @@ func TestCompile_optimizes_jumps(t *testing.T) { } func TestCompile_IntegerArgsFunc(t *testing.T) { + assert.SkipNoReflectMethod(t) env := mock.Env{} tests := []struct{ code string }{ {"FuncInt(0)"}, diff --git a/expr.go b/expr.go index 76fbd426f..d359985d9 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 built with the expr_noreflectmethod build tag. 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/expr_no_reflectmethod.go b/expr_no_reflectmethod.go new file mode 100644 index 000000000..cd86eee7e --- /dev/null +++ b/expr_no_reflectmethod.go @@ -0,0 +1,10 @@ +//go:build expr_noreflectmethod + +package expr + +// Eval is a panic-only stub used when building with the expr_noreflectmethod +// tag. The real Eval relies on runtime dispatch on the env, which requires +// reflect-based method resolution. Use Compile + Run instead. +func Eval(input string, env any) (any, error) { + panic("expr.Eval is not available with the expr_noreflectmethod build tag") +} diff --git a/expr_reflectmethod.go b/expr_reflectmethod.go new file mode 100644 index 000000000..d911a4bf8 --- /dev/null +++ b/expr_reflectmethod.go @@ -0,0 +1,38 @@ +//go:build !expr_noreflectmethod + +package expr + +import ( + "fmt" + + "github.com/expr-lang/expr/compiler" + "github.com/expr-lang/expr/parser" +) + +// Eval parses, compiles and runs given input. +// +// Eval is excluded from the build under the expr_noreflectmethod tag because +// it relies on runtime dispatch on the env, which requires reflect-based +// method resolution. +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/expr_reflectmethod_examples_test.go b/expr_reflectmethod_examples_test.go new file mode 100644 index 000000000..ee3d6f53b --- /dev/null +++ b/expr_reflectmethod_examples_test.go @@ -0,0 +1,237 @@ +//go:build !expr_noreflectmethod + +package expr_test + +import ( + "fmt" + "strings" + "time" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/test/mock" +) + +func ExampleEval() { + output, err := expr.Eval("greet + name", map[string]any{ + "greet": "Hello, ", + "name": "world!", + }) + if err != nil { + fmt.Printf("err: %v", err) + return + } + + fmt.Printf("%v", output) + + // Output: Hello, world! +} + +func ExampleEval_runtime_error() { + _, err := expr.Eval(`map(1..3, {1 % (# - 3)})`, nil) + fmt.Print(err) + + // Output: runtime error: integer divide by zero (1:14) + // | map(1..3, {1 % (# - 3)}) + // | .............^ +} + +func ExampleEval_bytes_literal() { + // Bytes literal returns []byte. + output, err := expr.Eval(`b"abc"`, nil) + if err != nil { + fmt.Printf("%v", err) + return + } + + fmt.Printf("%v", output) + + // Output: [97 98 99] +} + +func ExampleEnv_tagged_field_names() { + env := struct { + FirstWord string + Separator string `expr:"Space"` + SecondWord string `expr:"second_word"` + }{ + FirstWord: "Hello", + Separator: " ", + SecondWord: "World", + } + + output, err := expr.Eval(`FirstWord + Space + second_word`, env) + if err != nil { + fmt.Printf("%v", err) + return + } + + fmt.Printf("%v", output) + + // Output: Hello World +} + +func ExampleEnv_hidden_tagged_field_names() { + type Internal struct { + Visible string + Hidden string `expr:"-"` + } + type environment struct { + Visible string + Hidden string `expr:"-"` + HiddenInternal Internal `expr:"-"` + VisibleInternal Internal + } + + env := environment{ + Hidden: "First level secret", + HiddenInternal: Internal{ + Visible: "Second level secret", + Hidden: "Also hidden", + }, + VisibleInternal: Internal{ + Visible: "Not a secret", + Hidden: "Hidden too", + }, + } + + hiddenValues := []string{ + `Hidden`, + `HiddenInternal`, + `HiddenInternal.Visible`, + `HiddenInternal.Hidden`, + `VisibleInternal["Hidden"]`, + } + for _, expression := range hiddenValues { + output, err := expr.Eval(expression, env) + if err == nil || !strings.Contains(err.Error(), "cannot fetch") { + fmt.Printf("unexpected output: %v; err: %v\n", output, err) + return + } + fmt.Printf("%q is hidden as expected\n", expression) + } + + visibleValues := []string{ + `Visible`, + `VisibleInternal`, + `VisibleInternal["Visible"]`, + } + for _, expression := range visibleValues { + _, err := expr.Eval(expression, env) + if err != nil { + fmt.Printf("unexpected error: %v\n", err) + return + } + fmt.Printf("%q is visible as expected\n", expression) + } + + testWithIn := []string{ + `not ("Hidden" in $env)`, + `"Visible" in $env`, + `not ("Hidden" in VisibleInternal)`, + `"Visible" in VisibleInternal`, + } + for _, expression := range testWithIn { + val, err := expr.Eval(expression, env) + shouldBeTrue, ok := val.(bool) + if err != nil || !ok || !shouldBeTrue { + fmt.Printf("unexpected result; value: %v; error: %v\n", val, err) + return + } + } + + // Output: "Hidden" is hidden as expected + // "HiddenInternal" is hidden as expected + // "HiddenInternal.Visible" is hidden as expected + // "HiddenInternal.Hidden" is hidden as expected + // "VisibleInternal[\"Hidden\"]" is hidden as expected + // "Visible" is visible as expected + // "VisibleInternal" is visible as expected + // "VisibleInternal[\"Visible\"]" is visible as expected +} + +func ExampleOperator() { + code := ` + Now() > CreatedAt && + (Now() - CreatedAt).Hours() > 24 + ` + + type Env struct { + CreatedAt time.Time + Now func() time.Time + Sub func(a, b time.Time) time.Duration + After func(a, b time.Time) bool + } + + options := []expr.Option{ + expr.Env(Env{}), + expr.Operator(">", "After"), + expr.Operator("-", "Sub"), + } + + program, err := expr.Compile(code, options...) + if err != nil { + fmt.Printf("%v", err) + return + } + + env := Env{ + CreatedAt: time.Date(2018, 7, 14, 0, 0, 0, 0, time.UTC), + Now: func() time.Time { return time.Now() }, + Sub: func(a, b time.Time) time.Duration { return a.Sub(b) }, + After: func(a, b time.Time) bool { return a.After(b) }, + } + + output, err := expr.Run(program, env) + if err != nil { + fmt.Printf("%v", err) + return + } + + fmt.Printf("%v", output) + + // Output: true +} + +func ExampleAllowUndefinedVariables_zero_value_functions() { + code := `words == "" ? Split("foo,bar", ",") : Split(words, ",")` + + // Env is map[string]string type on which methods are defined. + env := mock.MapStringStringEnv{} + + options := []expr.Option{ + expr.Env(env), + expr.AllowUndefinedVariables(), // Allow to use undefined variables. + } + + program, err := expr.Compile(code, options...) + if err != nil { + fmt.Printf("%v", err) + return + } + + output, err := expr.Run(program, env) + if err != nil { + fmt.Printf("%v", err) + return + } + fmt.Printf("%v", output) + + // Output: [foo bar] +} + +func ExampleTimezone() { + program, err := expr.Compile(`now().Location().String()`, expr.Timezone("Asia/Kamchatka")) + if err != nil { + fmt.Printf("%v", err) + return + } + + output, err := expr.Run(program, nil) + if err != nil { + fmt.Printf("%v", err) + return + } + + fmt.Printf("%v", output) + // Output: Asia/Kamchatka +} diff --git a/expr_test.go b/expr_test.go index fd1ce3ab7..84ace2ab4 100644 --- a/expr_test.go +++ b/expr_test.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "reflect" - "strings" "sync" "testing" "time" @@ -23,30 +22,6 @@ import ( "github.com/expr-lang/expr/test/mock" ) -func ExampleEval() { - output, err := expr.Eval("greet + name", map[string]any{ - "greet": "Hello, ", - "name": "world!", - }) - if err != nil { - fmt.Printf("err: %v", err) - return - } - - fmt.Printf("%v", output) - - // Output: Hello, world! -} - -func ExampleEval_runtime_error() { - _, err := expr.Eval(`map(1..3, {1 % (# - 3)})`, nil) - fmt.Print(err) - - // Output: runtime error: integer divide by zero (1:14) - // | map(1..3, {1 % (# - 3)}) - // | .............^ -} - func ExampleCompile() { env := map[string]any{ "foo": 1, @@ -70,20 +45,8 @@ func ExampleCompile() { // Output: true } -func ExampleEval_bytes_literal() { - // Bytes literal returns []byte. - output, err := expr.Eval(`b"abc"`, nil) - if err != nil { - fmt.Printf("%v", err) - return - } - - fmt.Printf("%v", output) - - // Output: [97 98 99] -} - func TestDisableIfOperator_AllowsIfFunction(t *testing.T) { + assert.SkipNoReflectMethod(t) env := map[string]any{ "if": func(x int) int { return x + 1 }, } @@ -145,107 +108,6 @@ func ExampleEnv() { // Output: true } -func ExampleEnv_tagged_field_names() { - env := struct { - FirstWord string - Separator string `expr:"Space"` - SecondWord string `expr:"second_word"` - }{ - FirstWord: "Hello", - Separator: " ", - SecondWord: "World", - } - - output, err := expr.Eval(`FirstWord + Space + second_word`, env) - if err != nil { - fmt.Printf("%v", err) - return - } - - fmt.Printf("%v", output) - - // Output: Hello World -} - -func ExampleEnv_hidden_tagged_field_names() { - type Internal struct { - Visible string - Hidden string `expr:"-"` - } - type environment struct { - Visible string - Hidden string `expr:"-"` - HiddenInternal Internal `expr:"-"` - VisibleInternal Internal - } - - env := environment{ - Hidden: "First level secret", - HiddenInternal: Internal{ - Visible: "Second level secret", - Hidden: "Also hidden", - }, - VisibleInternal: Internal{ - Visible: "Not a secret", - Hidden: "Hidden too", - }, - } - - hiddenValues := []string{ - `Hidden`, - `HiddenInternal`, - `HiddenInternal.Visible`, - `HiddenInternal.Hidden`, - `VisibleInternal["Hidden"]`, - } - for _, expression := range hiddenValues { - output, err := expr.Eval(expression, env) - if err == nil || !strings.Contains(err.Error(), "cannot fetch") { - fmt.Printf("unexpected output: %v; err: %v\n", output, err) - return - } - fmt.Printf("%q is hidden as expected\n", expression) - } - - visibleValues := []string{ - `Visible`, - `VisibleInternal`, - `VisibleInternal["Visible"]`, - } - for _, expression := range visibleValues { - _, err := expr.Eval(expression, env) - if err != nil { - fmt.Printf("unexpected error: %v\n", err) - return - } - fmt.Printf("%q is visible as expected\n", expression) - } - - testWithIn := []string{ - `not ("Hidden" in $env)`, - `"Visible" in $env`, - `not ("Hidden" in VisibleInternal)`, - `"Visible" in VisibleInternal`, - } - for _, expression := range testWithIn { - val, err := expr.Eval(expression, env) - shouldBeTrue, ok := val.(bool) - if err != nil || !ok || !shouldBeTrue { - fmt.Printf("unexpected result; value: %v; error: %v\n", val, err) - return - } - } - - // Output: "Hidden" is hidden as expected - // "HiddenInternal" is hidden as expected - // "HiddenInternal.Visible" is hidden as expected - // "HiddenInternal.Hidden" is hidden as expected - // "VisibleInternal[\"Hidden\"]" is hidden as expected - // "Visible" is visible as expected - // "VisibleInternal" is visible as expected - // "VisibleInternal[\"Visible\"]" is visible as expected -} - func ExampleAsKind() { program, err := expr.Compile("{a: 1, b: 2}", expr.AsKind(reflect.Map)) if err != nil { @@ -375,49 +237,6 @@ func ExampleWarnOnAny() { // Output: expected int, but got interface {} } -func ExampleOperator() { - code := ` - Now() > CreatedAt && - (Now() - CreatedAt).Hours() > 24 - ` - - type Env struct { - CreatedAt time.Time - Now func() time.Time - Sub func(a, b time.Time) time.Duration - After func(a, b time.Time) bool - } - - options := []expr.Option{ - expr.Env(Env{}), - expr.Operator(">", "After"), - expr.Operator("-", "Sub"), - } - - program, err := expr.Compile(code, options...) - if err != nil { - fmt.Printf("%v", err) - return - } - - env := Env{ - CreatedAt: time.Date(2018, 7, 14, 0, 0, 0, 0, time.UTC), - Now: func() time.Time { return time.Now() }, - Sub: func(a, b time.Time) time.Duration { return a.Sub(b) }, - After: func(a, b time.Time) bool { return a.After(b) }, - } - - output, err := expr.Run(program, env) - if err != nil { - fmt.Printf("%v", err) - return - } - - fmt.Printf("%v", output) - - // Output: true -} - func ExampleOperator_with_decimal() { type Decimal struct{ N float64 } code := `A + B - C` @@ -570,33 +389,6 @@ func ExampleAllowUndefinedVariables_zero_value() { // Output: Hello, world! } -func ExampleAllowUndefinedVariables_zero_value_functions() { - code := `words == "" ? Split("foo,bar", ",") : Split(words, ",")` - - // Env is map[string]string type on which methods are defined. - env := mock.MapStringStringEnv{} - - options := []expr.Option{ - expr.Env(env), - expr.AllowUndefinedVariables(), // Allow to use undefined variables. - } - - program, err := expr.Compile(code, options...) - if err != nil { - fmt.Printf("%v", err) - return - } - - output, err := expr.Run(program, env) - if err != nil { - fmt.Printf("%v", err) - return - } - fmt.Printf("%v", output) - - // Output: [foo bar] -} - type patcher struct{} func (p *patcher) Visit(node *ast.Node) { @@ -677,23 +469,6 @@ func ExampleWithContext() { // Output: 42 } -func ExampleTimezone() { - program, err := expr.Compile(`now().Location().String()`, expr.Timezone("Asia/Kamchatka")) - if err != nil { - fmt.Printf("%v", err) - return - } - - output, err := expr.Run(program, nil) - if err != nil { - fmt.Printf("%v", err) - return - } - - fmt.Printf("%v", output) - // Output: Asia/Kamchatka -} - func TestExpr_readme_example(t *testing.T) { env := map[string]any{ "greet": "Hello, %v!", @@ -713,6 +488,7 @@ func TestExpr_readme_example(t *testing.T) { } func TestExpr(t *testing.T) { + assert.SkipNoReflectMethod(t) date := time.Date(2017, time.October, 23, 18, 30, 0, 0, time.UTC) oneDay, _ := time.ParseDuration("24h") timeNowPlusOneDay := date.Add(oneDay) @@ -1573,12 +1349,14 @@ func TestExpr_optional_chaining_array(t *testing.T) { } func TestExpr_eval_with_env(t *testing.T) { + assert.SkipNoReflectMethod(t) _, err := expr.Eval("true", expr.Env(map[string]any{})) assert.Error(t, err) assert.Contains(t, err.Error(), "misused") } func TestExpr_fetch_from_func(t *testing.T) { + assert.SkipNoReflectMethod(t) _, err := expr.Eval("foo.Value", map[string]any{ "foo": func() {}, }) @@ -1623,6 +1401,7 @@ func TestExpr_map_default_values_compile_check(t *testing.T) { } func TestExpr_calls_with_nil(t *testing.T) { + assert.SkipNoReflectMethod(t) env := map[string]any{ "equals": func(a, b any) any { assert.Nil(t, a, "a is not nil") @@ -1757,6 +1536,7 @@ func (p *stringerPatcher) Visit(node *ast.Node) { } func TestPatch(t *testing.T) { + assert.SkipNoReflectMethod(t) program, err := expr.Compile( `Foo == "Foo.String"`, expr.Env(mock.Env{}), @@ -1797,6 +1577,7 @@ func TestAsBool_exposed_error(t *testing.T) { } func TestEval_exposed_error(t *testing.T) { + assert.SkipNoReflectMethod(t) _, err := expr.Eval(`1 % 0`, nil) require.Error(t, err) @@ -2213,6 +1994,7 @@ func TestEval_nil_in_maps(t *testing.T) { // Test the use of env keyword. Forms env[] and env[”] are valid. // The enclosed identifier must be in the expression env. func TestEnv_keyword(t *testing.T) { + assert.SkipNoReflectMethod(t) env := map[string]any{ "space test": "ok", "space_test": "not ok", // Seems to be some underscore substituting happening, check that. @@ -2349,6 +2131,7 @@ func TestIssue401(t *testing.T) { } func TestEval_slices_out_of_bound(t *testing.T) { + assert.SkipNoReflectMethod(t) tests := []struct { code string want any @@ -2445,6 +2228,7 @@ func TestIssue462(t *testing.T) { } func TestIssue_embedded_pointer_struct(t *testing.T) { + assert.SkipNoReflectMethod(t) var tests = []struct { input string env mock.Env @@ -2605,6 +2389,7 @@ func TestOperatorDependsOnEnv(t *testing.T) { } func TestIssue624(t *testing.T) { + assert.SkipNoReflectMethod(t) type tag struct { Name string } @@ -2632,6 +2417,7 @@ one(Tags, .Name in ["one"]) && one(Tags, .Name in ["two"]) } func TestPredicateCombination(t *testing.T) { + assert.SkipNoReflectMethod(t) tests := []struct { code1 string code2 string @@ -2805,6 +2591,7 @@ func TestExpr_env_types_map_error(t *testing.T) { } func TestIssue758_filter_map_index(t *testing.T) { + assert.SkipNoReflectMethod(t) env := map[string]interface{}{} exprStr := ` @@ -2834,6 +2621,7 @@ func TestExpr_wierd_cases(t *testing.T) { } func TestIssue785_get_nil(t *testing.T) { + assert.SkipNoReflectMethod(t) exprStrs := []string{ `get(nil, "a")`, `get({}, "a")`, @@ -2924,6 +2712,7 @@ func TestIssue802(t *testing.T) { } func TestIssue807(t *testing.T) { + assert.SkipNoReflectMethod(t) type MyStruct struct { nonExported string } diff --git a/internal/testify/assert/no_reflectmethod.go b/internal/testify/assert/no_reflectmethod.go new file mode 100644 index 000000000..3d45a7c5a --- /dev/null +++ b/internal/testify/assert/no_reflectmethod.go @@ -0,0 +1,12 @@ +//go:build expr_noreflectmethod + +package assert + +import "testing" + +// SkipNoReflectMethod skips the current test under the expr_noreflectmethod +// build tag, where reflect-based env method dispatch is unavailable. +func SkipNoReflectMethod(t *testing.T) { + t.Helper() + t.Skip("requires reflect-based method dispatch") +} diff --git a/internal/testify/assert/reflectmethod.go b/internal/testify/assert/reflectmethod.go new file mode 100644 index 000000000..9d502a289 --- /dev/null +++ b/internal/testify/assert/reflectmethod.go @@ -0,0 +1,9 @@ +//go:build !expr_noreflectmethod + +package assert + +import "testing" + +// SkipNoReflectMethod is a no-op in builds without the expr_noreflectmethod +// tag. +func SkipNoReflectMethod(t *testing.T) {} diff --git a/optimizer/optimizer_test.go b/optimizer/optimizer_test.go index 03458689f..2a59d250b 100644 --- a/optimizer/optimizer_test.go +++ b/optimizer/optimizer_test.go @@ -147,6 +147,7 @@ func TestOptimize_in_range(t *testing.T) { } func TestOptimize_in_range_with_floats(t *testing.T) { + assert.SkipNoReflectMethod(t) out, err := expr.Eval(`f in 1..3`, map[string]any{"f": 1.5}) require.NoError(t, err) assert.Equal(t, false, out) diff --git a/patcher/with_context_test.go b/patcher/with_context_test.go index 278705334..3d7004a6c 100644 --- a/patcher/with_context_test.go +++ b/patcher/with_context_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/expr-lang/expr/internal/testify/assert" "github.com/expr-lang/expr/internal/testify/require" "github.com/expr-lang/expr" @@ -71,6 +72,7 @@ func (testEnvContext) Fn(ctx context.Context, a int) int { } func TestWithContext_env_struct(t *testing.T) { + assert.SkipNoReflectMethod(t) withContext := patcher.WithContext{Name: "ctx"} program, err := expr.Compile(`Fn(40)`, expr.Env(testEnvContext{}), expr.Patch(withContext)) @@ -95,6 +97,7 @@ func (f *TestFoo) GetValue(a int) int64 { } func TestWithContext_with_env_method_chain(t *testing.T) { + assert.SkipNoReflectMethod(t) env := map[string]any{ "ctx": context.TODO(), } diff --git a/test/crowdsec/crowdsec_test.go b/test/crowdsec/crowdsec_test.go index 0ddb54102..3d66b8a57 100644 --- a/test/crowdsec/crowdsec_test.go +++ b/test/crowdsec/crowdsec_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package crowdsec_test import ( diff --git a/test/deref/deref_test.go b/test/deref/deref_test.go index 8df682faf..91e7c68a8 100644 --- a/test/deref/deref_test.go +++ b/test/deref/deref_test.go @@ -64,6 +64,7 @@ func TestDeref_unary(t *testing.T) { } func TestDeref_eval(t *testing.T) { + assert.SkipNoReflectMethod(t) i := 1 env := map[string]any{ "i": &i, @@ -88,6 +89,7 @@ func TestDeref_emptyCtx(t *testing.T) { } func TestDeref_emptyCtx_Eval(t *testing.T) { + assert.SkipNoReflectMethod(t) output, err := expr.Eval(`ctx`, map[string]any{ "ctx": context.Background(), }) @@ -107,6 +109,7 @@ func TestDeref_context_WithValue(t *testing.T) { } func TestDeref_method_on_int_pointer(t *testing.T) { + assert.SkipNoReflectMethod(t) output, err := expr.Eval(`foo.Bar()`, map[string]any{ "foo": new(foo), }) @@ -121,6 +124,7 @@ func (f *foo) Bar() int { } func TestDeref_multiple_pointers(t *testing.T) { + assert.SkipNoReflectMethod(t) a := 42 b := &a c := &b @@ -142,6 +146,7 @@ func TestDeref_multiple_pointers(t *testing.T) { } func TestDeref_pointer_of_interface(t *testing.T) { + assert.SkipNoReflectMethod(t) v := 42 a := &v b := any(a) @@ -164,6 +169,7 @@ func TestDeref_pointer_of_interface(t *testing.T) { } func TestDeref_nil(t *testing.T) { + assert.SkipNoReflectMethod(t) var b *int = nil c := &b t.Run("returned as is", func(t *testing.T) { @@ -184,6 +190,7 @@ func TestDeref_nil(t *testing.T) { } func TestDeref_nil_in_pointer_of_interface(t *testing.T) { + assert.SkipNoReflectMethod(t) var a *int32 = nil b := any(a) c := any(&b) @@ -241,6 +248,7 @@ func TestDeref_commutative(t *testing.T) { } func TestDeref_fetch_from_interface_mix_pointer(t *testing.T) { + assert.SkipNoReflectMethod(t) type FooBar struct { Value string } @@ -273,6 +281,7 @@ func TestDeref_func_args(t *testing.T) { } func TestDeref_struct_func_args(t *testing.T) { + assert.SkipNoReflectMethod(t) n, _ := time.Parse(time.RFC3339, "2024-05-12T18:30:00+00:00") duration := 30 * time.Minute env := map[string]any{ @@ -306,6 +315,7 @@ func TestDeref_ignore_func_args(t *testing.T) { } func TestDeref_ignore_struct_func_args(t *testing.T) { + assert.SkipNoReflectMethod(t) n := time.Now() location, _ := time.LoadLocation("UTC") env := map[string]any{ diff --git a/test/examples/examples_test.go b/test/examples/examples_test.go index 86de35d3f..d06b11214 100644 --- a/test/examples/examples_test.go +++ b/test/examples/examples_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package main import ( diff --git a/test/gen/gen_test.go b/test/gen/gen_test.go index 4050fc6e9..9bbe6cb5b 100644 --- a/test/gen/gen_test.go +++ b/test/gen/gen_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package main import ( diff --git a/test/interface/interface_method_test.go b/test/interface/interface_method_test.go index a27215035..95da2187d 100644 --- a/test/interface/interface_method_test.go +++ b/test/interface/interface_method_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package interface_test import ( diff --git a/test/interface/interface_test.go b/test/interface/interface_test.go index 45ce1c427..55417cc8d 100644 --- a/test/interface/interface_test.go +++ b/test/interface/interface_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package interface_test import ( diff --git a/test/issues/688/issue_test.go b/test/issues/688/issue_test.go index 837978c13..2a41f6d8d 100644 --- a/test/issues/688/issue_test.go +++ b/test/issues/688/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issue_test import ( diff --git a/test/issues/730/issue_test.go b/test/issues/730/issue_test.go index 864421bc0..7f3441d6e 100644 --- a/test/issues/730/issue_test.go +++ b/test/issues/730/issue_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/assert" "github.com/expr-lang/expr/internal/testify/require" ) @@ -43,6 +44,7 @@ func TestIssue730_warn_about_different_types(t *testing.T) { } func TestIssue730_eval(t *testing.T) { + assert.SkipNoReflectMethod(t) code := `Mode == 1` tmp := ModeEnumA diff --git a/test/issues/739/issue_test.go b/test/issues/739/issue_test.go index c58633fe8..341ed47b7 100644 --- a/test/issues/739/issue_test.go +++ b/test/issues/739/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issue_test import ( diff --git a/test/issues/756/issue_test.go b/test/issues/756/issue_test.go index 93180a27a..6c6bc8989 100644 --- a/test/issues/756/issue_test.go +++ b/test/issues/756/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issue_test import ( diff --git a/test/issues/817/issue_test.go b/test/issues/817/issue_test.go index f397f9d1b..be5817172 100644 --- a/test/issues/817/issue_test.go +++ b/test/issues/817/issue_test.go @@ -5,10 +5,12 @@ import ( "testing" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/assert" "github.com/expr-lang/expr/internal/testify/require" ) func TestIssue817_1(t *testing.T) { + assert.SkipNoReflectMethod(t) out, err := expr.Eval( `sprintf("result: %v %v", 1, nil)`, map[string]any{ @@ -20,6 +22,7 @@ func TestIssue817_1(t *testing.T) { } func TestIssue817_2(t *testing.T) { + assert.SkipNoReflectMethod(t) out, err := expr.Eval( `thing(nil)`, map[string]any{ diff --git a/test/issues/823/issue_test.go b/test/issues/823/issue_test.go index 221267de4..dcfab0b69 100644 --- a/test/issues/823/issue_test.go +++ b/test/issues/823/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issue_test import ( diff --git a/test/issues/844/issue_test.go b/test/issues/844/issue_test.go index 2993ec82f..cbf64a2ce 100644 --- a/test/issues/844/issue_test.go +++ b/test/issues/844/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package main import ( diff --git a/test/issues/888/issue_test.go b/test/issues/888/issue_test.go index 175e34f1e..03bb71d7e 100644 --- a/test/issues/888/issue_test.go +++ b/test/issues/888/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package main import ( diff --git a/test/issues/934/issue_test.go b/test/issues/934/issue_test.go index 15ac26276..babb8484a 100644 --- a/test/issues/934/issue_test.go +++ b/test/issues/934/issue_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issue934 import ( diff --git a/test/operator/issues584/issues584_test.go b/test/operator/issues584/issues584_test.go index 5ceb2d50c..68d0cc10b 100644 --- a/test/operator/issues584/issues584_test.go +++ b/test/operator/issues584/issues584_test.go @@ -1,3 +1,5 @@ +//go:build !expr_noreflectmethod + package issues584_test import ( diff --git a/test/operator/operator_test.go b/test/operator/operator_test.go index ec7da8724..b22a1702e 100644 --- a/test/operator/operator_test.go +++ b/test/operator/operator_test.go @@ -13,6 +13,7 @@ import ( ) func TestOperator_struct(t *testing.T) { + assert.SkipNoReflectMethod(t) env := mock.Env{ Time: time.Date(2017, time.October, 23, 18, 30, 0, 0, time.UTC), } @@ -35,6 +36,7 @@ func TestOperator_no_env(t *testing.T) { } func TestOperator_interface(t *testing.T) { + assert.SkipNoReflectMethod(t) env := mock.Env{} code := `Foo == "Foo.String" && "Foo.String" == Foo && Time != Foo && Time == Time` diff --git a/vm/runtime/no_reflectmethod.go b/vm/runtime/no_reflectmethod.go new file mode 100644 index 000000000..983c62c1c --- /dev/null +++ b/vm/runtime/no_reflectmethod.go @@ -0,0 +1,19 @@ +//go:build expr_noreflectmethod + +package runtime + +import "reflect" + +// MethodByName is a no-op stub used when building with the +// expr_noreflectmethod tag. It avoids reaching reflect.Value.MethodByName so +// the Go linker can perform full method dead-code elimination on user types. +func MethodByName(v reflect.Value, name string) (any, bool) { + return nil, false +} + +// MethodByIndex is a no-op stub used when building with the +// expr_noreflectmethod tag. It avoids reaching reflect.Value.Method so the Go +// linker can perform full method dead-code elimination on user types. +func MethodByIndex(v reflect.Value, index int) (any, bool) { + return nil, false +} diff --git a/vm/runtime/reflectmethod.go b/vm/runtime/reflectmethod.go new file mode 100644 index 000000000..f7364a649 --- /dev/null +++ b/vm/runtime/reflectmethod.go @@ -0,0 +1,23 @@ +//go:build !expr_noreflectmethod + +package runtime + +import "reflect" + +// MethodByName resolves a method by name on v at runtime via reflection. +func MethodByName(v reflect.Value, name string) (any, bool) { + method := v.MethodByName(name) + if method.IsValid() { + return method.Interface(), true + } + return nil, false +} + +// MethodByIndex dispatches a method by index on v at runtime via reflection. +func MethodByIndex(v reflect.Value, index int) (any, bool) { + method := v.Method(index) + if method.IsValid() { + return method.Interface(), true + } + return nil, false +} diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index bc6f2b4df..cf1737278 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -27,9 +27,8 @@ func Fetch(from, i any) any { // Methods can be defined on any type. if v.NumMethod() > 0 { if methodName, ok := i.(string); ok { - method := v.MethodByName(methodName) - if method.IsValid() { - return method.Interface() + if m, ok := MethodByName(v, methodName); ok { + return m } } } @@ -150,12 +149,10 @@ type Method struct { func FetchMethod(from any, method *Method) any { v := reflect.ValueOf(from) - kind := v.Kind() - if kind != reflect.Invalid { + if v.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 m, ok := MethodByIndex(v, method.Index); ok { + return m } } panic(fmt.Sprintf("cannot fetch %v from %T", method.Name, from)) diff --git a/vm/vm_test.go b/vm/vm_test.go index c86183cad..10bbad51e 100644 --- a/vm/vm_test.go +++ b/vm/vm_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/expr-lang/expr/file" + "github.com/expr-lang/expr/internal/testify/assert" "github.com/expr-lang/expr/internal/testify/require" "github.com/expr-lang/expr" @@ -190,6 +191,7 @@ func (InnerEnv) WillError(param string) (bool, error) { } func TestRun_MethodWithError(t *testing.T) { + assert.SkipNoReflectMethod(t) input := `WillError("yes")` tree, err := parser.Parse(input) @@ -236,6 +238,7 @@ func TestRun_FastMethods(t *testing.T) { } func TestRun_InnerMethodWithError(t *testing.T) { + assert.SkipNoReflectMethod(t) input := `InnerEnv.WillError("yes")` tree, err := parser.Parse(input) @@ -252,6 +255,7 @@ func TestRun_InnerMethodWithError(t *testing.T) { } func TestRun_InnerMethodWithError_NilSafe(t *testing.T) { + assert.SkipNoReflectMethod(t) input := `InnerEnv?.WillError("yes")` tree, err := parser.Parse(input)