| // Copyright 2026 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package api | |
| import ( | |
| "errors" | |
| "go/ast" | |
| "go/parser" | |
| "net/http" | |
| "reflect" | |
| "strings" | |
| "testing" | |
| ) | |
| func parseExpr(t *testing.T, s string) ast.Expr { | |
| t.Helper() | |
| expr, err := parser.ParseExpr(s) | |
| if err != nil { | |
| t.Fatalf("ParseExpr(%q) failed: %v", s, err) | |
| } | |
| return expr | |
| } | |
| func TestEvalBase(t *testing.T) { | |
| env := map[string]any{ | |
| "x": 10, | |
| "s": "hello", | |
| "identity": func(v any) any { | |
| return v | |
| }, | |
| "add1": func(v int) int { | |
| return v + 1 | |
| }, | |
| "greet": func(name string) (string, error) { | |
| if name == "" { | |
| return "", errors.New("empty name") | |
| } | |
| return "Hello " + name, nil | |
| }, | |
| "panicFunc": func() { | |
| panic("intentional panic") | |
| }, | |
| } | |
| tests := []struct { | |
| expr string | |
| want any | |
| wantErr bool | |
| }{ | |
| // Literals | |
| {expr: `1`, want: 1}, | |
| {expr: `"hello"`, want: "hello"}, | |
| {expr: `true`, want: true}, | |
| {expr: `false`, want: false}, | |
| {expr: `nil`, want: nil}, | |
| // Variables | |
| {expr: `x`, want: 10}, | |
| {expr: `s`, want: "hello"}, | |
| {expr: `y`, wantErr: true}, // undefined | |
| // Arithmetic operators | |
| {expr: `1 + 2`, want: 3}, | |
| {expr: `5 - 3`, want: 2}, | |
| {expr: `2 * 3`, want: 6}, | |
| {expr: `6 / 2`, want: 3}, | |
| {expr: `5 % 2`, want: 1}, | |
| {expr: `x + 5`, want: 15}, | |
| {expr: `-5`, want: -5}, | |
| {expr: `-x`, want: -10}, | |
| // String concatenation | |
| {expr: `s + " world"`, want: "hello world"}, | |
| {expr: `"a" + "b"`, want: "ab"}, | |
| // Arithmetic errors | |
| {expr: `6 / 0`, wantErr: true}, // division by zero | |
| {expr: `5 % 0`, wantErr: true}, // division by zero | |
| {expr: `x + s`, wantErr: true}, // type mismatch (int + string) | |
| {expr: `s - "a"`, wantErr: true}, // invalid op for string | |
| {expr: `-s`, wantErr: true}, // invalid unary minus for string | |
| // Comparison operators (int) | |
| {expr: `1 < 2`, want: true}, | |
| {expr: `2 < 1`, want: false}, | |
| {expr: `1 < 1`, want: false}, | |
| {expr: `1 > 2`, want: false}, | |
| {expr: `2 > 1`, want: true}, | |
| {expr: `1 > 1`, want: false}, | |
| {expr: `1 <= 2`, want: true}, | |
| {expr: `2 <= 1`, want: false}, | |
| {expr: `1 <= 1`, want: true}, | |
| {expr: `1 >= 2`, want: false}, | |
| {expr: `2 >= 1`, want: true}, | |
| {expr: `1 >= 1`, want: true}, | |
| {expr: `1 == 1`, want: true}, | |
| {expr: `1 == 2`, want: false}, | |
| {expr: `1 != 1`, want: false}, | |
| {expr: `1 != 2`, want: true}, | |
| // Comparison operators (string) | |
| {expr: `"a" < "b"`, want: true}, | |
| {expr: `"b" < "a"`, want: false}, | |
| {expr: `"a" == "a"`, want: true}, | |
| {expr: `"a" == "b"`, want: false}, | |
| {expr: `"a" != "b"`, want: true}, | |
| // Comparison operators (bool) | |
| {expr: `true == true`, want: true}, | |
| {expr: `true == false`, want: false}, | |
| {expr: `true != false`, want: true}, | |
| // Comparison with nil | |
| {expr: `nil == nil`, want: true}, | |
| {expr: `nil != nil`, want: false}, | |
| {expr: `identity(nil) == nil`, want: true}, | |
| {expr: `identity(1) == nil`, want: false}, | |
| {expr: `s == nil`, want: false}, | |
| // Comparison errors | |
| {expr: `1 < "a"`, wantErr: true}, // type mismatch | |
| {expr: `true == 1`, want: false}, // type mismatch | |
| {expr: `true < false`, wantErr: true}, // invalid op for bool | |
| // Logical operators | |
| {expr: `true && true`, want: true}, | |
| {expr: `true && false`, want: false}, | |
| {expr: `false && true`, want: false}, | |
| {expr: `false && false`, want: false}, | |
| {expr: `true || true`, want: true}, | |
| {expr: `true || false`, want: true}, | |
| {expr: `false || true`, want: true}, | |
| {expr: `false || false`, want: false}, | |
| {expr: `!true`, want: false}, | |
| {expr: `!false`, want: true}, | |
| // Short-circuiting verification | |
| {expr: `false && panicFunc()`, want: false}, // short-circuit && | |
| {expr: `true || panicFunc()`, want: true}, // short-circuit || | |
| {expr: `false && greet("") == ""`, want: false}, // short-circuit && with error-returning func | |
| // Logical errors | |
| {expr: `true && 1`, wantErr: true}, // type mismatch | |
| {expr: `false || "a"`, wantErr: true}, // type mismatch | |
| {expr: `!1`, wantErr: true}, // invalid type for ! | |
| // Function calls | |
| {expr: `identity(1)`, want: 1}, | |
| {expr: `identity("a")`, want: "a"}, | |
| {expr: `add1(x)`, want: 11}, | |
| {expr: `greet(s)`, want: "Hello hello"}, | |
| {expr: `greet("")`, wantErr: true}, // error return | |
| {expr: `panicFunc()`, wantErr: true}, // panic recovery | |
| {expr: `identity()`, wantErr: true}, // arg mismatch (0 instead of 1) | |
| {expr: `x()`, wantErr: true}, // not a function | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.expr, func(t *testing.T) { | |
| astExpr := parseExpr(t, tc.expr) | |
| got, err := evaluate(astExpr, env) | |
| if (err != nil) != tc.wantErr { | |
| t.Errorf("evaluate(%s) error = %v, wantErr %v", tc.expr, err, tc.wantErr) | |
| return | |
| } | |
| if tc.wantErr { | |
| return | |
| } | |
| if !reflect.DeepEqual(got, tc.want) { | |
| t.Errorf("evaluate(%s) = %v, want %v", tc.expr, got, tc.want) | |
| } | |
| }) | |
| } | |
| } | |
| func TestEvalComprehensive(t *testing.T) { | |
| env := map[string]any{ | |
| "x": 10, | |
| "s": "hello", | |
| "greet": func(name string) (string, error) { | |
| return "Hello " + name, nil | |
| }, | |
| } | |
| exprStr := `x > 5 && greet(s) == "Hello hello" && (1 + 2 * 3) == 7` | |
| astExpr := parseExpr(t, exprStr) | |
| got, err := evaluate(astExpr, env) | |
| if err != nil { | |
| t.Fatalf("evaluate(%s) failed: %v", exprStr, err) | |
| } | |
| want := true | |
| if got != want { | |
| t.Errorf("evaluate(%s) = %v, want %v", exprStr, got, want) | |
| } | |
| } | |
| func TestEvalFilter(t *testing.T) { | |
| sym := Symbol{ | |
| Name: "Eval", | |
| Kind: "func", | |
| Synopsis: "evaluates expressions", | |
| Parent: "", | |
| } | |
| tests := []struct { | |
| filter string | |
| want bool | |
| wantErr bool | |
| }{ | |
| {filter: `name == "Eval"`, want: true}, | |
| {filter: `kind == "func"`, want: true}, | |
| {filter: `parent == ""`, want: true}, | |
| {filter: `kind == "func" && name == "Eval"`, want: true}, | |
| {filter: `kind == "var" || name == "Eval"`, want: true}, | |
| {filter: `kind == "func" && parent != ""`, want: false}, | |
| {filter: `name == "Evaluate"`, want: false}, | |
| {filter: `synopsis == "evaluates expressions"`, want: true}, | |
| {filter: `name == 1`, want: false}, // different types == is false | |
| {filter: `name != 1`, want: true}, // different types != is true | |
| // contains and matches | |
| {filter: `contains(synopsis, "eval")`, want: true}, | |
| {filter: `contains(synopsis, "invalid")`, want: false}, | |
| {filter: `matches(name, "^Ev")`, want: true}, | |
| {filter: `matches(name, "val$")`, want: true}, | |
| {filter: "matches(name, `^a`)", want: false}, | |
| // Errors | |
| {filter: `kind < 1`, wantErr: true}, // type mismatch for < | |
| {filter: `nonexistent == ""`, wantErr: true}, // undefined identifier | |
| {filter: `matches(name,"[invalid")`, wantErr: true}, // invalid regex | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.filter, func(t *testing.T) { | |
| list := []Symbol{sym} | |
| got, err := filterStruct(list, tc.filter) | |
| if (err != nil) != tc.wantErr { | |
| t.Errorf("filter2(%+v, %q) error = %v, wantErr %v", list, tc.filter, err, tc.wantErr) | |
| return | |
| } | |
| if tc.wantErr { | |
| return | |
| } | |
| if tc.want { | |
| if len(got) != 1 || got[0] != sym { | |
| t.Errorf("filter2(%+v, %q) = %v, want [%+v]", list, tc.filter, got, sym) | |
| } | |
| } else { | |
| if len(got) != 0 { | |
| t.Errorf("filter2(%+v, %q) = %v, want empty", list, tc.filter, got) | |
| } | |
| } | |
| }) | |
| } | |
| } | |
| func TestFilterErrors(t *testing.T) { | |
| type badFuncStruct struct { | |
| Func func() (int, int) | |
| } | |
| type manyFuncStruct struct { | |
| Func func() (int, int, int) | |
| } | |
| type variadicFuncStruct struct { | |
| Func func(...int) bool | |
| } | |
| type intStruct struct { | |
| X int | |
| } | |
| type panicFuncStruct struct { | |
| Func func() bool | |
| } | |
| tests := []struct { | |
| name string | |
| run func() error | |
| wantBad bool // true if we expect a BadRequest error (*Error) | |
| wantSubstr string // substring in error message | |
| }{ | |
| { | |
| name: "filterString empty varName", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "true", "") | |
| return err | |
| }, | |
| wantBad: false, | |
| wantSubstr: "string filter must have varName", | |
| }, | |
| { | |
| name: "filterStruct non-struct", | |
| run: func() error { | |
| _, err := filterStruct([]int{1}, "true") | |
| return err | |
| }, | |
| wantBad: false, | |
| wantSubstr: "need struct or pointer to struct", | |
| }, | |
| { | |
| name: "parse error", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "invalid go expr", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "parsing filter", | |
| }, | |
| { | |
| name: "eval error undefined identifier", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "unknown_var == 'a'", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "undefined identifier", | |
| }, | |
| { | |
| name: "eval error regex compile", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, `matches(x, "[invalid")`, "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "parsing regexp", | |
| }, | |
| { | |
| name: "eval error arg count mismatch", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "contains(x)", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "argument count mismatch", | |
| }, | |
| { | |
| name: "eval error not a function", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "x()", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "not a function", | |
| }, | |
| { | |
| name: "eval error division by zero", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "1/0 == 1", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "division by zero", | |
| }, | |
| { | |
| name: "eval error invalid type for +", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, `(x == "a") + 1`, "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "invalid type for +", | |
| }, | |
| { | |
| name: "non-bool result", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "1 + 1", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "did not evaluate to bool", | |
| }, | |
| { | |
| name: "eval error second return value must be error", | |
| run: func() error { | |
| list := []badFuncStruct{{Func: func() (int, int) { return 1, 2 }}} | |
| _, err := filterStruct(list, "Func() == 1") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "second return value must be error, got int", | |
| }, | |
| { | |
| name: "eval error function has too many return values", | |
| run: func() error { | |
| list := []manyFuncStruct{{Func: func() (int, int, int) { return 1, 2, 3 }}} | |
| _, err := filterStruct(list, "Func() == 1") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "function has too many return values: 3", | |
| }, | |
| { | |
| name: "eval error variadic functions are not supported", | |
| run: func() error { | |
| list := []variadicFuncStruct{{Func: func(x ...int) bool { return true }}} | |
| _, err := filterStruct(list, "Func(1, 2)") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "variadic functions are not supported", | |
| }, | |
| { | |
| name: "eval error unsupported basic lit kind", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "'a' == 'a'", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "unsupported basic lit kind", | |
| }, | |
| { | |
| name: "eval error unsupported unary operator", | |
| run: func() error { | |
| list := []intStruct{{X: 1}} | |
| _, err := filterStruct(list, "^X == 1") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "unsupported unary operator", | |
| }, | |
| { | |
| name: "eval error invalid type for <", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, `(x == "a") < 1`, "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "invalid type for <", | |
| }, | |
| { | |
| name: "eval error unsupported binary operator", | |
| run: func() error { | |
| list := []intStruct{{X: 1}} | |
| _, err := filterStruct(list, "(X & 1) == 1") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "unsupported binary operator", | |
| }, | |
| { | |
| name: "eval error expected type T, got T", | |
| run: func() error { | |
| _, err := filterString([]string{"a"}, "!x", "x") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "expected type bool, got string", | |
| }, | |
| { | |
| name: "eval error panic during function call", | |
| run: func() error { | |
| list := []panicFuncStruct{{Func: func() bool { panic("aaaaa") }}} | |
| _, err := filterStruct(list, "Func()") | |
| return err | |
| }, | |
| wantBad: true, | |
| wantSubstr: "panic during function call: aaaaa", | |
| }, | |
| } | |
| for _, tc := range tests { | |
| t.Run(tc.name, func(t *testing.T) { | |
| err := tc.run() | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| var apiErr *Error | |
| isBad := errors.As(err, &apiErr) | |
| if tc.wantBad { | |
| if !isBad { | |
| t.Errorf("expected BadRequest (*Error), got %T (%v)", err, err) | |
| } else if apiErr.Code != http.StatusBadRequest { | |
| t.Errorf("expected Code %d, got %d", http.StatusBadRequest, apiErr.Code) | |
| } | |
| } else { | |
| if isBad { | |
| t.Errorf("expected plain Go error, got BadRequest (*Error): %v", err) | |
| } | |
| } | |
| if tc.wantSubstr != "" { | |
| if !strings.Contains(err.Error(), tc.wantSubstr) { | |
| t.Errorf("expected error to contain %q, got %q", tc.wantSubstr, err.Error()) | |
| } | |
| } | |
| }) | |
| } | |
| } |