From 0d92edc195e317dd9d8f2d5a36e5eb3bb36f24c2 Mon Sep 17 00:00:00 2001 From: Ville Vesilehto Date: Sun, 30 Nov 2025 18:23:14 +0200 Subject: [PATCH] fix(vm): handle nil in AsBool with undef vars Update AsBool implementation to strictly return a boolean value, ensuring that undefined variables are cast to false. Undefined variables resolve to nil when AllowUndefinedVariables is enabled. This adds a new ToBool runtime helper, updates the OpCast VM instruction to support boolean casting, and modifies the compiler to emit this cast when AsBool is used. Adds regression tests. Signed-off-by: Ville Vesilehto --- compiler/compiler.go | 2 + compiler/compiler_test.go | 48 ++++++++++++++++++++ test/issues/830/issue_test.go | 20 +++++++++ vm/runtime/runtime.go | 12 +++++ vm/vm.go | 2 + vm/vm_test.go | 83 +++++++++++++++++++++++++++++++---- 6 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 test/issues/830/issue_test.go diff --git a/compiler/compiler.go b/compiler/compiler.go index 83b19a885..4c31ca904 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -53,6 +53,8 @@ func Compile(tree *parser.Tree, config *conf.Config) (program *Program, err erro c.emit(OpCast, 1) case reflect.Float64: c.emit(OpCast, 2) + case reflect.Bool: + c.emit(OpCast, 3) } if c.config.Optimize { c.optimize() diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index b683b82d6..6efce686f 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -2,6 +2,7 @@ package compiler_test import ( "math" + "reflect" "testing" "github.com/expr-lang/expr/internal/testify/assert" @@ -675,3 +676,50 @@ func TestCompile_call_on_nil(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "foo is nil; cannot call nil as function") } + +func TestCompile_Expect(t *testing.T) { + tests := []struct { + input string + option expr.Option + op vm.Opcode + arg int + }{ + { + input: "1", + option: expr.AsKind(reflect.Int), + op: vm.OpCast, + arg: 0, + }, + { + input: "1", + option: expr.AsInt64(), + op: vm.OpCast, + arg: 1, + }, + { + input: "1", + option: expr.AsFloat64(), + op: vm.OpCast, + arg: 2, + }, + { + input: "true", + option: expr.AsBool(), + op: vm.OpCast, + arg: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + program, err := expr.Compile(tt.input, tt.option) + require.NoError(t, err) + + lastOp := program.Bytecode[len(program.Bytecode)-1] + lastArg := program.Arguments[len(program.Arguments)-1] + + assert.Equal(t, tt.op, lastOp) + assert.Equal(t, tt.arg, lastArg) + }) + } +} diff --git a/test/issues/830/issue_test.go b/test/issues/830/issue_test.go new file mode 100644 index 000000000..96db36933 --- /dev/null +++ b/test/issues/830/issue_test.go @@ -0,0 +1,20 @@ +package issues + +import ( + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/assert" + "github.com/expr-lang/expr/internal/testify/require" +) + +func TestIssue830(t *testing.T) { + program, err := expr.Compile("varNotExist", expr.AllowUndefinedVariables(), expr.AsBool()) + require.NoError(t, err) + + output, err := expr.Run(program, map[string]interface{}{}) + require.NoError(t, err) + + // The user expects output to be false (bool), but gets nil. + assert.Equal(t, false, output) +} diff --git a/vm/runtime/runtime.go b/vm/runtime/runtime.go index 56759c531..36995d306 100644 --- a/vm/runtime/runtime.go +++ b/vm/runtime/runtime.go @@ -393,6 +393,18 @@ func ToFloat64(a any) float64 { } } +func ToBool(a any) bool { + if a == nil { + return false + } + switch x := a.(type) { + case bool: + return x + default: + panic(fmt.Sprintf("invalid operation: bool(%T)", x)) + } +} + func IsNil(v any) bool { if v == nil { return true diff --git a/vm/vm.go b/vm/vm.go index ed61d2f90..8e22639ec 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -454,6 +454,8 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { vm.push(runtime.ToInt64(vm.pop())) case 2: vm.push(runtime.ToFloat64(vm.pop())) + case 3: + vm.push(runtime.ToBool(vm.pop())) } case OpDeref: diff --git a/vm/vm_test.go b/vm/vm_test.go index 817fc6cc2..679dcf44b 100644 --- a/vm/vm_test.go +++ b/vm/vm_test.go @@ -61,18 +61,57 @@ func TestRun_ReuseVM_for_different_variables(t *testing.T) { } func TestRun_Cast(t *testing.T) { - input := `1` + tests := []struct { + input string + expect reflect.Kind + want any + }{ + { + input: `1`, + expect: reflect.Float64, + want: float64(1), + }, + { + input: `1`, + expect: reflect.Int, + want: int(1), + }, + { + input: `1`, + expect: reflect.Int64, + want: int64(1), + }, + { + input: `true`, + expect: reflect.Bool, + want: true, + }, + { + input: `false`, + expect: reflect.Bool, + want: false, + }, + { + input: `nil`, + expect: reflect.Bool, + want: false, + }, + } - tree, err := parser.Parse(input) - require.NoError(t, err) + for _, tt := range tests { + t.Run(fmt.Sprintf("%v %v", tt.expect, tt.input), func(t *testing.T) { + tree, err := parser.Parse(tt.input) + require.NoError(t, err) - program, err := compiler.Compile(tree, &conf.Config{Expect: reflect.Float64}) - require.NoError(t, err) + program, err := compiler.Compile(tree, &conf.Config{Expect: tt.expect}) + require.NoError(t, err) - out, err := vm.Run(program, nil) - require.NoError(t, err) + out, err := vm.Run(program, nil) + require.NoError(t, err) - require.Equal(t, float64(1), out) + require.Equal(t, tt.want, out) + }) + } } func TestRun_Helpers(t *testing.T) { @@ -1063,6 +1102,34 @@ func TestVM_DirectBasicOpcodes(t *testing.T) { consts: []any{int32(42)}, want: int64(42), }, + { + name: "OpCast bool to bool", + bytecode: []vm.Opcode{ + vm.OpTrue, // Push true + vm.OpCast, // Cast to bool + }, + args: []int{0, 3}, + want: true, + }, + { + name: "OpCast nil to bool", + bytecode: []vm.Opcode{ + vm.OpNil, // Push nil + vm.OpCast, // Cast to bool + }, + args: []int{0, 3}, + want: false, + }, + { + name: "OpCast int to bool", + bytecode: []vm.Opcode{ + vm.OpPush, // Push int + vm.OpCast, // Cast to bool + }, + args: []int{0, 3}, + consts: []any{1}, + wantErr: true, + }, { name: "OpCast invalid type", bytecode: []vm.Opcode{