diff --git a/.golangci.yml b/.golangci.yml index 961a584..63562c3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -207,7 +207,7 @@ linters-settings: gocognit: # Minimal code complexity to report # Default: 30 (but we recommend 10-20) - min-complexity: 15 + min-complexity: 20 gocritic: # Which checks should be enabled; can't be combined with 'disabled-checks'. @@ -300,7 +300,7 @@ linters-settings: gocyclo: # Minimal code complexity to report. # Default: 30 (but we recommend 10-20) - min-complexity: 15 + min-complexity: 20 godot: # Comments to be checked: `declarations`, `toplevel`, or `all`. diff --git a/README.md b/README.md index 2743c19..a8c111f 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Escapes are supported: ## Bools -In addition to boolean expressions, sepcial contants `True` and `False` may be used. +In addition to boolean expressions, special contants `True` and `False` may be used. Do not double-quote them, or they will become plain strings! @@ -148,7 +148,7 @@ This is container `Value`. It can contain zero or any number of `Value`'s. Curre * Go classifies bit shift operators with the higher `*`. * `&&` is synonymous of `And`. * `||` is synonymous of `Or`. - * Worded operators such as `And` and `Or` are **case-sensitive** and must be followed by a blank character. `True Or (False)` is a Bool expression with the `Or` operator but `True Or(False)` is an invalid expression attempting to call a user-defined function called `Or()`. + * Worded operators such as `And` and `Or` are **case-sensitive** and must be followed by a blank character. `True Or (False)` is a Bool expression with the `Or` operator but `True Or(False)` is an expression attempting to call a user-defined function called `Or()`. * Types: String, Number, Bool, MultiValue * Associativity with parentheses: `(` and `)` * Functions: @@ -158,6 +158,8 @@ This is container `Value`. It can contain zero or any number of `Value`'s. Curre ## Functions +(See also Objects) + A function is defined as a Go type: `type FunctionalValue func(...Value) Value` Function names are case-insensitive. @@ -172,12 +174,44 @@ This allows parsing the expression once with `Parse` and run `Tree`.`Eval` multi ## Variables +(See also Objects) + Variable names are case-sensitive. Values are passed as a `map[string]Value` using `WithVariables` when calling `Eval` from `Tree`. This allows parsing the expression once with `Parse` and run `Tree`.`Eval` multiple times with different variable values. +## Objects + +Objects are Go `struct`'s which properties act as gal variables and methods as gal functions. + +Object definitions are passed as a `map[string]Object` using `WithObjects` when calling `Eval` from `Tree`. + +This allows parsing the expression once with `Parse` and run `Tree`.`Eval` multiple times with different instances of an object. + +Example: + +`type Car struct` has several properties and methods - one of which is `func (c *Car) CurrentSpeed() gal.Value`. + +```go + expr := `aCar.MaxSpeed - aCar.CurrentSpeed()` + parsedExpr := gal.Parse(expr) + + got := parsedExpr.Eval( + gal.WithObjects(map[string]gal.Object{ + "aCar": Car{ + Make: "Lotus Esprit", + Mileage: gal.NewNumberFromInt(2000), + Speed: 100, + MaxSpeed: 250, + }, + }), + ) + // result: 150 == 250 - 100 + +``` + ## High level design Expressions are parsed in two stages: diff --git a/go.mod b/go.mod index 4b46a2c..221e560 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require github.com/shopspring/decimal v1.4.0 require ( github.com/google/go-cmp v0.6.0 github.com/pkg/errors v0.9.1 + github.com/samber/lo v1.39.0 github.com/stretchr/testify v1.7.1 ) require ( github.com/davecgh/go-spew v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6d574b9..f309c6d 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/object.go b/object.go index 025d437..674ba3f 100644 --- a/object.go +++ b/object.go @@ -3,10 +3,13 @@ package gal import ( "fmt" "reflect" + + "github.com/pkg/errors" + "github.com/samber/lo" ) // TODO: implement support for nested structs? -func ObjectGetProperty(obj Object, name string) (Value, bool) { +func ObjectGetProperty(obj Object, name string) (Value, bool) { //nolint: gocognit, gocyclo, cyclop if obj == nil { return NewUndefinedWithReasonf("object is nil"), false } @@ -32,53 +35,120 @@ func ObjectGetProperty(obj Object, name string) (Value, bool) { } } + // TODO: we only support `struct` for now. Perhaps simple types (int, float, etc) are a worthwhile enhancement? if t.Kind() != reflect.Struct { - // TODO: we only support `struct` for now. Perhaps simple types (int, float, etc) are a worthwhile enhancement? return NewUndefinedWithReasonf("object is '%s' but only 'struct' and '*struct' are currently supported", t.Kind()), false } - typeName := t.Name() - - // Iterate over the fields of the struct - for i := 0; i < v.NumField(); i++ { - vName := v.Type().Field(i).Name - vType := v.Type().Field(i).Type.Name() - vValueI := v.Field(i).Interface() - if vName == name { - if vValue, ok := vValueI.(Value); ok { - return vValue, true - } else { - switch vValueIType := vValueI.(type) { - case int: - return NewNumberFromInt(int64(vValueIType)), true - case int32: - return NewNumberFromInt(int64(vValueIType)), true - case int64: - return NewNumberFromInt(vValueIType), true - case uint: - return NewNumberFromInt(int64(vValueIType)), true - case uint32: - return NewNumberFromInt(int64(vValueIType)), true - case uint64: - n, err := NewNumberFromString(fmt.Sprintf("%d", vValueIType)) - if err != nil { - return NewUndefinedWithReasonf("value '%d' of property '%s:%s' cannot be converted to a Number", vValueIType, typeName, name), false - } - return n, true - case float32: // this will commonly suffer from floating point issues - return NewNumberFromFloat(float64(vValueIType)), true - case float64: - return NewNumberFromFloat(vValueIType), true - case string: - return NewString(vValueIType), true - case bool: - return NewBool(vValueIType), true - default: - return NewUndefinedWithReasonf("property '%s:%s' is of type '%s', not a gal.Value", typeName, name, vType), false - } + vValue := v.FieldByName(name) + if !vValue.IsValid() { + return NewUndefinedWithReasonf("property '%T:%s' does not exist on object", obj, name), false + } + + galValue, err := goAnyToGalType(vValue) + if err != nil { + return NewUndefinedWithReasonf("object::%T:%s - %s", obj, name, err.Error()), false + } + return galValue, true +} + +func ObjectGetMethod(obj Object, name string) (FunctionalValue, bool) { + if obj == nil { + return func(...Value) Value { + return NewUndefinedWithReasonf("object is nil for type '%T'", obj) + }, false + } + + value := reflect.ValueOf(obj) + if value.IsZero() || !value.IsValid() { + return func(...Value) Value { + return NewUndefinedWithReasonf("object is nil for type '%T' or does not have a method '%s'", obj, name) + }, false + } + + methodValue := value.MethodByName(name) + if !methodValue.IsValid() { + return func(...Value) Value { + return NewUndefinedWithReasonf("type '%T' does not have a method '%s'", obj, name) + }, false + } + + methodType := methodValue.Type() + numParams := methodType.NumIn() + + var fn FunctionalValue = func(args ...Value) (retValue Value) { + if len(args) != numParams { + return NewUndefinedWithReasonf("invalid function call - object::%T:%s - wants %d args, received %d instead", obj, name, numParams, len(args)) + } + + callArgs := lo.Map(args, func(item Value, index int) reflect.Value { + paramType := methodType.In(index) + + switch paramType.Kind() { + // TODO: continue with more "case"'s + case reflect.Float32: + return reflect.ValueOf(float32(item.(Numberer).Number().Float64())) + case reflect.Float64: + return reflect.ValueOf(item.(Numberer).Number().Float64()) + default: + return reflect.ValueOf(item) } + }) + + defer func() { + if r := recover(); r != nil { + retValue = NewUndefinedWithReasonf("invalid function call - object::%T:%s - invalid argument type passed to function - %v", obj, name, r) + return + } + }() + + out := methodValue.Call(callArgs) + if len(out) != 1 { + return NewUndefinedWithReasonf("invalid function call - object::%T:%s - must return 1 value, returned %d instead", obj, name, len(out)) } + + retValue, err := goAnyToGalType(out[0]) + if err != nil { + return NewUndefinedWithReasonf("object::%T:%s - %s", obj, name, err.Error()) + } + return } - return NewUndefinedWithReasonf("property '%s:%s' does not exist on object", typeName, name), false + return fn, true +} + +// attempt to convert a Go 'any' type to an equivalent gal.Value +func goAnyToGalType(vValue reflect.Value) (Value, error) { + vValueI := vValue.Interface() + + switch vValueIType := vValueI.(type) { + case Value: + return vValueIType, nil + case int: + return NewNumberFromInt(int64(vValueIType)), nil + case int32: + return NewNumberFromInt(int64(vValueIType)), nil + case int64: + return NewNumberFromInt(vValueIType), nil + case uint: + return NewNumberFromInt(int64(vValueIType)), nil + case uint32: + return NewNumberFromInt(int64(vValueIType)), nil + case uint64: + n, err := NewNumberFromString(fmt.Sprintf("%d", vValueIType)) + if err != nil { + return nil, errors.Errorf("value uint64(%d) cannot be converted to a Number", vValueIType) + } + return n, nil + case float32: // this will commonly suffer from floating point issues + return NewNumberFromFloat(float64(vValueIType)), nil + case float64: + return NewNumberFromFloat(vValueIType), nil + case string: + return NewString(vValueIType), nil + case bool: + return NewBool(vValueIType), nil + default: + return nil, errors.Errorf("type '%T' cannot be mapped to gal.Value", vValueIType) + } } diff --git a/object_test.go b/object_test.go index 119c9da..c879fab 100644 --- a/object_test.go +++ b/object_test.go @@ -4,16 +4,57 @@ import ( "fmt" "testing" - "github.com/seborama/gal/v8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/seborama/gal/v8" ) type Car struct { - Make string - Mileage gal.Number - Speed float32 - MaxSpeed int64 + Make string + Mileage gal.Number + Speed float32 + MaxSpeed int64 + ComplexMethod complex128 +} + +func (c *Car) Ignite() gal.Value { + return gal.True +} + +func (c Car) Shutdown() gal.Value { //nolint: gocritic + return gal.True +} + +func (c *Car) CurrentSpeed() gal.Value { + return gal.NewNumberFromFloat(float64(c.Speed)) +} + +func (c *Car) CurrentSpeed2() float32 { + return c.Speed +} + +func (c *Car) SetSpeed(speed gal.Number) { + c.Speed = float32(speed.Float64()) +} + +func (c *Car) SetSpeed2(speed gal.Number) gal.Bool { + c.Speed = float32(speed.Float64()) + return gal.True +} + +func (c *Car) SetSpeed3(speed float32) gal.Bool { + c.Speed = speed + return gal.True +} + +type fancyType struct { + Speed float32 +} + +func (c *Car) SetSpeed4(speed fancyType) gal.Bool { + c.Speed = speed.Speed + return gal.True } func (c *Car) String() string { @@ -51,7 +92,69 @@ func TestObjectGetProperty(t *testing.T) { require.False(t, ok) assert.Equal(t, "undefined: object is 'complex128' but only 'struct' and '*struct' are currently supported", val.String()) + val, ok = gal.ObjectGetProperty(myCar, "ComplexMethod") + require.False(t, ok) + assert.Equal(t, "undefined: object::*gal_test.Car:ComplexMethod - type 'complex128' cannot be mapped to gal.Value", val.String()) + val, ok = gal.ObjectGetProperty(myCar, "MaxSpeed") require.True(t, ok) assert.Equal(t, gal.NewNumberFromInt(250), val) } + +func TestObjectGetMethod(t *testing.T) { + myCar := &Car{ + Make: "Lotus", + Mileage: gal.NewNumberFromInt(100), + Speed: 50.345, + MaxSpeed: 250, + } + + var nilCar *Car + + val, ok := gal.ObjectGetMethod(nilCar, "Ignite") + require.False(t, ok) + assert.Equal(t, "undefined: object is nil for type '*gal_test.Car' or does not have a method 'Ignite'", val().String()) + + val, ok = gal.ObjectGetMethod(myCar, "DoesNotExist!") + require.False(t, ok) + assert.Equal(t, "undefined: type '*gal_test.Car' does not have a method 'DoesNotExist!'", val().String()) + + val, ok = gal.ObjectGetMethod(myCar, "Ignite") + require.True(t, ok) + assert.Equal(t, gal.True, val()) + + val, ok = gal.ObjectGetMethod(myCar, "CurrentSpeed") + require.True(t, ok) + assert.Equal(t, "50.345", val().(gal.Numberer).Number().Trunc(3).String()) + + val, ok = gal.ObjectGetMethod(myCar, "CurrentSpeed2") + require.True(t, ok) + assert.Equal(t, "50.345", val().(gal.Numberer).Number().Trunc(3).String()) + + val, ok = gal.ObjectGetMethod(myCar, "SetSpeed") + require.True(t, ok) + got := val(gal.NewNumberFromFloat(76), gal.True, gal.False) + assert.Equal(t, "undefined: invalid function call - object::*gal_test.Car:SetSpeed - wants 1 args, received 3 instead", got.String()) + + val, ok = gal.ObjectGetMethod(myCar, "SetSpeed") + require.True(t, ok) + got = val(gal.NewNumberFromFloat(86)) + assert.Equal(t, "undefined: invalid function call - object::*gal_test.Car:SetSpeed - must return 1 value, returned 0 instead", got.String()) + + val, ok = gal.ObjectGetMethod(myCar, "SetSpeed2") + require.True(t, ok) + got = val(gal.NewNumberFromFloat(96)) + assert.Equal(t, gal.True, got) + assert.Equal(t, "96", myCar.CurrentSpeed().String()) + + val, ok = gal.ObjectGetMethod(myCar, "SetSpeed3") + require.True(t, ok) + got = val(gal.NewNumberFromFloat(106)) + assert.Equal(t, gal.True, got) + assert.Equal(t, "106", myCar.CurrentSpeed().String()) + + val, ok = gal.ObjectGetMethod(myCar, "SetSpeed4") + require.True(t, ok) + got = val(gal.NewString("blah")) + assert.Equal(t, "undefined: invalid function call - object::*gal_test.Car:SetSpeed4 - invalid argument type passed to function - reflect: Call using gal.String as type gal_test.fancyType", got.String()) +} diff --git a/value.go b/value.go index b1ec4ba..415f81f 100644 --- a/value.go +++ b/value.go @@ -29,6 +29,7 @@ func ToValue(value any) Value { v, _ := toValue(value) return v } + func toValue(value any) (Value, bool) { switch typedValue := value.(type) { case int: @@ -77,8 +78,8 @@ func ToBool(val Value) Bool { // Functions can accept a MultiValue, and also return a MultiValue. // This allows a function to effectively return multiple values as a MultiValue. // TODO: we could add a syntax to instantiate a MultiValue within an expression. -// TODO: ... perhaps along the lines of [[v1 v2 ...]] or simply a built-in function such as -// TODO: ... MultiValue(...) - nothing stops the user from creating their own for now :-) +// ... perhaps along the lines of [[v1 v2 ...]] or simply a built-in function such as +// ... MultiValue(...) - nothing stops the user from creating their own for now :-) // // TODO: implement other methods such as Add, LessThan, etc (if meaningful) type MultiValue struct {