diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 59a1190..239b196 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,24 +26,24 @@ jobs: path-to-profile: ./build/test-all.cover - macos: - runs-on: macos-latest - steps: - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: 1.25 - cache: false + # macos: + # runs-on: macos-latest + # steps: + # - name: Set up Go + # uses: actions/setup-go@v5 + # with: + # go-version: 1.25 + # cache: false - - name: Checkout code - uses: actions/checkout@v4 + # - name: Checkout code + # uses: actions/checkout@v4 - - name: Build and tests - env: - BASH_COMPAT: 3.2 - CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} - LANG: en_US.UTF-8 - run: make all + # - name: Build and tests + # env: + # BASH_COMPAT: 3.2 + # CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} + # LANG: en_US.UTF-8 + # run: make all release: diff --git a/VERSION b/VERSION index 44517d5..fe04e7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.19 +0.0.20 diff --git a/config/config.go b/config/config.go index 6b1532d..4ae8fdc 100644 --- a/config/config.go +++ b/config/config.go @@ -22,8 +22,7 @@ var ErrConfig = errors.New("config") // NewErrConfig is a convenience method to create a new config error with the // given context wrapping the original error. func NewErrConfig(message, context string, err error) error { - //nolint:errorlint // wrapping error hard to test. // TODO: improve tests. - return fmt.Errorf("%w - %s [%s]: %v", ErrConfig, message, context, err) + return fmt.Errorf("%w - %s [%s]: %w", ErrConfig, message, context, err) } // Config common application configuration. @@ -113,8 +112,17 @@ func (r *Reader[C]) SetDefaultConfig( r.SetDefault("info.platform", info.Platform) r.SetDefault("info.compiler", info.Compiler) - reflect.NewTagWalker("default", "mapstructure", zero). - Walk(key, config, r.SetDefault) + err := reflect.NewTagWalker("default", "mapstructure", + zero, r.SetDefault).Walk(key, config) + if err != nil { + err := NewErrConfig("creating defaults", "", err) + logrus.WithFields(logrus.Fields{ + "key": key, "config": config, + }).WithError(err).Warn("creating defaults") + if r.GetBool("viper.panic.defaults") { + panic(err) + } + } return r } diff --git a/config/config_test.go b/config/config_test.go index a44a55f..4b6faf2 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,16 +3,16 @@ package config_test import ( "errors" "fmt" - "reflect" - "strconv" "testing" - "github.com/go-viper/mapstructure/v2" "github.com/ory/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/tkrop/go-config/config" + "github.com/tkrop/go-config/info" "github.com/tkrop/go-config/internal/filepath" + ireflect "github.com/tkrop/go-config/internal/reflect" "github.com/tkrop/go-testing/mock" ref "github.com/tkrop/go-testing/reflect" "github.com/tkrop/go-testing/test" @@ -20,106 +20,185 @@ import ( var configPaths = []string{filepath.Normalize(".")} +func newConfig( + env, level string, setup func(info *info.Info), +) *config.Config { + reader := config.NewReader[config.Config]("TC", "test") + config := reader.GetConfig("test-helper") + + if setup != nil { + setup(config.Info) + } + + config.Env = env + config.Log.Level = level + + return config +} + type ConfigParams struct { - setenv func(test.Test) - setup func(*config.Reader[config.Config]) - expect mock.SetupFunc - expectEnv string - expectLogLevel string + setup mock.SetupFunc + reader func(test.Test) *config.Reader[config.Config] + expect *config.Config } var configTestCases = map[string]ConfigParams{ "default config without file": { - expectEnv: "prod", - expectLogLevel: "info", + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test") + }, + expect: newConfig("prod", "info", nil), }, "default config with file": { - setup: func(r *config.Reader[config.Config]) { - r.AddConfigPath("fixtures") + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + }) }, - expectEnv: "prod", - expectLogLevel: "debug", + expect: newConfig("prod", "debug", func(info *info.Info) { + info.Path = "github.com/tkrop/go-config" + }), }, "read config with overriding env": { - setenv: func(t test.Test) { + reader: func(t test.Test) *config.Reader[config.Config] { t.Setenv("TC_ENV", "test") t.Setenv("TC_LOG_LEVEL", "trace") + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + }) }, - setup: func(r *config.Reader[config.Config]) { - r.AddConfigPath("fixtures") - }, - expectEnv: "test", - expectLogLevel: "trace", + expect: newConfig("test", "trace", nil), }, "read config with overriding func": { - setup: func(r *config.Reader[config.Config]) { - r.SetDefault("log.level", "trace") + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.SetDefault("log.level", "trace") + }) }, - expectEnv: "prod", - expectLogLevel: "trace", + expect: newConfig("prod", "trace", nil), }, "panic after file not found": { - setup: func(r *config.Reader[config.Config]) { - r.SetDefault("viper.panic.load", true) - }, - expect: test.Panic(config.NewErrConfig("loading file", "test", + setup: test.Panic(config.NewErrConfig("loading file", "test", ref.NewBuilder[viper.ConfigFileNotFoundError](). Set("locations", fmt.Sprintf("%s", configPaths)). - Set("name", "test").Build())), + Set("name", "test").Build()).Error()), + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.SetDefault("viper.panic.load", true) + }) + }, }, - "panic after unmarshal failure": { - setup: func(r *config.Reader[config.Config]) { - r.AddConfigPath("fixtures") - r.SetDefault("viper.panic.unmarshal", true) - r.SetDefault("info.dirty", "5s") - }, - expect: test.Panic(config.NewErrConfig("unmarshal config", - "test", fmt.Errorf("decoding failed due to the following error(s):\n\n%v", + "panic after unmarshal failure next": { + setup: test.Panic(config.NewErrConfig("unmarshal config", + "test", fmt.Errorf( + "decoding failed due to the following error(s):\n\n%v", "'Info.Dirty' cannot parse value as 'bool': "+ - "strconv.ParseBool: invalid syntax", - ))), + "strconv.ParseBool: invalid syntax")).Error()), + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.AddConfigPath("fixtures") + r.SetDefault("viper.panic.unmarshal", true) + r.SetDefault("info.dirty", "5s") + }) + }, }, - // TODO: Improve error wrapping test for unmarshal failure. - "panic after unmarshal failure next": { - setup: func(r *config.Reader[config.Config]) { - r.AddConfigPath("fixtures") - r.SetDefault("viper.panic.unmarshal", true) - r.SetDefault("info.dirty", "5s") + + "error on default config with invalid tag": { + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.SetDefaultConfig("", &struct { + Field []string `default:"invalid"` + }{}, false) + }) + }, + expect: newConfig("prod", "info", nil), + }, + + "panic on default config with invalid tag": { + setup: test.Panic(config.NewErrConfig("creating defaults", "", + ireflect.NewErrTagWalker("yaml parsing", "field", "invalid", + errors.New("yaml: unmarshal errors:\n line 1: "+ + "cannot unmarshal !!str `invalid` into []string"))). + Error()), + reader: func(_ test.Test) *config.Reader[config.Config] { + return config.NewReader[config.Config]("TC", "test"). + SetDefaults(func(r *config.Reader[config.Config]) { + r.SetDefault("viper.panic.defaults", true) + r.SetDefaultConfig("", &struct { + Field []string `default:"invalid"` + }{}, false) + }) }, - expect: test.Panic(config.NewErrConfig("unmarshal config", - "test", fmt.Errorf("decoding failed due to the following error(s):\n\n%w", - errors.Join(ref.NewBuilder[*mapstructure.DecodeError](). - Set("name", "Info.Dirty"). - Set("err", &mapstructure.ParseError{ - Expected: reflect.ValueOf(true), Value: "5s", - Err: &strconv.NumError{Func: "ParseBool", Num: "5s"}, - }).Build())))), }, } func TestConfig(t *testing.T) { test.Map(t, configTestCases). - Filter(test.Not(test.Pattern[ConfigParams]( - "panic-after-unmarshal-failure-next"))). RunSeq(func(t test.Test, param ConfigParams) { // Given - mock.NewMocks(t).Expect(param.expect) - if param.setenv != nil { - param.setenv(t) - } - reader := config.NewReader[config.Config]("TC", "test"). - SetDefaults(param.setup) + mock.NewMocks(t).Expect(param.setup) + + // When + reader := param.reader(t) + config := reader.LoadConfig("test") + + // Then + assert.Equal(t, param.expect, config) + }) +} + +// TODO: improve test to provide meaningful insights about defaults. +type AnyConfigParams struct { + // setup mock.SetupFunc + config any + expect any +} + +type object struct { + A any + B any + C any + D []int +} + +var anyConfigParams = map[string]AnyConfigParams{ + "read struct tag": { + config: &struct { + S *object `default:"{a: , d: [1,2,3]}"` + }{S: &object{}}, + expect: &struct { + S *object `default:"{a: , d: [1,2,3]}"` + }{S: &object{ + A: "", + D: []int{1, 2, 3}, + }}, + }, +} + +func TestAnyConfig(t *testing.T) { + test.Map(t, anyConfigParams). + RunSeq(func(t test.Test, param AnyConfigParams) { + // Given + // mock.NewMocks(t).Expect(param.setup) + reader := config.NewReader[any]("TC", "test") + reader.SetDefaultConfig("", param.config, true) // When - reader.LoadConfig("test") + err := reader.Unmarshal(param.config) // Then - assert.Equal(t, param.expectEnv, reader.GetString("env")) - assert.Equal(t, param.expectLogLevel, reader.GetString("log.level")) + require.NoError(t, err) + assert.Equal(t, param.expect, param.config) }) } diff --git a/go.mod b/go.mod index 3ab4d07..6d0df3e 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,12 @@ module github.com/tkrop/go-config go 1.25.4 require ( - github.com/go-viper/mapstructure/v2 v2.4.0 github.com/ory/viper v1.7.5 github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tkrop/go-testing v0.0.41 + github.com/tkrop/go-testing v0.0.44 go.uber.org/mock v0.6.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -17,6 +16,7 @@ require ( require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/dgraph-io/ristretto v0.0.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.1 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect @@ -40,6 +40,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - golang.org/x/term v0.36.0 - golang.org/x/text v0.30.0 // indirect + golang.org/x/term v0.37.0 + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index ae8e154..1a0cb23 100644 --- a/go.sum +++ b/go.sum @@ -94,10 +94,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tkrop/go-testing v0.0.36 h1:zqdegv/3kYN/YeFrVdicQ5pk1HXAy+kp3sjV/1Az1U4= -github.com/tkrop/go-testing v0.0.36/go.mod h1:zA+nf5U80EqqEBuRF8PaRjium1UcQmyX27usZCd+fcE= -github.com/tkrop/go-testing v0.0.41 h1:TibdqoYj+DMangabEkETgujITlMwZA9b+IGTjRMtpRE= -github.com/tkrop/go-testing v0.0.41/go.mod h1:zA+nf5U80EqqEBuRF8PaRjium1UcQmyX27usZCd+fcE= +github.com/tkrop/go-testing v0.0.44 h1:axBm94gQ7d0EzuyT/ik/nNrs+fUQZVL9+VzeKkrtDtQ= +github.com/tkrop/go-testing v0.0.44/go.mod h1:rBKZGfm4PpsQl2R/XVcDZFpnS6uGaNMVvOIwCoOn9YQ= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -116,12 +114,12 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/reflect/walker.go b/internal/reflect/walker.go index 25b9d78..3cd8a77 100644 --- a/internal/reflect/walker.go +++ b/internal/reflect/walker.go @@ -2,159 +2,312 @@ package reflect import ( + "errors" + "fmt" "reflect" "slices" "strconv" "strings" + + "gopkg.in/yaml.v3" ) +// ErrTagWalker is a common error to indicate a tag walker error. +var ErrTagWalker = errors.New("tag walker") + +// NewErrTagWalker is a convenience method to create a new tag walker error +// with the given path value context wrapping the original error. +func NewErrTagWalker(message, path string, value any, err error) error { + return fmt.Errorf("%w - %s [%s=%#v]: %w", + ErrTagWalker, message, path, value, err) +} + // TagWalker provides a way to walk through a struct and apply a function to // each field that is settable. type TagWalker struct { dtag, mtag string zero bool + call func(path string, value any) + errors []error } -// NewTagWalker creates a new TagWalker with the given default tag name and -// given map tag name. The walker integrates the [go-defaults][go-defaults] and -// [mapstructure][mapstructure] packages to setup default values in the config -// reader. However, the implementation is not dependent on these packages and -// can be used without them or similar packages. +// NewTagWalker creates a new TagWalker with the given default tag name, map +// tag name, a flag to determine whether to signal zero values, and a callback +// function to be called for each field with a non-zero default value. The +// walker integrates the [go-defaults][defaults] and [mapstructure][mapstr] +// packages to signal paths and default values. However, the implementation is +// not dependent on these packages and can be used with different tag names and +// packages. // -// [go-defaults]: -// [mapstructure]: -func NewTagWalker(dtag, mtag string, zero bool) *TagWalker { - return &TagWalker{dtag: dtag, mtag: mtag, zero: zero} +// The `TagWalker` is used to setup default values in the [reader][reader] +// using a template config with `mapstructure` and `default` tags. +// +// [defaults]: +// [mapstr]: +// [reader]: +func NewTagWalker( + dtag, mtag string, zero bool, + call func(path string, value any), +) *TagWalker { + return &TagWalker{ + dtag: dtag, mtag: mtag, + zero: zero, call: call, + } } -// Walk walks through the fields of the given value and calls the given +// Walk walks through the fields of the given value and calls the callback // function with the path and tag of each field that has a tag. -func (w *TagWalker) Walk( - key string, value any, - call func(path string, value any), -) { - w.walk(strings.ToLower(key), reflect.ValueOf(value), call) +func (w *TagWalker) Walk(path string, value any) error { + w.walk(strings.ToLower(path), reflect.ValueOf(value)) + return errors.Join(w.errors...) } // walk is the internal walker function that is called recursively for each -// element of the given value. The function calls the given function for each +// element of the given value. The function calls the callback function for each // value to apply the path and tag of the field to ensure that all paths can be // provided via environment variables to the config reader. -func (w *TagWalker) walk( - key string, value reflect.Value, - call func(path string, value any), -) { +func (w *TagWalker) walk(path string, value reflect.Value) { switch value.Kind() { case reflect.Ptr: - // TODO: Find test case for this code! - // if value.IsZero() { - // value = reflect.New(value.Type().Elem()) - // } - w.walk(key, value.Elem(), call) + if value.IsZero() { + value = reflect.New(value.Type().Elem()) + } + w.walk(path, value.Elem()) case reflect.Slice, reflect.Array: for index := range value.Len() { - nkey := w.key(key, strconv.Itoa(index)) - w.walk(nkey, value.Index(index), call) + npath := w.path(path, strconv.Itoa(index)) + w.walk(npath, value.Index(index)) } case reflect.Map: - for _, fkey := range value.MapKeys() { - nkey := w.key(key, fkey.String()) - w.walk(nkey, value.MapIndex(fkey), call) + for _, fpath := range value.MapKeys() { + npath := w.path(path, fpath.String()) + w.walk(npath, value.MapIndex(fpath)) } case reflect.Struct: - w.walkStruct(key, value, call) + w.walkStruct(path, value) default: if value.IsValid() && (!value.IsZero() || w.zero) { - call(key, value.Interface()) + w.call(path, value.Interface()) } } } // walkStruct walks through the fields of the given struct value and calls the -// given function with the path and tag of each field that has a tag. On each -// field it also calls recursively the `walk` function depth-first. -func (w *TagWalker) walkStruct( - key string, value reflect.Value, - call func(path string, value any), -) { +// callback function with the path and tag of each field that has a tag. On +// each field it also calls recursively the `walk` function depth-first. +func (w *TagWalker) walkStruct(path string, value reflect.Value) { vtype := value.Type() for index := range value.NumField() { field := vtype.Field(index) if field.IsExported() { - w.walkField(w.field(key, field), - value.Field(index), field, call) + w.walkField(w.field(path, field), + field, value.Field(index)) } } } -// walkField walks through the given field value and calls the given function -// with the path and tag of the field. If the field is a struct, the function -// calls the `walkStruct` function to walk through the struct fields. If the -// field is a pointer, slice, array, or map, the function calls the `walk` -// function to walk through the field elements. +// walkField walks through the given field value and calls the callback +// function with the path and tag of the field. If the field is a struct, the +// function calls the `walkStruct` function to walk through the struct fields. +// If the field is a pointer, slice, array, or map, the function calls the +// `walk` function to walk through the field elements. func (w *TagWalker) walkField( - key string, value reflect.Value, - field reflect.StructField, - call func(path string, value any), + path string, field reflect.StructField, value reflect.Value, ) { switch value.Kind() { case reflect.Struct: - w.walkStruct(key, value, call) + if field.Tag.Get(w.dtag) != "" { + w.callField(path, field) + } else { + w.walkStruct(path, value) + } case reflect.Ptr: if value.IsZero() { value = reflect.New(value.Type().Elem()) } - w.walkField(key, value.Elem(), field, call) + w.walkField(path, field, value.Elem()) case reflect.Slice, reflect.Array, reflect.Map: - if value.Len() == 0 { - call(key, field.Tag.Get(w.dtag)) - } else { - w.walk(key, value, call) + if value.Len() != 0 { + w.walk(path, value) } + w.callField(path, field) default: if value.IsValid() && !value.IsZero() { - call(key, value.Interface()) + w.call(path, value.Interface()) + } else if field.Tag.Get(w.dtag) != "" || w.zero { + w.callField(path, field) + } + } +} + +// callField is the generic callback wrapper function that creates a structured +// value for the callback from the attached `default` tag by parsing it as yaml. +// For complex numbers, which YAML doesn't support natively, we first parse the +// value as string/[]string, then convert to the actual complex type. +func (w *TagWalker) callField(path string, field reflect.StructField) { + if value := field.Tag.Get(w.dtag); value != "" { + fieldType := field.Type + parseType := parseType(fieldType) + ptr := reflect.New(parseType) + + if err := yaml.Unmarshal([]byte(value), ptr.Interface()); err != nil { + w.errors = append(w.errors, + NewErrTagWalker("yaml parsing", path, value, err)) + w.call(path, value) + } else if parseType != fieldType { + w.callComplex(path, ptr.Elem().Interface(), fieldType) } else { - call(key, field.Tag.Get(w.dtag)) + w.call(path, ptr.Elem().Interface()) } } } -// field returns the field key for the given field and whether it is squashed. +// callComplex is a specialized callback wrapper function to handle complex +// number types. It converts the parsed string/[]string value into the actual +// complex type and calls the generic callback function. If conversion fails, +// it appends an error to the walker errors and calls the generic callback +// function with the original value. +func (w *TagWalker) callComplex(path string, value any, fieldType reflect.Type) { + if number, err := toComplex(value, fieldType); err != nil { + w.errors = append(w.errors, + NewErrTagWalker("complex parsing", path, value, err)) + w.call(path, value) + } else { + w.call(path, number) + } +} + +// path is the default path building function. It concatenates the current path +// with the field name separated by a dot `.`. If the path is empty, the field +// name is used as base path. +func (*TagWalker) path(path, name string) string { + if path != "" { + return path + "." + strings.ToLower(name) + } + return strings.ToLower(name) +} + +// field returns the field path for the given field and whether it is squashed. // If the field has a tag, the tag is used as terminal field name. If the tag // is empty, the field name is used as terminal field name. If the tag contains -// a `squash` option, the key is not extended with the field name. -func (w *TagWalker) field( - key string, field reflect.StructField, -) string { +// a `squash` option, the path is not extended with the field name. +func (w *TagWalker) field(path string, field reflect.StructField) string { mtag := field.Tag.Get(w.mtag) if mtag == "" { - return w.key(key, field.Name) + return w.path(path, field.Name) } args := strings.Split(mtag, ",") - if isStruct(field) && slices.Contains(args[1:], "squash") { - return key + if w.isStruct(field) && slices.Contains(args[1:], "squash") { + return path } else if args[0] != "" { - return w.key(key, args[0]) + return w.path(path, args[0]) } - return w.key(key, field.Name) + return w.path(path, field.Name) +} + +// pointer wraps the given result into a pointer type and returns the pointer. +func pointer(result any) any { + ptr := reflect.New(reflect.TypeOf(result)) + ptr.Elem().Set(reflect.ValueOf(result)) + return ptr.Interface() +} + +// parseType returns the appropriate type for parsing the default tag value. +// For complex numbers, which YAML doesn't support natively, we return string +// or []string types so that we first can parse the value in yaml. +func parseType(fieldType reflect.Type) reflect.Type { + switch fieldType.Kind() { + case reflect.Complex64, reflect.Complex128: + return reflect.TypeOf("") + case reflect.Slice, reflect.Array: + if isComplex(fieldType.Elem()) { + return reflect.TypeOf([]string{}) + } + case reflect.Ptr: + ftype := parseType(fieldType.Elem()) + if ftype != fieldType.Elem() { + return ftype + } + } + return fieldType } // isStruct evaluates whether the given field is a struct or a pointer to a // struct. -func isStruct(field reflect.StructField) bool { +func (*TagWalker) isStruct(field reflect.StructField) bool { return (field.Type.Kind() == reflect.Struct || field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct) } -// key is the default key building function. It concatenates the current key -// with the field name separated by a dot `.`. If the key is empty, the field -// name is used as base key. -func (*TagWalker) key(key, name string) string { - if key != "" { - return key + "." + strings.ToLower(name) +// isComplex evaluates whether the given type is a complex number type. +func isComplex(elemType reflect.Type) bool { + return elemType.Kind() == reflect.Complex64 || + elemType.Kind() == reflect.Complex128 +} + +// Complex bit sizes for parsing. +const ( + // Complex64BitSize is the bit size for parsing complex64 numbers. + Complex64BitSize = 64 + // Complex128BitSize is the bit size for parsing complex128 numbers. + Complex128BitSize = 128 +) + +// toComplex converts the parsed string/[]string value into the actual complex +// type based on the target field type. It supports complex64 and complex128 +// types, as well as pointers to these types. +func toComplex(parsed any, fieldType reflect.Type) (any, error) { + switch fieldType.Kind() { + case reflect.Complex64: + return parseValue[complex64](parsed.(string), Complex64BitSize) + case reflect.Complex128: + return parseValue[complex128](parsed.(string), Complex128BitSize) + + case reflect.Slice, reflect.Array: + switch fieldType.Elem().Kind() { + case reflect.Complex64: + return parseSlice[complex64](parsed.([]string), Complex64BitSize) + case reflect.Complex128: + return parseSlice[complex128](parsed.([]string), Complex128BitSize) + } + + case reflect.Ptr: + if result, err := toComplex(parsed, fieldType.Elem()); err == nil { + return pointer(result), nil + } else { + return nil, err + } + } + panic(NewErrTagWalker("unsupported type", "", fieldType, nil)) +} + +// parseValue parses a single complex number string into the specified +// complex type (complex64 or complex128) based on the target bitSize. +func parseValue[T complex64 | complex128]( + str string, bitSize int, +) (T, error) { + if number, err := strconv.ParseComplex( + strings.TrimSpace(str), bitSize); err != nil { + return T(0), err + } else { + return T(number), nil } - return strings.ToLower(name) +} + +// parseSlice parses a slice of complex number strings into a slice +// of the specified complex type based on the target bitSize. +func parseSlice[T complex64 | complex128]( + strs []string, bitSize int, +) ([]T, error) { + result := make([]T, len(strs)) + for index, str := range strs { + if number, err := parseValue[T](str, bitSize); err != nil { + return nil, err + } else { + result[index] = number + } + } + return result, nil } diff --git a/internal/reflect/walker_internal_test.go b/internal/reflect/walker_internal_test.go new file mode 100644 index 0000000..8bd1797 --- /dev/null +++ b/internal/reflect/walker_internal_test.go @@ -0,0 +1,26 @@ +package reflect + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tkrop/go-testing/test" +) + +// TestToComplexPanic tests the defensive error path in toComplex +// for types that should never be passed through the public API. +func TestToComplexPanic(t *testing.T) { + // Given + parsed := "test" + fieldType := reflect.TypeOf(parsed) + defer test.Recover(t, NewErrTagWalker("unsupported type", + "", fieldType, nil)) + + // When + result, err := toComplex(parsed, fieldType) + + // Then + assert.Nil(t, result) + assert.NoError(t, err) +} diff --git a/internal/reflect/walker_test.go b/internal/reflect/walker_test.go index 734de17..b1fe571 100644 --- a/internal/reflect/walker_test.go +++ b/internal/reflect/walker_test.go @@ -1,11 +1,15 @@ package reflect_test import ( + "fmt" + "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/tkrop/go-config/internal/reflect" "github.com/tkrop/go-testing/mock" "github.com/tkrop/go-testing/test" + "gopkg.in/yaml.v3" ) //revive:disable:line-length-limit // go:generate line length. @@ -33,6 +37,7 @@ type TagWalkerParams struct { key string zero bool expect mock.SetupFunc + error error } //revive:disable:nested-structs // simplifies test cases a lot. @@ -113,7 +118,7 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ }, // Test build-in slice values. - "slice-bool": { + "slice-bool-values": { value: []bool{true, false}, zero: true, expect: mock.Chain( @@ -121,7 +126,7 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("1", false), ), }, - "slice-int": { + "slice-int-values": { value: []int{1, 0}, zero: true, expect: mock.Chain( @@ -129,7 +134,7 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("1", 0), ), }, - "slice-uint": { + "slice-uint-values": { value: []uint{1, 0}, zero: true, expect: mock.Chain( @@ -137,7 +142,7 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("1", uint(0)), ), }, - "slice-float": { + "slice-float-values": { value: []float64{1.0, 0.0}, zero: true, expect: mock.Chain( @@ -145,15 +150,15 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("1", 0.0), ), }, - "slice-complex": { - value: []complex128{1.0, 0.0}, + "slice-complex-values": { + value: []complex128{1 + 2i, 3 + 4i}, zero: true, expect: mock.Chain( - Call("0", complex128(1.0)), - Call("1", complex128(0.0)), + Call("0", complex128(1+2i)), + Call("1", complex128(3+4i)), ), }, - "slice-string": { + "slice-string-values": { zero: true, value: []string{"test", ""}, expect: mock.Chain( @@ -161,162 +166,173 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("1", ""), ), }, - "slice-byte": { + "slice-byte-values": { value: []byte{'a', 'b'}, expect: mock.Chain( Call("0", byte('a')), Call("1", byte('b')), ), }, - "slice-rune": { + "slice-rune-values": { value: []rune{'a', 'b'}, expect: mock.Chain( Call("0", rune('a')), Call("1", rune('b')), ), }, - "slice-any": { + "slice-any-values": { value: []any{0, "test"}, expect: mock.Chain( Call("0", 0), Call("1", "test"), ), }, + "slice-nil-ptr-values": { + value: []*struct { + A any `tag:"any"` + }{nil}, + expect: Call("0.a", "any"), + }, // Test struct with field tags. "struct-bool-tags": { value: struct { hidden bool - Visible bool `tag:"visible"` + Visible bool `tag:"true"` }{}, - expect: Call("visible", "visible"), + expect: Call("visible", true), }, + "struct-ints-tags": { value: struct { - I int `tag:"int"` - PI *int `tag:"*int"` - SI []int `tag:"[]int"` - PSI *[]int `tag:"*[]int"` - I8 int8 `tag:"int8"` - I16 int16 `tag:"int16"` - I32 int32 `tag:"int32"` - I64 int64 `tag:"int64"` + I int `tag:"1"` + PI *int `tag:"2"` + SI []int `tag:"[1,2,3]"` + PSI *[]int `tag:"[1,2,3]"` + I8 int8 `tag:"8"` + I16 int16 `tag:"16"` + I32 int32 `tag:"32"` + I64 int64 `tag:"64"` }{}, expect: mock.Chain( - Call("i", "int"), - Call("pi", "*int"), - Call("si", "[]int"), - Call("psi", "*[]int"), - Call("i8", "int8"), - Call("i16", "int16"), - Call("i32", "int32"), - Call("i64", "int64"), + Call("i", int(1)), + Call("pi", test.Ptr(int(2))), + Call("si", []int{1, 2, 3}), + Call("psi", test.Ptr([]int{1, 2, 3})), + Call("i8", int8(8)), + Call("i16", int16(16)), + Call("i32", int32(32)), + Call("i64", int64(64)), ), }, - "struct-uints": { + + "struct-uint-tags": { value: struct { - UI uint `tag:"uint"` - PUI *uint `tag:"*uint"` - SUI []uint `tag:"[]uint"` - PSUI *[]uint `tag:"*[]uint"` - UI8 uint8 `tag:"uint8"` - UI16 uint16 `tag:"uint16"` - UI32 uint32 `tag:"uint32"` - UI64 uint64 `tag:"uint64"` + UI uint `tag:"1"` + PUI *uint `tag:"2"` + SUI []uint `tag:"[1,2,3]"` + PSUI *[]uint `tag:"[1,2,3]"` + UI8 uint8 `tag:"8"` + UI16 uint16 `tag:"16"` + UI32 uint32 `tag:"32"` + UI64 uint64 `tag:"64"` }{}, expect: mock.Chain( - Call("ui", "uint"), - Call("pui", "*uint"), - Call("sui", "[]uint"), - Call("psui", "*[]uint"), - Call("ui8", "uint8"), - Call("ui16", "uint16"), - Call("ui32", "uint32"), - Call("ui64", "uint64"), + Call("ui", uint(1)), + Call("pui", test.Ptr(uint(2))), + Call("sui", []uint{1, 2, 3}), + Call("psui", test.Ptr([]uint{1, 2, 3})), + Call("ui8", uint8(8)), + Call("ui16", uint16(16)), + Call("ui32", uint32(32)), + Call("ui64", uint64(64)), ), }, - "struct-floats": { + + "struct-float-tags": { value: struct { - F32 float32 `tag:"float32"` - F64 float64 `tag:"float64"` - PF32 *float32 `tag:"*float32"` - PF64 *float64 `tag:"*float64"` - SF32 []float32 `tag:"[]float32"` - SF64 []float64 `tag:"[]float64"` - PSF32 *[]float32 `tag:"*[]float32"` - PSF64 *[]float64 `tag:"*[]float64"` + F32 float32 `tag:"32e-1"` + F64 float64 `tag:"64e-1"` + PF32 *float32 `tag:"32e-1"` + PF64 *float64 `tag:"64e-1"` + SF32 []float32 `tag:"[32e-1]"` + SF64 []float64 `tag:"[64e-1]"` + PSF32 *[]float32 `tag:"[32e-1]"` + PSF64 *[]float64 `tag:"[64e-1]"` }{}, expect: mock.Chain( - Call("f32", "float32"), - Call("f64", "float64"), - Call("pf32", "*float32"), - Call("pf64", "*float64"), - Call("sf32", "[]float32"), - Call("sf64", "[]float64"), - Call("psf32", "*[]float32"), - Call("psf64", "*[]float64"), + Call("f32", float32(32e-1)), + Call("f64", float64(64e-1)), + Call("pf32", test.Ptr(float32(32e-1))), + Call("pf64", test.Ptr(float64(64e-1))), + Call("sf32", []float32{32e-1}), + Call("sf64", []float64{64e-1}), + Call("psf32", test.Ptr([]float32{32e-1})), + Call("psf64", test.Ptr([]float64{64e-1})), ), }, - "struct-complex": { + + "struct-complex-tags": { value: struct { - F32 complex64 `tag:"complex64"` - F64 complex128 `tag:"complex128"` - PF32 *complex64 `tag:"*complex64"` - PF64 *complex128 `tag:"*complex128"` - SF32 []complex64 `tag:"[]complex64"` - SF64 []complex128 `tag:"[]complex128"` - PSF32 *[]complex64 `tag:"*[]complex64"` - PSF64 *[]complex128 `tag:"*[]complex128"` + C64 complex64 `tag:"64+2i"` + C128 complex128 `tag:"128+2i"` + PC64 *complex64 `tag:"64+2i"` + PC128 *complex128 `tag:"128+2i"` + SC64 []complex64 `tag:"[64+2i, 32+1i]"` + SC128 []complex128 `tag:"[128+2i, 64+1i]"` + PSC64 *[]complex64 `tag:"[64+2i, 32+1i]"` + PSC128 *[]complex128 `tag:"[128+2i, 64+1i]"` }{}, expect: mock.Chain( - Call("f32", "complex64"), - Call("f64", "complex128"), - Call("pf32", "*complex64"), - Call("pf64", "*complex128"), - Call("sf32", "[]complex64"), - Call("sf64", "[]complex128"), - Call("psf32", "*[]complex64"), - Call("psf64", "*[]complex128"), + Call("c64", complex64(64+2i)), + Call("c128", complex128(128+2i)), + Call("pc64", test.Ptr(complex64(64+2i))), + Call("pc128", test.Ptr(complex128(128+2i))), + Call("sc64", []complex64{64 + 2i, 32 + 1i}), + Call("sc128", []complex128{128 + 2i, 64 + 1i}), + Call("psc64", test.Ptr([]complex64{64 + 2i, 32 + 1i})), + Call("psc128", test.Ptr([]complex128{128 + 2i, 64 + 1i})), ), }, - "struct-strings": { + + "struct-string-tags": { value: struct { S string `tag:"string"` - PS *string `tag:"*string"` - B byte `tag:"uint8"` - PB *byte `tag:"*uint8"` - SB []byte `tag:"[]uint8"` - PSB *[]byte `tag:"*[]uint8"` - R rune `tag:"int32"` - PR *rune `tag:"*int32"` - SR []rune `tag:"[]int32"` - PSR *[]rune `tag:"*[]int32"` + PS *string `tag:"string"` + B byte `tag:"117"` + PB *byte `tag:"42"` + SB []byte `tag:"[117,105,110,116,56]"` + PSB *[]byte `tag:"[42,117,105,110,116,56]"` + R rune `tag:"105"` + PR *rune `tag:"42"` + SR []rune `tag:"[105,110,116,51,50]"` + PSR *[]rune `tag:"[42,105,110,116,51,50]"` }{}, expect: mock.Chain( Call("s", "string"), - Call("ps", "*string"), - Call("b", "uint8"), - Call("pb", "*uint8"), - Call("sb", "[]uint8"), - Call("psb", "*[]uint8"), - Call("r", "int32"), - Call("pr", "*int32"), - Call("sr", "[]int32"), - Call("psr", "*[]int32"), + Call("ps", test.Ptr("string")), + Call("b", byte('u')), + Call("pb", test.Ptr(byte('*'))), + Call("sb", []byte("uint8")), + Call("psb", test.Ptr([]byte("*uint8"))), + Call("r", rune('i')), + Call("pr", test.Ptr(rune('*'))), + Call("sr", []rune("int32")), + Call("psr", test.Ptr([]rune("*int32"))), ), }, // Test structs with field values. "struct-all-values": { value: struct { - Bool bool `map:"bool" default:"false"` - Int int `map:"int" default:"-2"` - Uint uint `map:"uint" default:"2"` - Float float64 `map:"float" default:"3.0"` - String string `map:"string" default:"STRING"` - Byte byte `map:"byte" default:"A"` - Rune rune `map:"rune" default:"B"` - Any any `map:"any" default:"ANY"` + Bool bool `map:"bool" tag:"false"` + Int int `map:"int" tag:"-2"` + Uint uint `map:"uint" tag:"2"` + Float float64 `map:"float" tag:"3.0"` + String string `map:"string" tag:"STRING"` + Byte byte `map:"byte" tag:"A"` + Rune rune `map:"rune" tag:"B"` + Any any `map:"any" tag:"ANY"` }{ Bool: true, Int: int(-1), @@ -353,21 +369,17 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ value: struct { S struct { A any `tag:"any"` - } `tag:"struct{any}"` + } }{}, - expect: mock.Chain( - Call("s.a", "any"), - ), + expect: Call("s.a", "any"), }, "struct-ptr-struct": { value: struct { S *struct { A any `tag:"any"` - } `tag:"*struct{any}"` + } }{}, - expect: mock.Chain( - Call("s.a", "any"), - ), + expect: Call("s.a", "any"), }, // Test pointer struct with nested structs. @@ -384,70 +396,83 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ value: &struct { S struct { A any `tag:"any"` - } `tag:"struct{any}"` + } }{}, - expect: mock.Chain( - Call("s.a", "any"), - ), + expect: Call("s.a", "any"), }, - "ptr-struct-ptr-struct": { + "ptr-struct-ptr-struct-tags": { value: &struct { S *struct { A any `tag:"any"` - } `tag:"*struct{any}"` + } `tag:"{a: any}"` }{}, expect: mock.Chain( - Call("s.a", "any"), + Call("s", &struct { + A any `tag:"any"` + }{A: "any"}), ), }, // Test struct with nested slices and tags. - "struct-slice-tag": { + "struct-slice-tags": { value: struct { - S []any `tag:"[]any"` + S []any `tag:"[any,all]"` }{}, - expect: Call("s", "[]any"), + expect: Call("s", []any{"any", "all"}), }, - "struct-slice-struct-tag": { + "struct-slice-struct-tags": { value: struct { S []struct { A any `tag:"any"` - } `tag:"[]struct{any}"` + } `tag:"[{a: any},{a: all}]"` }{}, - expect: Call("s", "[]struct{any}"), + expect: Call("s", []struct { + A any `tag:"any"` + }{{A: "any"}, {A: "all"}}), }, - "struct-slice-ptr-struct-tag": { + "struct-slice-ptr-struct-tags": { value: struct { S []*struct { A any `tag:"any"` - } `tag:"[]*struct{any}"` + } `tag:"[{a: any},{a: all}]"` }{}, - expect: Call("s", "[]*struct{any}"), + expect: Call("s", []*struct { + A any `tag:"any"` + }{{A: "any"}, {A: "all"}}), }, - "struct-ptr-slice-ptr-struct-tag": { + "struct-ptr-slice-ptr-struct-tags": { value: struct { S *[]*struct { A any `tag:"any"` - } `tag:"*[]*struct{any}"` + } `tag:"[{a: any},{a: all}]"` }{}, - expect: Call("s", "*[]*struct{any}"), + expect: Call("s", &[]*struct { + A any `tag:"any"` + }{ + test.Ptr(struct { + A any `tag:"any"` + }{A: "any"}), + test.Ptr(struct { + A any `tag:"any"` + }{A: "all"}), + }), }, // Test struct with nested slices and values. - "struct-slice-value": { + "struct-slice-values": { value: struct { - S []any `tag:"[]any"` + S []any }{S: []any{1, 2}}, expect: mock.Chain( Call("s.0", 1), Call("s.1", 2), ), }, - "struct-slice-struct-value": { + "struct-slice-struct-values": { value: struct { S []struct { A any `tag:"any"` - } `tag:"[]struct{any}"` + } }{S: []struct { A any `tag:"any"` }{{A: 1}, {A: 2}}}, @@ -456,11 +481,11 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("s.1.a", 2), ), }, - "struct-slice-ptr-struct-value": { + "struct-slice-ptr-struct-values": { value: struct { S []*struct { A any `tag:"any"` - } `tag:"[]*struct{any}"` + } }{S: []*struct { A any `tag:"any"` }{{A: 1}, {A: 2}}}, @@ -469,11 +494,11 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("s.1.a", 2), ), }, - "struct-ptr-slice-ptr-struct-value": { + "struct-ptr-slice-ptr-struct-values": { value: struct { S *[]*struct { A any `tag:"any"` - } `tag:"*[]*struct{any}"` + } }{S: &[]*struct { A any `tag:"any"` }{{A: 1}, {A: 2}}}, @@ -484,61 +509,82 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ }, // Test struct with nested maps. - "struct-map-tag": { + "struct-map-tags": { value: struct { - M map[string]any `tag:"map[string]any"` + M map[string]any `tag:"{a: any, b: {a: all}}"` }{}, - expect: Call("m", "map[string]any"), + expect: Call("m", map[string]any{ + "a": "any", + "b": map[string]any{"a": "all"}, + }), }, - "struct-ptr-map-tag": { + "struct-ptr-map-tags": { value: struct { - M *map[string]any `tag:"*map[string]any"` + M *map[string]any `tag:"{a: any, b: {a: all}}"` }{}, - expect: Call("m", "*map[string]any"), + expect: Call("m", &map[string]any{ + "a": "any", + "b": map[string]any{"a": "all"}, + }), }, - "struct-map-struct-tag": { + "struct-map-struct-tags": { value: &struct { M map[string]struct { A any `tag:"any"` - } `tag:"map[string]struct{any}"` + } `tag:"{a: {a: any},b: {a: all}}"` }{}, - expect: Call("m", "map[string]struct{any}"), + expect: Call("m", map[string]struct { + A any `tag:"any"` + }{ + "a": {A: "any"}, + "b": {A: "all"}, + }), }, - "struct-ptr-map-struct-tag": { + "struct-ptr-map-struct-tags": { value: &struct { M map[string]struct { A any `tag:"any"` - } `tag:"*map[string]struct{any}"` + } `tag:"{a: {a: any},b: {a: all} }"` }{}, - expect: Call("m", "*map[string]struct{any}"), + expect: Call("m", map[string]struct { + A any `tag:"any"` + }{ + "a": {A: "any"}, + "b": {A: "all"}, + }), }, - "struct-map-ptr-struct-tag": { + "struct-map-ptr-struct-tags": { value: &struct { M map[string]*struct { A any `tag:"any"` - } `tag:"map[string]*struct{any}"` + } `tag:"{a: {a: any},b: {a: all}}"` }{}, - expect: Call("m", "map[string]*struct{any}"), + expect: Call("m", map[string]*struct { + A any `tag:"any"` + }{ + "a": {A: "any"}, + "b": {A: "all"}, + }), }, // Test struct with nested maps. - "struct-map-value": { + "struct-map-values": { value: struct { - M map[string]any `tag:"map[string]any"` + M map[string]any }{M: map[string]any{"key": "value"}}, expect: Call("m.key", "value"), }, - "struct-ptr-map-value": { + "struct-ptr-map-values": { value: struct { - M *map[string]any `tag:"*map[string]any"` + M *map[string]any }{M: &map[string]any{"key": "value"}}, expect: Call("m.key", "value"), }, - "struct-map-struct-value": { + "struct-map-struct-values": { value: struct { M map[string]struct { A any `tag:"any"` - } `tag:"map[string]struct{any}"` + } }{M: map[string]struct { A any `tag:"any"` }{"key-0": {A: 1}, "key-1": {A: 2}}}, @@ -547,11 +593,11 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("m.key-1.a", 2), ), }, - "struct-ptr-map-struct-value": { + "struct-ptr-map-struct-values": { value: struct { M *map[string]struct { A any `tag:"any"` - } `tag:"*map[string]struct{any}"` + } }{M: &map[string]struct { A any `tag:"any"` }{"key-0": {A: 1}, "key-1": {A: 2}}}, @@ -560,11 +606,11 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ Call("m.key-1.a", 2), ), }, - "struct-ptr-map-ptr-struct-value": { + "struct-ptr-map-ptr-struct-values": { value: struct { M *map[string]*struct { A any `tag:"any"` - } `tag:"map[string]*struct{any}"` + } }{M: &map[string]*struct { A any `tag:"any"` }{"key-0": {A: 1}, "key-1": {A: 2}}}, @@ -574,69 +620,121 @@ var tagWalkerTestCases = map[string]TagWalkerParams{ ), }, - // Test map structure tags. - "map-name": { + // Test with special tags. + "tag-map-squash": { + value: struct { + S struct { + A any `tag:"any"` + } `map:",squash"` + }{}, + expect: Call("a", "any"), + }, + "tag-map-remain": { + value: struct { + Field any `map:",remain" tag:"any"` + }{}, + expect: Call("field", "any"), + }, + "tag-yaml-slice": { value: &struct { - M any `map:"X" tag:"any"` + S []string `tag:"[a,b]"` }{}, - expect: Call("x", "any"), + expect: Call("s", []string{"a", "b"}), }, - "map-squash": { + "tag-yaml-map": { value: &struct { S struct { - A *any `map:"X" tag:"*any"` - } `map:",squash" tag:"struct{*any}"` + A string `tag:"v"` + B []string + C string + } `tag:"{a: a, b: [a,b], c: c}"` }{}, expect: mock.Chain( - Call("x", "*any"), + Call("s", struct { + A string `tag:"v"` + B []string + C string + }{A: "a", B: []string{"a", "b"}, C: "c"}), ), }, - "map-empty": { + "tag-yaml-error": { value: &struct { - S struct { - A *any `map:",omitempty" tag:"*any"` - } `map:",squash" tag:"struct{*any}"` + S []string `tag:"a,b"` }{}, - expect: mock.Chain( - Call("a", "*any"), + expect: mock.Setup( + Call("s", "a,b"), ), + error: fmt.Errorf("%w - %s [%s=%s]: %w", + reflect.ErrTagWalker, "yaml parsing", "s", "\"a,b\"", + &yaml.TypeError{Errors: []string{ + "line 1: cannot unmarshal !!str `a,b` into []string", + }}), }, - "map-remain": { + + // Complex number parsing errors + "tag-yaml-complex-invalid": { value: &struct { - S struct { - A *any `map:",omitempty" tag:"*any"` - R map[string]any `map:",remain" tag:"map[string]any"` - } `map:",squash" tag:"struct{*any}"` + C complex64 `tag:"invalid"` }{}, - expect: mock.Chain( - Call("a", "*any"), - Call("r", "map[string]any"), + expect: mock.Setup( + Call("c", "invalid"), ), - }, - "map-comma": { + error: fmt.Errorf("%w - %s [%s=%s]: %w", + reflect.ErrTagWalker, "complex parsing", "c", "\"invalid\"", + &strconv.NumError{ + Func: "ParseComplex", + Num: "invalid", + Err: strconv.ErrSyntax, + }), + }, + "tag-yaml-complex-slice-invalid": { value: &struct { - S struct { - A *any `map:"," tag:"*any"` - } `map:",squash" tag:"struct{*any}"` + SC []complex64 `tag:"[invalid, 1+2i]"` }{}, - expect: mock.Chain( - Call("a", "*any"), + expect: mock.Setup( + Call("sc", []string{"invalid", "1+2i"}), ), + error: fmt.Errorf("%w - %s [%s=%#v]: %w", + reflect.ErrTagWalker, "complex parsing", "sc", + []string{"invalid", "1+2i"}, + &strconv.NumError{ + Func: "ParseComplex", + Num: "invalid", + Err: strconv.ErrSyntax, + }), + }, + "tag-yaml-complex-ptr-slice-invalid": { + value: &struct { + PSC *[]complex64 `tag:"[invalid, 1+2i]"` + }{}, + expect: mock.Setup( + Call("psc", []string{"invalid", "1+2i"}), + ), + error: fmt.Errorf("%w - %s [%s=%#v]: %w", + reflect.ErrTagWalker, "complex parsing", "psc", + []string{"invalid", "1+2i"}, + &strconv.NumError{ + Func: "ParseComplex", + Num: "invalid", + Err: strconv.ErrSyntax, + }), }, } -// TestTagWalker_Walk tests TagWalker.Walk. -func TestTagWalker_Walk(t *testing.T) { +// TestTagWalker tests TagWalker.Walk. +func TestTagWalker(t *testing.T) { test.Map(t, tagWalkerTestCases). + // Filter(test.Pattern[TagWalkerParams]("struct-string-tags")). Run(func(t test.Test, param TagWalkerParams) { // Given mocks := mock.NewMocks(t).Expect(param.expect) - walker := reflect.NewTagWalker("tag", "map", param.zero) + walker := reflect.NewTagWalker("tag", "map", param.zero, + mock.Get(mocks, NewMockCallback).Call) // When - walker.Walk(param.key, param.value, - mock.Get(mocks, NewMockCallback).Call) + err := walker.Walk(param.key, param.value) // Then + assert.Equal(t, param.error, err) }) }