From c5477993884042333cb9923030401cce9940bbd7 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 5 Mar 2026 21:07:51 +0000 Subject: [PATCH 1/4] Support for custom struct tags and runtime context --- builtin/builtin.go | 13 +-- builtin/function.go | 18 ++-- builtin/lib.go | 6 +- checker/checker.go | 7 ++ checker/nature/nature.go | 6 ++ checker/nature/utils.go | 15 ++-- compiler/compiler.go | 17 +++- conf/config.go | 16 +++- expr.go | 9 ++ expr_withtag_test.go | 178 +++++++++++++++++++++++++++++++++++++++ vm/opcodes.go | 1 + vm/program.go | 9 ++ vm/runtime/runtime.go | 74 +++++++++++++--- vm/vm.go | 29 +++++-- vm/vm_test.go | 20 +++-- 15 files changed, 368 insertions(+), 50 deletions(-) create mode 100644 expr_withtag_test.go diff --git a/builtin/builtin.go b/builtin/builtin.go index 87e73614a..94d643899 100644 --- a/builtin/builtin.go +++ b/builtin/builtin.go @@ -1,6 +1,7 @@ package builtin import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -595,13 +596,13 @@ var Builtins = []*Function{ }, { Name: "first", - Func: func(args ...any) (any, error) { + FuncWithContext: func(ctx context.Context, args ...any) (any, error) { defer func() { if r := recover(); r != nil { return } }() - return runtime.Fetch(args[0], 0), nil + return runtime.FromContext(ctx).Fetch(args[0], 0), nil }, Validate: func(args []reflect.Type) (reflect.Type, error) { if len(args) != 1 { @@ -618,13 +619,13 @@ var Builtins = []*Function{ }, { Name: "last", - Func: func(args ...any) (any, error) { + FuncWithContext: func(ctx context.Context, args ...any) (any, error) { defer func() { if r := recover(); r != nil { return } }() - return runtime.Fetch(args[0], -1), nil + return runtime.FromContext(ctx).Fetch(args[0], -1), nil }, Validate: func(args []reflect.Type) (reflect.Type, error) { if len(args) != 1 { @@ -640,8 +641,8 @@ var Builtins = []*Function{ }, }, { - Name: "get", - Func: get, + Name: "get", + FuncWithContext: get, }, { Name: "take", diff --git a/builtin/function.go b/builtin/function.go index 6634ac3f8..0d4be8d10 100644 --- a/builtin/function.go +++ b/builtin/function.go @@ -1,18 +1,20 @@ package builtin import ( + "context" "reflect" ) type Function struct { - Name string - Fast func(arg any) any - Func func(args ...any) (any, error) - Safe func(args ...any) (any, uint, error) - Types []reflect.Type - Validate func(args []reflect.Type) (reflect.Type, error) - Deref func(i int, arg reflect.Type) bool - Predicate bool + Name string + Fast func(arg any) any + Func func(args ...any) (any, error) + Safe func(args ...any) (any, uint, error) + FuncWithContext func(ctx context.Context, args ...any) (any, error) + Types []reflect.Type + Validate func(args []reflect.Type) (reflect.Type, error) + Deref func(i int, arg reflect.Type) bool + Predicate bool } func (f *Function) Type() reflect.Type { diff --git a/builtin/lib.go b/builtin/lib.go index 61748da08..847ae5111 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -1,6 +1,7 @@ package builtin import ( + "context" "fmt" "math" "reflect" @@ -543,7 +544,7 @@ func flatten(arg reflect.Value, depth int) ([]any, error) { return ret, nil } -func get(params ...any) (out any, err error) { +func get(ctx context.Context, params ...any) (out any, err error) { if len(params) < 2 { return nil, fmt.Errorf("invalid number of arguments (expected 2, got %d)", len(params)) } @@ -599,7 +600,8 @@ func get(params ...any) (out any, err error) { t := v.Type() field, ok := t.FieldByNameFunc(func(name string) bool { f, _ := t.FieldByName(name) - switch f.Tag.Get("expr") { + tagKey := runtime.FromContext(ctx).Tag() + switch f.Tag.Get(tagKey) { case "-": return false case fieldName: diff --git a/checker/checker.go b/checker/checker.go index 3620f2075..16fb7dec6 100644 --- a/checker/checker.go +++ b/checker/checker.go @@ -989,6 +989,13 @@ func (v *Checker) checkBuiltinGet(node *ast.BuiltinNode) Nature { return v.error(node.Arguments[1], "cannot use %s to get an element from %s", prop.String(), base.String()) } return base.Elem(&v.config.NtCache) + case reflect.Struct, reflect.Ptr: + if s, ok := node.Arguments[1].(*ast.StringNode); ok { + if nt, ok := base.FieldByName(&v.config.NtCache, s.Value); ok { + return nt + } + } + return Nature{} } return v.error(node.Arguments[0], "type %v does not support indexing", base.String()) } diff --git a/checker/nature/nature.go b/checker/nature/nature.go index c96f28c43..b01b9c825 100644 --- a/checker/nature/nature.go +++ b/checker/nature/nature.go @@ -98,10 +98,16 @@ type TypeData struct { // from the Nature type, they only describe. However, when receiving a Nature // from one of those packages, the cache must be set immediately. type Cache struct { + tag string methods map[reflect.Type]*methodset structs map[reflect.Type]Nature } +// SetTag ensures the tag is set. +func (c *Cache) SetTag(tag string) { + c.tag = tag +} + // NatureOf returns a Nature describing "i". If "i" is nil then it returns a // Nature describing the value "nil". func (c *Cache) NatureOf(i any) Nature { diff --git a/checker/nature/utils.go b/checker/nature/utils.go index 2af946002..957559210 100644 --- a/checker/nature/utils.go +++ b/checker/nature/utils.go @@ -2,18 +2,23 @@ package nature import ( "reflect" + "strings" "github.com/expr-lang/expr/internal/deref" ) -func fieldName(fieldName string, tag reflect.StructTag) (string, bool) { - switch taggedName := tag.Get("expr"); taggedName { +func fieldName(name string, tag reflect.StructTag, tagKey string) (string, bool) { + tagVal := tag.Get(tagKey) + if i := strings.IndexByte(tagVal, ','); i >= 0 { + tagVal = tagVal[:i] + } + switch tagVal { case "-": return "", false case "": - return fieldName, true + return name, true default: - return taggedName, true + return tagVal, true } } @@ -59,7 +64,7 @@ func (s *structData) structField(c *Cache, parentEmbed *structData, name string) if !field.IsExported() { continue } - fName, ok := fieldName(field.Name, field.Tag) + fName, ok := fieldName(field.Name, field.Tag, c.tag) if !ok || fName == "" { // name can still be empty for a type created at runtime with // reflect diff --git a/compiler/compiler.go b/compiler/compiler.go index f66cf9ed8..98669f269 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -77,6 +77,7 @@ func Compile(tree *parser.Tree, config *conf.Config) (program *Program, err erro c.functions, c.debugInfo, span, + c.config.Tag, ) return } @@ -172,8 +173,15 @@ func (c *compiler) addVariable(name string) int { return c.variables - 1 } -// emitFunction adds builtin.Function.Func to the program.functions and emits call opcode. +// emitFunction adds builtin.Function.Func (or FuncWithContext) to the program and emits a call opcode. func (c *compiler) emitFunction(fn *builtin.Function, argsLen int) { + if fn.FuncWithContext != nil { + id := c.addConstant(fn.FuncWithContext) + c.debugInfo[fmt.Sprintf("const_%d", id)] = fn.Name + c.emit(OpPush, id) + c.emit(OpCallContext, argsLen) + return + } switch argsLen { case 0: c.emit(OpCall0, c.addFunction(fn.Name, fn.Func)) @@ -1149,6 +1157,11 @@ func (c *compiler) BuiltinNode(node *ast.BuiltinNode) { if id, ok := builtin.Index[node.Name]; ok { f := builtin.Builtins[id] + if c.config != nil { + if overridden, ok := c.config.Builtins[node.Name]; ok { + f = overridden + } + } for i, arg := range node.Arguments { c.compile(arg) argType := arg.Type() @@ -1171,7 +1184,7 @@ func (c *compiler) BuiltinNode(node *ast.BuiltinNode) { c.emit(OpPush, id) c.debugInfo[fmt.Sprintf("const_%d", id)] = node.Name c.emit(OpCallSafe, len(node.Arguments)) - } else if f.Func != nil { + } else { c.emitFunction(f, len(node.Arguments)) } return diff --git a/conf/config.go b/conf/config.go index f7c95d203..97eb3569f 100644 --- a/conf/config.go +++ b/conf/config.go @@ -16,6 +16,10 @@ var ( // DefaultMaxNodes represents default maximum allowed AST nodes by the compiler. DefaultMaxNodes uint = 1e4 + + // DefaultTag defines the default tag to use to determine field names. Override + // with the WithTag method. + DefaultTag string = "expr" ) type FunctionsTable map[string]*builtin.Function @@ -36,6 +40,7 @@ type Config struct { Builtins FunctionsTable Disabled map[string]bool // disabled builtins NtCache nature.Cache + Tag string // DisableIfOperator disables the built-in `if ... { } else { }` operator syntax // so that users can use a custom function named `if(...)` without conflicts. // When enabled, the lexer treats `if`/`else` as identifiers and the parser @@ -57,9 +62,17 @@ func CreateNew() *Config { for _, f := range builtin.Builtins { c.Builtins[f.Name] = f } + c.SetTag(DefaultTag) return c } +// SetTag sets the struct tag key used for field name resolution in expressions. +// It updates the config, the nature cache, and the get() builtin atomically. +func (c *Config) SetTag(tag string) { + c.Tag = tag + c.NtCache.SetTag(tag) +} + // New creates new config with environment. func New(env any) *Config { c := CreateNew() @@ -77,7 +90,8 @@ func (c *Config) ConstExpr(name string) { if c.EnvObject == nil { panic("no environment is specified for ConstExpr()") } - fn := reflect.ValueOf(runtime.Fetch(c.EnvObject, name)) + ctx := runtime.New(c.Tag) + fn := reflect.ValueOf(ctx.Fetch(c.EnvObject, name)) if fn.Kind() != reflect.Func { panic(fmt.Errorf("const expression %q must be a function", name)) } diff --git a/expr.go b/expr.go index 76fbd426f..fba46ae9f 100644 --- a/expr.go +++ b/expr.go @@ -54,6 +54,15 @@ func Operator(operator string, fn ...string) Option { } } +// WithTag sets the struct tag key used for field name resolution in expressions. +// Defaults to "expr". Pass "json" to use JSON struct tags, etc. +// Fields tagged with "-" are hidden regardless of which tag is active. +func WithTag(name string) Option { + return func(c *conf.Config) { + c.SetTag(name) + } +} + // 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 { diff --git a/expr_withtag_test.go b/expr_withtag_test.go new file mode 100644 index 000000000..02a18495e --- /dev/null +++ b/expr_withtag_test.go @@ -0,0 +1,178 @@ +package expr_test + +import ( + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/assert" + "github.com/expr-lang/expr/internal/testify/require" +) + +// --- test types --- + +type jsonTagged struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Hidden string `json:"hidden,omitempty"` + Ignored string `json:"-"` + NoTag string +} + +type embeddedBase struct { + BaseField string `json:"base_field"` +} + +type jsonEmbedded struct { + embeddedBase + Own string `json:"own"` +} + +// TestWithTag_BasicJSON checks that json-tagged fields are accessible by their tag name. +func TestWithTag_BasicJSON(t *testing.T) { + env := jsonTagged{FirstName: "John", LastName: "Doe"} + program, err := expr.Compile(`first_name + " " + last_name`, + expr.Env(jsonTagged{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "John Doe", out) +} + +// TestWithTag_HideField checks that json:"-" causes a compile-time error when accessing the field. +func TestWithTag_HideField(t *testing.T) { + _, err := expr.Compile(`Ignored`, + expr.Env(jsonTagged{}), + expr.WithTag("json"), + ) + require.Error(t, err) +} + +// TestWithTag_CommaStripped checks that "name,omitempty" is accessible as "name". +func TestWithTag_CommaStripped(t *testing.T) { + env := jsonTagged{Hidden: "secret"} + program, err := expr.Compile(`hidden`, + expr.Env(jsonTagged{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "secret", out) +} + +// TestWithTag_ExprTagInactive verifies that the expr tag is NOT resolved when using WithTag("json"). +func TestWithTag_ExprTagInactive(t *testing.T) { + type withExprTag struct { + Val string `expr:"renamed" json:"val"` + } + // With json tag, "val" should work, "renamed" should not + _, err := expr.Compile(`renamed`, + expr.Env(withExprTag{}), + expr.WithTag("json"), + ) + require.Error(t, err) + + program, err := expr.Compile(`val`, + expr.Env(withExprTag{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, withExprTag{Val: "ok"}) + require.NoError(t, err) + assert.Equal(t, "ok", out) +} + +// TestWithTag_FallbackToFieldName checks that a field with no json tag is still accessible by its Go name. +func TestWithTag_FallbackToFieldName(t *testing.T) { + env := jsonTagged{NoTag: "direct"} + program, err := expr.Compile(`NoTag`, + expr.Env(jsonTagged{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "direct", out) +} + +// TestWithTag_OrderingBeforeEnv checks that WithTag before Env works. +func TestWithTag_OrderingBeforeEnv(t *testing.T) { + env := jsonTagged{FirstName: "Jane"} + program, err := expr.Compile(`first_name`, + expr.WithTag("json"), + expr.Env(jsonTagged{}), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "Jane", out) +} + +// TestWithTag_OrderingAfterEnv checks that WithTag after Env works. +func TestWithTag_OrderingAfterEnv(t *testing.T) { + env := jsonTagged{FirstName: "Jane"} + program, err := expr.Compile(`first_name`, + expr.Env(jsonTagged{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "Jane", out) +} + +// TestWithTag_InOperator checks that the `in` operator respects the configured tag. +func TestWithTag_InOperator(t *testing.T) { + env := jsonTagged{FirstName: "John"} + + // "first_name" in env should be true (field exists and is not hidden) + program, err := expr.Compile(`"first_name" in env`, + expr.Env(map[string]any{"env": jsonTagged{}}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, map[string]any{"env": env}) + require.NoError(t, err) + assert.Equal(t, true, out) +} + +// TestWithTag_Embedded checks that tag resolution works for embedded structs. +func TestWithTag_Embedded(t *testing.T) { + env := jsonEmbedded{ + embeddedBase: embeddedBase{BaseField: "base"}, + Own: "own", + } + program, err := expr.Compile(`base_field + "-" + own`, + expr.Env(jsonEmbedded{}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "base-own", out) +} + +// TestWithTag_GetBuiltin checks that get() uses the configured tag. +func TestWithTag_GetBuiltin(t *testing.T) { + env := map[string]any{"s": jsonTagged{FirstName: "Alice"}} + program, err := expr.Compile(`get(s, "first_name")`, + expr.Env(map[string]any{"s": jsonTagged{}}), + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.Equal(t, "Alice", out) +} + +// TestWithTag_DefaultUnaffected checks that Eval() without WithTag still uses the "expr" tag. +func TestWithTag_DefaultUnaffected(t *testing.T) { + type withExprTag struct { + Val string `expr:"my_val"` + } + out, err := expr.Eval(`my_val`, withExprTag{Val: "hello"}) + require.NoError(t, err) + assert.Equal(t, "hello", out) +} diff --git a/vm/opcodes.go b/vm/opcodes.go index 5fca0fa29..d18d52f4d 100644 --- a/vm/opcodes.go +++ b/vm/opcodes.go @@ -59,6 +59,7 @@ const ( OpCallN OpCallFast OpCallSafe + OpCallContext OpCallTyped OpCallBuiltin1 OpArray diff --git a/vm/program.go b/vm/program.go index 7eb96bd3d..ff98aa582 100644 --- a/vm/program.go +++ b/vm/program.go @@ -21,6 +21,10 @@ type Program struct { Arguments []int Constants []any + // runtime holds the tag-aware field resolver and its per-program field + // index cache. All struct field accesses at runtime go through it. + runtime *runtime.Context + source file.Source node ast.Node locations []file.Location @@ -42,6 +46,7 @@ func NewProgram( functions []Function, debugInfo map[string]string, span *Span, + tag string, ) *Program { return &Program{ source: source, @@ -54,6 +59,7 @@ func NewProgram( functions: functions, debugInfo: debugInfo, span: span, + runtime: runtime.New(tag), } } @@ -299,6 +305,9 @@ func (program *Program) DisassembleWriter(w io.Writer) { case OpCallSafe: argument("OpCallSafe") + case OpCallContext: + argument("OpCallContext") + case OpCallTyped: signature := reflect.TypeOf(FuncTypes[arg]).Elem().String() _, _ = fmt.Fprintf(w, "%v\t%v\t<%v>\t%v\n", pp, "OpCallTyped", arg, signature) diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index bc6f2b4df..cdf6a0038 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -3,6 +3,7 @@ package runtime //go:generate sh -c "go run ./helpers > ./helpers[generated].go" import ( + "context" "fmt" "math" "reflect" @@ -11,14 +12,50 @@ import ( "github.com/expr-lang/expr/internal/deref" ) -var fieldCache sync.Map +type contextKey struct{} +// New instantiates a new context with the provided tag. +func New(tag string) *Context { + return &Context{tag: tag} +} + +// FromContext retrieves the *Context stored by NewContext, or nil if none is present. +func FromContext(ctx context.Context) *Context { + c, _ := ctx.Value(contextKey{}).(*Context) + return c +} + +// fieldCacheKey is used inside Context to memoize struct field index lookups. +// It combines the struct type with the requested field name (which may be a +// tag value). type fieldCacheKey struct { t reflect.Type f string } -func Fetch(from, i any) any { +// Context holds the struct-tag key and a per-program field index cache for +// runtime struct field resolution. It must not be copied after first use +// (sync.Map is embedded). Embed it in vm.Program so each compiled program +// carries its own isolated context. +type Context struct { + tag string + cache sync.Map +} + +// With returns a new Go context with the runtime instance incorporated +// inside it. +func (c *Context) With(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKey{}, c) +} + +// Tag returns the runtime context's tag used in struct fields. +func (c *Context) Tag() string { + return c.tag +} + +// Fetch retrieves the value addressed by i from from. For structs, it uses +// c.tag to map i to a Go field name and caches the resolved index in c.cache. +func (c *Context) Fetch(from, i any) any { v := reflect.ValueOf(from) if v.Kind() == reflect.Invalid { panic(fmt.Sprintf("cannot fetch %v from %T", i, from)) @@ -72,16 +109,13 @@ func Fetch(from, i any) any { case reflect.Struct: fieldName := i.(string) t := v.Type() - key := fieldCacheKey{ - t: t, - f: fieldName, - } - if cv, ok := fieldCache.Load(key); ok { + key := fieldCacheKey{t: t, f: fieldName} + if cv, ok := c.cache.Load(key); ok { return v.FieldByIndex(cv.([]int)).Interface() } field, ok := t.FieldByNameFunc(func(name string) bool { field, _ := t.FieldByName(name) - switch field.Tag.Get("expr") { + switch field.Tag.Get(c.tag) { case "-": return false case fieldName: @@ -93,7 +127,7 @@ func Fetch(from, i any) any { if ok && field.IsExported() { value := v.FieldByIndex(field.Index) if value.IsValid() { - fieldCache.Store(key, field.Index) + c.cache.Store(key, field.Index) return value.Interface() } } @@ -206,7 +240,9 @@ func Slice(array, from, to any) any { panic(fmt.Sprintf("cannot slice %v", from)) } -func In(needle any, array any) bool { +// In reports whether needle is in array. For structs it checks whether a +// field whose name (or c.tag tag value) equals needle exists and is exported. +func (c *Context) In(needle any, array any) bool { if array == nil { return false } @@ -242,8 +278,20 @@ func In(needle any, array any) bool { if !n.IsValid() || n.Kind() != reflect.String { panic(fmt.Sprintf("cannot use %T as field name of %T", needle, array)) } - field, ok := v.Type().FieldByName(n.String()) - if !ok || !field.IsExported() || field.Tag.Get("expr") == "-" { + fieldName := n.String() + t := v.Type() + field, ok := t.FieldByNameFunc(func(name string) bool { + f, _ := t.FieldByName(name) + switch f.Tag.Get(c.tag) { + case "-": + return false + case fieldName: + return true + default: + return name == fieldName + } + }) + if !ok || !field.IsExported() { return false } value := v.FieldByIndex(field.Index) @@ -255,7 +303,7 @@ func In(needle any, array any) bool { case reflect.Ptr: value := v.Elem() if value.IsValid() { - return In(needle, value.Interface()) + return c.In(needle, value.Interface()) } return false } diff --git a/vm/vm.go b/vm/vm.go index ba3b53863..ff871b8ed 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -3,6 +3,7 @@ package vm //go:generate sh -c "go run ./func_types > ./func_types[generated].go" import ( + "context" "fmt" "reflect" "regexp" @@ -52,6 +53,11 @@ type VM struct { } func (vm *VM) Run(program *Program, env any) (_ any, err error) { + ctx := context.Background() + return vm.RunWithContext(ctx, program, env) +} + +func (vm *VM) RunWithContext(ctx context.Context, program *Program, env any) (_ any, err error) { defer func() { if r := recover(); r != nil { var location file.Location @@ -69,6 +75,8 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { } }() + ctx = program.runtime.With(ctx) + if vm.Stack == nil { vm.Stack = make([]any, 0, 2) } else { @@ -122,7 +130,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { vm.push(vm.Variables[arg]) case OpLoadConst: - vm.push(runtime.Fetch(env, program.Constants[arg])) + vm.push(program.runtime.Fetch(env, program.Constants[arg])) case OpLoadField: vm.push(runtime.FetchField(env, program.Constants[arg].(*runtime.Field))) @@ -139,7 +147,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case OpFetch: b := vm.pop() a := vm.pop() - vm.push(runtime.Fetch(a, b)) + vm.push(program.runtime.Fetch(a, b)) case OpFetchField: a := vm.pop() @@ -236,7 +244,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case OpIn: b := vm.pop() a := vm.pop() - vm.push(runtime.In(a, b)) + vm.push(program.runtime.In(a, b)) case OpLess: b := vm.pop() @@ -469,6 +477,16 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { vm.memGrow(mem) vm.push(out) + case OpCallContext: + fn := vm.pop().(func(context.Context, ...any) (any, error)) + var args []any + args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, arg) + out, err := fn(ctx, args...) + if err != nil { + panic(err) + } + vm.push(out) + case OpCallTyped: vm.push(vm.call(vm.pop(), arg)) @@ -816,6 +834,7 @@ var opArgLenEstimation = [...]int{ OpCallN: 4, // here we don't know either, but we can guess it could be common to receive // up to 3 arguments in a function - OpCallFast: 3, - OpCallSafe: 3, + OpCallFast: 3, + OpCallSafe: 3, + OpCallContext: 3, } diff --git a/vm/vm_test.go b/vm/vm_test.go index c86183cad..5088097f1 100644 --- a/vm/vm_test.go +++ b/vm/vm_test.go @@ -694,8 +694,9 @@ func TestVM_DirectCallOpcodes(t *testing.T) { tt.bytecode, tt.args, tt.funcs, - nil, // debugInfo - nil, // span + nil, // debugInfo + nil, // span + "expr", // tag ) vm := &vm.VM{} got, err := vm.Run(program, nil) @@ -819,9 +820,10 @@ func TestVM_IndexAndCountOperations(t *testing.T) { tt.consts, tt.bytecode, tt.args, - nil, // functions - nil, // debugInfo - nil, // span + nil, // functions + nil, // debugInfo + nil, // span + "expr", // tag ) vm := &vm.VM{} got, err := vm.Run(program, nil) @@ -1288,9 +1290,10 @@ func TestVM_DirectBasicOpcodes(t *testing.T) { tt.consts, tt.bytecode, tt.args, - nil, // functions - nil, // debugInfo - nil, // span + nil, // functions + nil, // debugInfo + nil, // span + "expr", // tag ) vm := &vm.VM{} got, err := vm.Run(program, tt.env) @@ -1460,6 +1463,7 @@ func TestVM_OpJump_NegativeOffset(t *testing.T) { nil, nil, nil, + "expr", ) _, err := vm.Run(program, nil) From 3aab032447d4b6b5ac72480b2485467364ae581b Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 5 Mar 2026 21:40:57 +0000 Subject: [PATCH 2/4] Fixing default tag in compiler --- compiler/compiler.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compiler/compiler.go b/compiler/compiler.go index 98669f269..3cd061119 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -37,8 +37,10 @@ func Compile(tree *parser.Tree, config *conf.Config) (program *Program, err erro debugInfo: make(map[string]string), } + tag := conf.DefaultTag if config != nil { c.ntCache = &c.config.NtCache + tag = config.Tag } else { c.ntCache = new(Cache) } @@ -77,7 +79,7 @@ func Compile(tree *parser.Tree, config *conf.Config) (program *Program, err erro c.functions, c.debugInfo, span, - c.config.Tag, + tag, ) return } From fb97da811249b878d02bd2a14cbaaac0be80b13f Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 6 Mar 2026 08:44:04 +0000 Subject: [PATCH 3/4] Fixing performance degredation on assignments --- vm/vm.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vm/vm.go b/vm/vm.go index ff871b8ed..9f3fc50ca 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -75,8 +75,6 @@ func (vm *VM) RunWithContext(ctx context.Context, program *Program, env any) (_ } }() - ctx = program.runtime.With(ctx) - if vm.Stack == nil { vm.Stack = make([]any, 0, 2) } else { @@ -99,6 +97,7 @@ func (vm *VM) RunWithContext(ctx context.Context, program *Program, env any) (_ vm.ip = 0 var fnArgsBuf []any + var rtCtx context.Context // lazily enriched on first OpCallContext hit for vm.ip < len(program.Bytecode) { if debug && vm.debug { @@ -481,7 +480,10 @@ func (vm *VM) RunWithContext(ctx context.Context, program *Program, env any) (_ fn := vm.pop().(func(context.Context, ...any) (any, error)) var args []any args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, arg) - out, err := fn(ctx, args...) + if rtCtx == nil { + rtCtx = program.runtime.With(ctx) + } + out, err := fn(rtCtx, args...) if err != nil { panic(err) } From c23ccc78012de7514ffa970f39bb49dc713fcc20 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 6 Mar 2026 12:25:17 +0000 Subject: [PATCH 4/4] Fixing issues with tags that include commas --- builtin/lib.go | 14 +++++++------- expr_withtag_test.go | 14 ++++++++++++++ vm/runtime/runtime.go | 44 ++++++++++++++++++++++++++++++------------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/builtin/lib.go b/builtin/lib.go index 847ae5111..55e59f4b4 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -598,17 +598,17 @@ func get(ctx context.Context, params ...any) (out any, err error) { case reflect.Struct: fieldName := i.(string) t := v.Type() + rtCtx := runtime.FromContext(ctx) field, ok := t.FieldByNameFunc(func(name string) bool { f, _ := t.FieldByName(name) - tagKey := runtime.FromContext(ctx).Tag() - switch f.Tag.Get(tagKey) { - case "-": + tagName, ok := rtCtx.TagName(f.Tag) + if !ok { return false - case fieldName: - return true - default: - return name == fieldName } + if tagName != "" { + return tagName == fieldName + } + return name == fieldName }) if ok && field.IsExported() { value := v.FieldByIndex(field.Index) diff --git a/expr_withtag_test.go b/expr_withtag_test.go index 02a18495e..17a19b52b 100644 --- a/expr_withtag_test.go +++ b/expr_withtag_test.go @@ -16,6 +16,7 @@ type jsonTagged struct { Hidden string `json:"hidden,omitempty"` Ignored string `json:"-"` NoTag string + Embed *jsonEmbedded `json:"embed,omitempty"` } type embeddedBase struct { @@ -62,6 +63,19 @@ func TestWithTag_CommaStripped(t *testing.T) { assert.Equal(t, "secret", out) } +// TestWithTag_CommaStrippedEmbed checks that a field whose tag has comma options +// (e.g. json:"embed,omitempty") is accessible at runtime via its tag name. +func TestWithTag_CommaStrippedEmbed(t *testing.T) { + env := jsonTagged{} + program, err := expr.Compile(`(embed?.own ?? "") == "foo"`, + expr.WithTag("json"), + ) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + assert.False(t, out.(bool)) +} + // TestWithTag_ExprTagInactive verifies that the expr tag is NOT resolved when using WithTag("json"). func TestWithTag_ExprTagInactive(t *testing.T) { type withExprTag struct { diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index cdf6a0038..271f88a9e 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "reflect" + "strings" "sync" "github.com/expr-lang/expr/internal/deref" @@ -53,6 +54,23 @@ func (c *Context) Tag() string { return c.tag } +// TagName resolves the display name for a struct field under c's configured tag. +// Returns ("", false) if the field is excluded by a "-" tag value. +// Returns ("", true) if no tag is set — caller should fall back to the Go field name. +func (c *Context) TagName(tag reflect.StructTag) (string, bool) { + if c == nil { + return "", true + } + tagVal := tag.Get(c.tag) + if i := strings.IndexByte(tagVal, ','); i >= 0 { + tagVal = tagVal[:i] + } + if tagVal == "-" { + return "", false + } + return tagVal, true +} + // Fetch retrieves the value addressed by i from from. For structs, it uses // c.tag to map i to a Go field name and caches the resolved index in c.cache. func (c *Context) Fetch(from, i any) any { @@ -114,15 +132,15 @@ func (c *Context) Fetch(from, i any) any { return v.FieldByIndex(cv.([]int)).Interface() } field, ok := t.FieldByNameFunc(func(name string) bool { - field, _ := t.FieldByName(name) - switch field.Tag.Get(c.tag) { - case "-": + f, _ := t.FieldByName(name) + tagName, ok := c.TagName(f.Tag) + if !ok { return false - case fieldName: - return true - default: - return name == fieldName } + if tagName != "" { + return tagName == fieldName + } + return name == fieldName }) if ok && field.IsExported() { value := v.FieldByIndex(field.Index) @@ -282,14 +300,14 @@ func (c *Context) In(needle any, array any) bool { t := v.Type() field, ok := t.FieldByNameFunc(func(name string) bool { f, _ := t.FieldByName(name) - switch f.Tag.Get(c.tag) { - case "-": + tagName, ok := c.TagName(f.Tag) + if !ok { return false - case fieldName: - return true - default: - return name == fieldName } + if tagName != "" { + return tagName == fieldName + } + return name == fieldName }) if !ok || !field.IsExported() { return false