diff --git a/README.md b/README.md index 71d658a..4a45c67 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Package ini reads and writes INI files in Go. - **Several sources**: files, `[]byte` and `io.Reader`, with later sources overriding earlier ones. - **Rich INI syntax**: parent-child sections, auto-increment keys, repeated (shadow) keys, nested and multiline values, and `%(name)s` references. - **Typed values**: numbers, booleans, durations and times, with generic lists and candidates of any of them, and ranges of all but booleans. -- **Struct mapping**: fill a struct from a file, or a file from a struct, guided by `ini` struct tags. +- **Struct mapping**: fill a struct from a file, or a file from a struct, guided by `ini` struct tags. Types can format and parse their own values with `MarshalINI` and `UnmarshalINI`. - **Safe writes**: comments and order are kept, and what is written loads back the same, or `Write` returns an error. - **Precise errors**: parse errors give the line and column. - **Concurrency**: safe for concurrent use, or lock-free with `Locking(false)` for files that never change. diff --git a/ini.go b/ini.go index 4351161..cf5f779 100644 --- a/ini.go +++ b/ini.go @@ -18,7 +18,8 @@ // MapTo and StrictMapTo fill a struct from a data source or a File, and // File.ReflectFrom fills a File from a struct. Field names map to key and // section names unchanged unless NameMapping sets a mapper, and the ini, -// delim and comment struct tags adjust each field. +// delim and comment struct tags adjust each field. A type that implements +// Marshaler or Unmarshaler formats or parses its own values. // // File.Write and File.SaveTo write a File back with its comments and in its // order, and WriteOption values set the formatting. What Write writes loads diff --git a/key.go b/key.go index 40d3063..ea96a7c 100644 --- a/key.go +++ b/key.go @@ -510,7 +510,8 @@ func (k *Key) SetValue(v string) { // Scalar is a type a value can be parsed as, as struct mapping parses // fields: strings, booleans, integers and floats of any size, named types of // those, time.Duration (as "1s" or nanoseconds) and time.Time (RFC 3339). -// A number that doesn't fit the type doesn't parse. +// A number that doesn't fit the type doesn't parse. A type that implements +// Unmarshaler parses with UnmarshalINI instead. type Scalar interface { ~string | ~bool | ~int | ~int8 | ~int16 | ~int32 | ~int64 | diff --git a/struct.go b/struct.go index c73efe8..149906f 100644 --- a/struct.go +++ b/struct.go @@ -61,18 +61,82 @@ func parseDelim(actual string) string { return "," } -var timeType = reflect.TypeOf(time.Time{}) +// Marshaler is implemented by types that format themselves as a key value. +// ReflectFrom calls MarshalINI for fields, pointer fields and slice elements +// of such a type, whatever its kind. A type that isn't a string, boolean or +// number also needs Unmarshaler, as described there. +type Marshaler interface { + MarshalINI() ([]byte, error) +} + +// Unmarshaler is implemented by types that parse themselves from a key value. +// MapTo and StrictMapTo call UnmarshalINI for fields, pointer fields and slice +// elements of such a type, with the value as Key.String returns it, or with +// one element of a list, and so do List, ValidList, StrictList, In and Range +// for a Scalar type. An error is handled as for a value that doesn't parse: +// MapTo leaves a field unchanged and makes an element of a slice field the +// zero value, and StrictMapTo and StrictList return it. +// +// A type that isn't a string, boolean or number, such as a struct, slice or +// map type, maps to one key only when it implements both Marshaler and +// Unmarshaler, so that it maps the same way in both directions. Otherwise a +// struct type still maps to a section and a slice type to a list. A nonunique +// field, or a value passed for a section of a file with +// AllowNonUniqueSections, maps a struct type to sections either way. A struct that embeds such a type +// gets its methods, and so maps to one key too. +type Unmarshaler interface { + UnmarshalINI([]byte) error +} + +var ( + timeType = reflect.TypeFor[time.Time]() + marshalerType = reflect.TypeFor[Marshaler]() + unmarshalerType = reflect.TypeFor[Unmarshaler]() +) + +// isMarshaler and isUnmarshaler report whether t, or a pointer to t, +// implements Marshaler or Unmarshaler. +func isMarshaler(t reflect.Type) bool { + return reflect.PointerTo(t).Implements(marshalerType) +} + +func isUnmarshaler(t reflect.Type) bool { + return reflect.PointerTo(t).Implements(unmarshalerType) +} + +// isCustomType reports whether t formats or parses itself, so that it maps +// to a key value whatever its kind. A string, boolean or number type needs +// one method, as its kind handles the other direction. Any other type needs +// both. +func isCustomType(t reflect.Type) bool { + switch t.Kind() { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return isMarshaler(t) || isUnmarshaler(t) + } + return isMarshaler(t) && isUnmarshaler(t) +} // isStructType reports whether t is a struct type that maps to a section, -// which is any struct type but time.Time. +// which is any struct type but time.Time and custom types. func isStructType(t reflect.Type) bool { + return isSectionsType(t) && !isCustomType(t) +} + +// isSectionsType reports whether t can map to sections where they are asked +// for explicitly, by a nonunique field or a value passed for a section of a +// file with AllowNonUniqueSections: any struct type but time.Time, custom +// types too. +func isSectionsType(t reflect.Type) bool { return t.Kind() == reflect.Struct && t != timeType } // isValueType reports whether a key value can be parsed as or formatted from // a value of type t. func isValueType(t reflect.Type) bool { - if t == timeType { + if t == timeType || isCustomType(t) { return true } switch t.Kind() { @@ -87,7 +151,7 @@ func isValueType(t reflect.Type) bool { // isSliceElemType reports whether slices of t are supported. func isSliceElemType(t reflect.Type) bool { - if t == timeType { + if t == timeType || isCustomType(t) { return true } switch t.Kind() { @@ -100,6 +164,10 @@ func isSliceElemType(t reflect.Type) bool { // parseValue parses str as a value of type t, for which isValueType is true. func parseValue(t reflect.Type, str string) (reflect.Value, error) { v := reflect.New(t).Elem() + if isUnmarshaler(t) { + err := v.Addr().Interface().(Unmarshaler).UnmarshalINI([]byte(str)) + return v, err + } if t == timeType { ti, err := time.Parse(time.RFC3339, str) if err != nil { @@ -158,23 +226,40 @@ func parseValue(t reflect.Type, str string) (reflect.Value, error) { } // formatValue does the opposite thing as parseValue. -func formatValue(v reflect.Value) string { - if v.Type() == timeType { - return v.Interface().(time.Time).Format(time.RFC3339Nano) +func formatValue(v reflect.Value) (string, error) { + t := v.Type() + if isMarshaler(t) { + var m Marshaler + switch { + case t.Implements(marshalerType): + m = v.Interface().(Marshaler) + case v.CanAddr(): + m = v.Addr().Interface().(Marshaler) + default: + // MarshalINI has a pointer receiver, so call it on a copy. + p := reflect.New(t) + p.Elem().Set(v) + m = p.Interface().(Marshaler) + } + b, err := m.MarshalINI() + return string(b), err + } + if t == timeType { + return v.Interface().(time.Time).Format(time.RFC3339Nano), nil } switch v.Kind() { case reflect.String: - return v.String() + return v.String(), nil case reflect.Bool: - return fmt.Sprint(v.Bool()) + return fmt.Sprint(v.Bool()), nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return fmt.Sprint(v.Int()) + return fmt.Sprint(v.Int()), nil case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return fmt.Sprint(v.Uint()) + return fmt.Sprint(v.Uint()), nil case reflect.Float32, reflect.Float64: - return fmt.Sprint(v.Float()) + return fmt.Sprint(v.Float()), nil } - return "" + return "", fmt.Errorf("unsupported type %q", t) } // defaultComment sets *c to comment if it is empty, under the file's lock. @@ -258,7 +343,7 @@ func wrapStrictError(err error, isStrict bool) error { // leaves the field as it is, so it keeps its default, and is an error only // when isStrict. An unsupported type is always an error. func decodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { - if t.Kind() == reflect.Slice { + if t.Kind() == reflect.Slice && !isCustomType(t) { return decodeSlice(key, field, delim, allowShadow, isStrict) } @@ -272,8 +357,9 @@ func decodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, al } str := key.String() - // An empty string keeps the default value of a string field. - if vt.Kind() == reflect.String && len(str) == 0 && !isPtr { + // An empty string keeps the default value of a string field, unless + // the type parses itself. + if vt.Kind() == reflect.String && len(str) == 0 && !isPtr && !isUnmarshaler(vt) { return nil } val, err := parseValue(vt, str) @@ -402,7 +488,7 @@ func (s *Section) decodeSections(secName string, val reflect.Value, isStrict boo if isPtr { structType = elemType.Elem() } - if !isStructType(structType) { + if !isSectionsType(structType) { return reflect.Value{}, fmt.Errorf("unsupported type %q for non-unique sections", val.Type()) } @@ -515,7 +601,10 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) vals := make([]string, field.Len()) for i := range vals { - vals[i] = formatValue(field.Index(i)) + var err error + if vals[i], err = formatValue(field.Index(i)); err != nil { + return err + } } if allowShadow { @@ -576,14 +665,18 @@ func escapeSliceElem(s, delim string) string { // encodeValue does the opposite thing as decodeValue. func encodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow bool) error { switch { - case t.Kind() == reflect.Slice: + case t.Kind() == reflect.Slice && !isCustomType(t): return encodeSlice(key, field, delim, allowShadow) case t.Kind() == reflect.Pointer: if !field.IsNil() { return encodeValue(t.Elem(), key, field.Elem(), delim, allowShadow) } case isValueType(t): - key.SetValue(formatValue(field)) + v, err := formatValue(field) + if err != nil { + return err + } + key.SetValue(v) default: return fmt.Errorf("unsupported type %q", t) } @@ -706,7 +799,7 @@ func (s *Section) encodeStruct(val reflect.Value, path []reflect.Type) error { if structType.Kind() == reflect.Pointer { structType = structType.Elem() } - if !isStructType(structType) { + if !isSectionsType(structType) { return fmt.Errorf("field %q is not a slice of pointer or struct", fieldName) } if tooDeep(path, structType) { @@ -770,10 +863,10 @@ func (s *Section) ReflectFrom(v any) error { if !s.f.isDefaultSection(s.name) && s.f.options.AllowNonUniqueSections && (typ.Kind() == reflect.Slice || typ.Kind() == reflect.Pointer) { - if typ.Kind() == reflect.Pointer && !isStructType(typ.Elem()) { + if typ.Kind() == reflect.Pointer && !isSectionsType(typ.Elem()) { return errors.New("not a pointer to a struct") } - if typ.Kind() == reflect.Slice && (typ.Elem().Kind() != reflect.Pointer || !isStructType(typ.Elem().Elem())) { + if typ.Kind() == reflect.Slice && (typ.Elem().Kind() != reflect.Pointer || !isSectionsType(typ.Elem().Elem())) { return errors.New("not a slice of pointers to structs") } diff --git a/struct_matrix_test.go b/struct_matrix_test.go new file mode 100644 index 0000000..6bd4cac --- /dev/null +++ b/struct_matrix_test.go @@ -0,0 +1,540 @@ +package ini + +import ( + "bytes" + "errors" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "testing" +) + +// The Mx types cover each kind that struct mapping treats differently, +// without methods, with only MarshalINI, with only UnmarshalINI, and with +// both. A method of a type that must not use it returns errMxCalled. The +// methods of the other types read what they write, in a format that differs +// from the kind's own where both are implemented, so that the test sees which +// one ran. +var errMxCalled = errors.New("method must not be called") + +type ( + MxIntNone int + MxIntM int + MxIntU int + MxIntMU int +) + +func (v MxIntM) MarshalINI() ([]byte, error) { return []byte(strconv.Itoa(int(v))), nil } + +func (v *MxIntU) UnmarshalINI(b []byte) error { + n, err := strconv.Atoi(string(b)) + *v = MxIntU(n) + return err +} + +func (v MxIntMU) MarshalINI() ([]byte, error) { return []byte("#" + strconv.Itoa(int(v))), nil } + +func (v *MxIntMU) UnmarshalINI(b []byte) error { + s, ok := strings.CutPrefix(string(b), "#") + if !ok { + return fmt.Errorf("missing # in %q", b) + } + n, err := strconv.Atoi(s) + *v = MxIntMU(n) + return err +} + +type ( + MxStructNone struct{ A string } + MxStructM struct{ A string } + MxStructU struct{ A string } + MxStructMU struct{ A string } +) + +func (MxStructM) MarshalINI() ([]byte, error) { return nil, errMxCalled } + +func (*MxStructU) UnmarshalINI([]byte) error { return errMxCalled } + +// MarshalINI has a pointer receiver, unlike the other types. +func (v *MxStructMU) MarshalINI() ([]byte, error) { return []byte("<" + v.A + ">"), nil } + +func (v *MxStructMU) UnmarshalINI(b []byte) error { + s, ok := strings.CutPrefix(string(b), "<") + if s, ok2 := strings.CutSuffix(s, ">"); ok && ok2 { + v.A = s + return nil + } + return fmt.Errorf("missing <> in %q", b) +} + +type ( + MxSliceNone []string + MxSliceM []string + MxSliceU []string + MxSliceMU []string +) + +func (MxSliceM) MarshalINI() ([]byte, error) { return nil, errMxCalled } + +func (*MxSliceU) UnmarshalINI([]byte) error { return errMxCalled } + +func (v MxSliceMU) MarshalINI() ([]byte, error) { return []byte(strings.Join(v, "+")), nil } + +func (v *MxSliceMU) UnmarshalINI(b []byte) error { + *v = nil + if len(b) > 0 { + *v = strings.Split(string(b), "+") + } + return nil +} + +type ( + MxMapNone map[string]string + MxMapM map[string]string + MxMapU map[string]string + MxMapMU map[string]string +) + +func (MxMapM) MarshalINI() ([]byte, error) { return nil, errMxCalled } + +func (*MxMapU) UnmarshalINI([]byte) error { return errMxCalled } + +func (v MxMapMU) MarshalINI() ([]byte, error) { + var pairs []string + for k, val := range v { + pairs = append(pairs, k+":"+val) + } + sort.Strings(pairs) + return []byte(strings.Join(pairs, "&")), nil +} + +func (v *MxMapMU) UnmarshalINI(b []byte) error { + *v = nil + if len(b) == 0 { + return nil + } + *v = MxMapMU{} + for _, pair := range strings.Split(string(b), "&") { + k, val, ok := strings.Cut(pair, ":") + if !ok { + return fmt.Errorf("missing : in %q", pair) + } + (*v)[k] = val + } + return nil +} + +type ( + MxArrayNone [2]string + MxArrayM [2]string + MxArrayU [2]string + MxArrayMU [2]string +) + +func (MxArrayM) MarshalINI() ([]byte, error) { return nil, errMxCalled } + +func (*MxArrayU) UnmarshalINI([]byte) error { return errMxCalled } + +func (v MxArrayMU) MarshalINI() ([]byte, error) { return []byte(v[0] + "~" + v[1]), nil } + +func (v *MxArrayMU) UnmarshalINI(b []byte) error { + a, c, ok := strings.Cut(string(b), "~") + if !ok { + return fmt.Errorf("missing ~ in %q", b) + } + *v = MxArrayMU{a, c} + return nil +} + +// mxSamples holds a value of each Mx type that is not the zero value. +var mxSamples = []any{ + MxIntNone(5), MxIntM(5), MxIntU(5), MxIntMU(5), + MxStructNone{"a"}, MxStructM{"a"}, MxStructU{"a"}, MxStructMU{"a"}, + MxSliceNone{"a", "b"}, MxSliceM{"a", "b"}, MxSliceU{"a", "b"}, MxSliceMU{"a", "b"}, + MxMapNone{"k": "v"}, MxMapM{"k": "v"}, MxMapU{"k": "v"}, MxMapMU{"k": "v"}, + MxArrayNone{"a", "b"}, MxArrayM{"a", "b"}, MxArrayU{"a", "b"}, MxArrayMU{"a", "b"}, +} + +// mxInputs are inputs that read a value into a field F, or into F in a +// section S, in each of the forms struct mapping reads. +var mxInputs = []string{ + "F = 5\n", "F = #5\n", "F = a,b\n", "F = a+b\n", "F = \n", "F = k:v\n", + "F = ,\n", "F = #5,#6\n", "F = a~b\n", + "[F]\nA = a\n", "[F]\nA = a\n[F]\nA = b\n", + "[S]\nF = 5\n", "[S]\nF = #5\n", "[S]\nF = a,b\n", "[S]\nF = a+b\n", "[S]\nF = \n", "[S]\nF = k:v\n", + "[F]\nA = a\n[S]\nB = b\n", "[S]\nA = a\nB = b\n", +} + +// mxShape is a way to hold a value of a type in a struct. +type mxShape struct { + name string + // typ returns the struct type that holds t, or nil when t can't be + // held that way. + typ func(t reflect.Type) reflect.Type + // set stores the sample value v of type t in the struct s. + set func(s, v reflect.Value) + // opts are the options the file is loaded and written with. + opts []LoadOption +} + +var mxShapes = []mxShape{ + { + name: "allowshadow field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.SliceOf(t), Tag: `ini:"F,,allowshadow"`}}) + }, + set: func(s, v reflect.Value) { + s.Field(0).Set(reflect.Append(reflect.MakeSlice(s.Field(0).Type(), 0, 2), v, v)) + }, + opts: []LoadOption{AllowShadows(true), AllowDuplicateShadowValues(true)}, + }, + { + name: "delim field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.SliceOf(t), Tag: `delim:"|"`}}) + }, + set: func(s, v reflect.Value) { + s.Field(0).Set(reflect.Append(reflect.MakeSlice(s.Field(0).Type(), 0, 2), v, v)) + }, + }, + { + name: "field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: t}}) + }, + set: func(s, v reflect.Value) { s.Field(0).Set(v) }, + }, + { + name: "pointer field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.PointerTo(t)}}) + }, + set: func(s, v reflect.Value) { + p := reflect.New(v.Type()) + p.Elem().Set(v) + s.Field(0).Set(p) + }, + }, + { + name: "slice field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.SliceOf(t)}}) + }, + set: func(s, v reflect.Value) { + s.Field(0).Set(reflect.Append(reflect.MakeSlice(s.Field(0).Type(), 0, 2), v, v)) + }, + }, + { + name: "double pointer field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.PointerTo(reflect.PointerTo(t))}}) + }, + set: func(s, v reflect.Value) { + p := reflect.New(v.Type()) + p.Elem().Set(v) + pp := reflect.New(p.Type()) + pp.Elem().Set(p) + s.Field(0).Set(pp) + }, + }, + { + name: "array field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.ArrayOf(2, t)}}) + }, + set: func(s, v reflect.Value) { + s.Field(0).Index(0).Set(v) + s.Field(0).Index(1).Set(v) + }, + }, + { + name: "interface field", + typ: func(reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.TypeFor[any]()}}) + }, + set: func(s, v reflect.Value) { s.Field(0).Set(v) }, + }, + { + name: "field in a section", + typ: func(t reflect.Type) reflect.Type { + inner := reflect.StructOf([]reflect.StructField{{Name: "F", Type: t}}) + return reflect.StructOf([]reflect.StructField{{Name: "S", Type: inner}}) + }, + set: func(s, v reflect.Value) { s.Field(0).Field(0).Set(v) }, + }, + { + name: "nonunique field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.SliceOf(t), Tag: `ini:"F,,,nonunique"`}}) + }, + set: func(s, v reflect.Value) { + s.Field(0).Set(reflect.Append(reflect.MakeSlice(s.Field(0).Type(), 0, 2), v, v)) + }, + opts: []LoadOption{AllowNonUniqueSections(true)}, + }, + { + name: "nonunique pointer field", + typ: func(t reflect.Type) reflect.Type { + return reflect.StructOf([]reflect.StructField{{Name: "F", Type: reflect.SliceOf(reflect.PointerTo(t)), Tag: `ini:"F,,,nonunique"`}}) + }, + set: func(s, v reflect.Value) { + p := reflect.New(v.Type()) + p.Elem().Set(v) + s.Field(0).Set(reflect.Append(reflect.MakeSlice(s.Field(0).Type(), 0, 2), p, p)) + }, + opts: []LoadOption{AllowNonUniqueSections(true)}, + }, +} + +// mxResult is what happened to one value: "ok" when it was written and read +// back the same, or the step that failed. +func mxRoundTrip(in reflect.Value, opts []LoadOption) string { + f := Empty(opts...) + if err := f.ReflectFrom(in.Addr().Interface()); err != nil { + return "write error" + } + var buf bytes.Buffer + if _, err := f.Write(&buf); err != nil { + return "write error" + } + out := reflect.New(in.Type()) + if err := StrictMapTo(out.Interface(), buf.Bytes(), opts...); err != nil { + return fmt.Sprintf("written %q but StrictMapTo failed: %v", buf.String(), err) + } + if !reflect.DeepEqual(out.Elem().Interface(), in.Interface()) { + return fmt.Sprintf("written %q but read back %+v", buf.String(), out.Elem().Interface()) + } + lax := reflect.New(in.Type()) + if err := MapTo(lax.Interface(), buf.Bytes(), opts...); err != nil || !reflect.DeepEqual(lax.Elem().Interface(), in.Interface()) { + return fmt.Sprintf("written %q but MapTo read back %+v, %v", buf.String(), lax.Elem().Interface(), err) + } + return "ok" +} + +// mxWrittenNotRead reports whether a value of type t in the shape is written +// but can't be read: a pointer to a pointer to a type that maps to a key, +// and a pointer to a slice that is a list. Both were so before custom types. +func mxWrittenNotRead(shape string, t reflect.Type) bool { + switch shape { + case "double pointer field": + return isCustomType(t) || isValueType(t) || t.Kind() == reflect.Slice + case "pointer field": + return t.Kind() == reflect.Slice && !isCustomType(t) + } + return false +} + +// mxReads returns the first of mxInputs that StrictMapTo reads into a value +// of type t that is not the zero value, or "". +func mxReads(t reflect.Type, opts []LoadOption) string { + for _, in := range mxInputs { + out := reflect.New(t) + if err := StrictMapTo(out.Interface(), []byte(in), opts...); err == nil && !out.Elem().IsZero() { + return in + } + } + return "" +} + +// TestStructMappingMatrix checks every Mx type in every shape: a value is +// either written and read back the same by both MapTo and StrictMapTo, or +// can't be written, and then no input reads a value of its type either. +func TestStructMappingMatrix(t *testing.T) { + for _, shape := range mxShapes { + for _, sample := range mxSamples { + v := reflect.ValueOf(sample) + name := shape.name + "/" + v.Type().Name() + st := shape.typ(v.Type()) + opts := shape.opts + + in := reflect.New(st).Elem() + shape.set(in, v) + res := mxRoundTrip(in, opts) + if mxWrittenNotRead(shape.name, v.Type()) { + // Kept as on v2: written as the value pointed to, but not + // read. + if !strings.HasPrefix(res, "written") { + t.Errorf("%s: %s, want written but not read", name, res) + } + continue + } + if res == "write error" { + if read := mxReads(st, opts); read != "" { + res = fmt.Sprintf("can't be written, but %q reads", read) + } + } + t.Logf("%s: %s", name, res) + if res != "ok" && res != "write error" { + t.Errorf("%s: %s", name, res) + } + } + } +} + +// The MxEmbed and MxExtends types embed each Mx struct type, plainly and +// with the extends tag. +type ( + MxEmbedNone struct { + MxStructNone + B string + } + MxEmbedM struct { + MxStructM + B string + } + MxEmbedU struct { + MxStructU + B string + } + MxEmbedMU struct { + MxStructMU + B string + } + MxExtendsNone struct { + MxStructNone `ini:",,,,extends"` + B string + } + MxExtendsM struct { + MxStructM `ini:",,,,extends"` + B string + } + MxExtendsU struct { + MxStructU `ini:",,,,extends"` + B string + } + MxExtendsMU struct { + MxStructMU `ini:",,,,extends"` + B string + } +) + +// TestStructMappingMatrixEmbedded checks the same for embedded struct types, +// held directly and in a section. +func TestStructMappingMatrixEmbedded(t *testing.T) { + for _, v := range []any{ + MxEmbedNone{MxStructNone{"a"}, "b"}, MxEmbedM{MxStructM{"a"}, "b"}, + MxEmbedU{MxStructU{"a"}, "b"}, MxEmbedMU{MxStructMU{"a"}, "b"}, + MxExtendsNone{MxStructNone{"a"}, "b"}, MxExtendsM{MxStructM{"a"}, "b"}, + MxExtendsU{MxStructU{"a"}, "b"}, MxExtendsMU{MxStructMU{"a"}, "b"}, + } { + for _, shape := range mxShapes { + if shape.name != "field" { + continue + } + rv := reflect.ValueOf(v) + for _, held := range []struct { + name string + typ reflect.Type + set func(s reflect.Value) + }{ + {"top level", rv.Type(), func(s reflect.Value) { s.Set(rv) }}, + {shape.name, shape.typ(rv.Type()), func(s reflect.Value) { shape.set(s, rv) }}, + } { + name := held.name + "/" + rv.Type().Name() + in := reflect.New(held.typ).Elem() + held.set(in) + if held.name != "top level" && strings.HasSuffix(name, "MU") { + // The embedded type's methods are promoted, so the field + // maps to one key, as Unmarshaler says, and B isn't kept. + f := Empty() + if err := f.ReflectFrom(in.Addr().Interface()); err != nil { + t.Errorf("%s: %v", name, err) + } + var buf bytes.Buffer + _, _ = f.Write(&buf) + if buf.String() != "F = \n" { + t.Errorf("%s: written %q, want one key", name, buf.String()) + } + continue + } + res := mxRoundTrip(in, nil) + if res == "write error" { + if read := mxReads(held.typ, nil); read != "" { + res = fmt.Sprintf("can't be written, but %q reads", read) + } + } + t.Logf("%s: %s", name, res) + if res != "ok" && res != "write error" { + t.Errorf("%s: %s", name, res) + } + } + } + } +} + +// TestStructMappingMatrixSections checks the same for values passed for a +// section, as one struct and, with AllowNonUniqueSections, as several. +func TestStructMappingMatrixSections(t *testing.T) { + for _, sample := range mxSamples[4:8] { + v := reflect.ValueOf(sample) + for _, nonUnique := range []bool{false, true} { + var opts []LoadOption + if nonUnique { + opts = []LoadOption{AllowNonUniqueSections(true)} + } + cases := []struct { + name string + in reflect.Value + }{{"struct", v}} + if nonUnique { + p := reflect.New(v.Type()) + p.Elem().Set(v) + cases = append(cases, struct { + name string + in reflect.Value + }{"slice", reflect.Append(reflect.MakeSlice(reflect.SliceOf(p.Type()), 0, 2), p, p)}) + } + for _, c := range cases { + name := fmt.Sprintf("%s/%s/nonunique=%v", c.name, v.Type().Name(), nonUnique) + res := mxSectionRoundTrip(c.in, opts) + t.Logf("%s: %s", name, res) + if res != "ok" && res != "write and read error" { + t.Errorf("%s: %s", name, res) + } + } + } + } +} + +// mxSectionRoundTrip writes in, a struct or a slice of pointers to structs, +// to section S with Section.ReflectFrom and reads it back with +// Section.StrictMapTo. +func mxSectionRoundTrip(in reflect.Value, opts []LoadOption) string { + arg := in.Interface() + if in.Kind() == reflect.Struct { + p := reflect.New(in.Type()) + p.Elem().Set(in) + arg = p.Interface() + } + f := Empty(opts...) + werr := f.Section("S").ReflectFrom(arg) + var buf bytes.Buffer + if werr == nil { + _, werr = f.Write(&buf) + } + + read := func(data string) (reflect.Value, error) { + g, err := Load([]byte(data), opts...) + if err != nil { + return reflect.Value{}, err + } + out := reflect.New(in.Type()) + return out.Elem(), g.Section("S").StrictMapTo(out.Interface()) + } + if werr != nil { + if out, err := read("[S]\nA = a\n[S]\nA = b\n"); err == nil && !out.IsZero() { + return fmt.Sprintf("can't be written (%v), but reads %+v", werr, out.Interface()) + } + return "write and read error" + } + out, err := read(buf.String()) + if err != nil { + return fmt.Sprintf("written %q but StrictMapTo failed: %v", buf.String(), err) + } + if !reflect.DeepEqual(out.Interface(), in.Interface()) { + return fmt.Sprintf("written %q but read back %+v", buf.String(), out.Interface()) + } + return "ok" +} diff --git a/struct_test.go b/struct_test.go index 4a314d8..b7e00ff 100644 --- a/struct_test.go +++ b/struct_test.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "slices" + "strconv" "strings" "testing" "time" @@ -1437,6 +1438,18 @@ type fzCfg struct { Named fzName PNamed *fzName + Perm testPerm + PPerm *testPerm + Perms []testPerm + + Addr MxStructMU + PAddr *MxStructMU + Addrs []MxStructMU + Tags MxSliceMU + KV MxMapMU + Hosts MxSliceM + MPeers []MxStructMU `ini:"MPeer,,,nonunique"` + Sub fzSub PSub *fzSub Shadow []string `ini:"shadow,,allowshadow"` @@ -1455,9 +1468,12 @@ func FuzzStructMapping(f *testing.F) { "S = s\nI = -3\nI8 = 127\nU = 4\nU8 = 255\nF32 = 1.5\nF = 2.5\nB = true\nD = 1s\nT = 2020-01-02T03:04:05.5Z\n"+ "PS = p\nPI = 7\nPT = 2020-01-02T03:04:05Z\nPD = 3s\nSS = a,b\\, c,d\nSI = 1,2\nSU = 3,4\nSF = 1.5,2\nSB = true,false\n"+ "ST = 2020-01-02T03:04:05Z\nSP = x|y\nNamed = n\nPNamed = m\nshadow = a\nshadow = b\nE = e\n"+ + "Perm = write\nPPerm = read\nPerms = read, x, write\n"+ "[Sub]\nA = 1\nB = b\n[PSub]\nA = 2\n[Peer]\nA = 3\n[Peer]\nA = 4\n[PPeer]\nB = q\n[Node]\nX = 1\n[Child]\nX = 2\n", "I8 = 300\nU8 = -1\nF32 = 1e300\nD = -5s\nSI = 1,x,3\nT = 2006-01-02T15:04:05.123456789+07:00\n", "a = %(b)s\nb = %(a)s\nS = %(S)s%(S)s\n", + "Addr = \nPAddr = \nAddrs = ,\nTags = a+b\nKV = k:v&l:w\nHosts = h1,h2\n"+ + "[MPeer]\nA = m\n[MPeer]\nA = n\n", ) for _, in := range inputs { for _, flags := range []uint32{0, 1 << 7, 1<<14 | 1<<7, 1<<0 | 1<<14, 1<<20 - 1} { @@ -1577,3 +1593,185 @@ func normalizeFzValue(v reflect.Value) { } } } + +// testPerm is an enum that reads and writes itself by name. +type testPerm int + +var testPermNames = []string{"none", "read", "write"} + +func (p *testPerm) UnmarshalINI(b []byte) error { + for i, name := range testPermNames { + if string(b) == name { + *p = testPerm(i) + return nil + } + } + return fmt.Errorf("unknown permission %q", b) +} + +func (p testPerm) MarshalINI() ([]byte, error) { + if int(p) >= len(testPermNames) { + return nil, fmt.Errorf("invalid permission %d", int(p)) + } + return []byte(testPermNames[p]), nil +} + +// testHostPort is a struct that maps to one key, written as host:port. +type testHostPort struct { + Host string + Port int +} + +func (h *testHostPort) UnmarshalINI(b []byte) error { + host, port, ok := strings.Cut(string(b), ":") + if !ok { + return fmt.Errorf("missing port in %q", b) + } + n, err := strconv.Atoi(port) + if err != nil { + return err + } + h.Host, h.Port = host, n + return nil +} + +func (h *testHostPort) MarshalINI() ([]byte, error) { + return []byte(h.Host + ":" + strconv.Itoa(h.Port)), nil +} + +func TestMarshalerUnmarshaler(t *testing.T) { + type config struct { + Perm testPerm + Perms []testPerm + PPerm *testPerm + Addr testHostPort + Addrs []testHostPort `delim:"|"` + } + + t.Run("read", func(t *testing.T) { + data := []byte("Perm = write\nPerms = read, none\nPPerm = read\nAddr = example.com:80\nAddrs = a:1|b:2\n" + + "[Addr]\nHost = from a section\n") + var c config + require.NoError(t, StrictMapTo(&c, data)) + read := testPerm(1) + assert.Equal(t, config{ + Perm: 2, + Perms: []testPerm{1, 0}, + PPerm: &read, + // A struct that parses itself maps to its key, not to the + // section of the same name. + Addr: testHostPort{"example.com", 80}, + Addrs: []testHostPort{{"a", 1}, {"b", 2}}, + }, c) + }) + + t.Run("write and read back", func(t *testing.T) { + read := testPerm(1) + in := config{Perm: 2, Perms: []testPerm{1, 0}, PPerm: &read, Addr: testHostPort{"h", 8}, Addrs: []testHostPort{{"a", 1}}} + data, err := marshal(in) + require.NoError(t, err) + assert.Equal(t, "Perm = write\nPerms = read,none\nPPerm = read\nAddr = h:8\nAddrs = a:1\n", string(data)) + + var out config + require.NoError(t, unmarshal(data, &out)) + assert.Equal(t, in, out) + }) + + t.Run("errors", func(t *testing.T) { + c := config{Perm: 1} + require.NoError(t, MapTo(&c, []byte("Perm = admin\n"))) + assert.Equal(t, testPerm(1), c.Perm, "an invalid value leaves the field unchanged") + + err := StrictMapTo(&c, []byte("Perm = admin\n")) + assert.ErrorContains(t, err, `unknown permission "admin"`) + assert.ErrorContains(t, StrictMapTo(&c, []byte("Perms = read, admin\n")), "admin") + + _, err = marshal(config{Perm: 9}) + assert.ErrorContains(t, err, "invalid permission 9") + _, err = marshal(config{Perms: []testPerm{1, 9}}) + assert.ErrorContains(t, err, "invalid permission 9") + }) + + t.Run("generic accessors", func(t *testing.T) { + f, err := Load([]byte("perms = write, x, read\nperm = write\n")) + require.NoError(t, err) + sec := f.Section("") + assert.Equal(t, []testPerm{2, 0, 1}, List[testPerm](sec.Key("perms"), ",")) + assert.Equal(t, []testPerm{2, 1}, ValidList[testPerm](sec.Key("perms"), ",")) + _, err = StrictList[testPerm](sec.Key("perms"), ",") + assert.Error(t, err) + assert.Equal(t, testPerm(2), In(sec.Key("perm"), 0, []testPerm{1, 2})) + }) + + t.Run("sections asked for explicitly", func(t *testing.T) { + // A nonunique field, or a value passed for a section of a file with + // AllowNonUniqueSections, maps a custom struct type to sections. + f, err := Load([]byte("[peer]\nHost = a\nPort = 1\n[peer]\nHost = b\nPort = 2\n"), AllowNonUniqueSections(true)) + require.NoError(t, err) + var hp testHostPort + require.NoError(t, f.Section("peer").MapTo(&hp)) + assert.Equal(t, testHostPort{"a", 1}, hp) + var hps []*testHostPort + require.NoError(t, f.Section("peer").MapTo(&hps)) + assert.Equal(t, []*testHostPort{{"a", 1}, {"b", 2}}, hps) + + require.NoError(t, f.Section("peer").ReflectFrom(&testHostPort{"c", 3})) + assert.Equal(t, "c", f.Section("peer").Key("Host").String()) + require.NoError(t, f.Section("peer").ReflectFrom([]*testHostPort{{"d", 4}, {"e", 5}})) + secs, err := f.SectionsByName("peer") + require.NoError(t, err) + assert.Len(t, secs, 2) + + type peers struct { + Peers []testHostPort `ini:"peer,,,nonunique"` + } + in := peers{Peers: []testHostPort{{"a", 1}, {"b", 2}}} + data, err := marshal(in) + require.NoError(t, err) + assert.Equal(t, "[peer]\nHost = a\nPort = 1\n\n[peer]\nHost = b\nPort = 2\n", string(data)) + var out peers + require.NoError(t, unmarshal(data, &out)) + assert.Equal(t, in, out) + }) + + t.Run("one method on a type that isn't a string, boolean or number", func(t *testing.T) { + // Without both methods, a slice type is still a list and a struct + // type a section, in both directions. + type oneWay struct { + Hosts testMarshalOnlyList + Tags testUnmarshalOnlyList + Peer testMarshalOnlyStruct + } + var c oneWay + require.NoError(t, StrictMapTo(&c, []byte("Hosts = a,b\nTags = x,y\n[Peer]\nName = p\n"))) + assert.Equal(t, oneWay{Hosts: testMarshalOnlyList{"a", "b"}, Tags: testUnmarshalOnlyList{"x", "y"}, Peer: testMarshalOnlyStruct{Name: "p"}}, c) + + data, err := marshal(c) + require.NoError(t, err) + assert.Equal(t, "Hosts = a,b\nTags = x,y\n\n[Peer]\nName = p\n", string(data)) + + // A map type with one method stays unsupported both ways. + type withMap struct{ M testMarshalOnlyMap } + assert.Error(t, StrictMapTo(&withMap{}, []byte("M = a\n"))) + _, err = marshal(withMap{M: testMarshalOnlyMap{"a": 1}}) + assert.Error(t, err) + }) +} + +// testMarshalOnlyList, testUnmarshalOnlyList, testMarshalOnlyStruct and +// testMarshalOnlyMap implement one of Marshaler and Unmarshaler, with methods +// that must not be called. +type ( + testMarshalOnlyList []string + testUnmarshalOnlyList []string + testMarshalOnlyStruct struct{ Name string } + testMarshalOnlyMap map[string]int +) + +func (testMarshalOnlyList) MarshalINI() ([]byte, error) { panic("called") } + +func (*testUnmarshalOnlyList) UnmarshalINI([]byte) error { panic("called") } + +func (testMarshalOnlyStruct) MarshalINI() ([]byte, error) { panic("called") } + +func (testMarshalOnlyMap) MarshalINI() ([]byte, error) { panic("called") }