diff --git a/CHANGELOG.md b/CHANGELOG.md index e418fd6..7b15082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `omitempty` follows the encoding/json semantics: the field is skipped when + it holds an empty string, a zero number, `false`, a nil pointer or + interface, or a nil or empty slice, array or map. In 1.x the option covered + only the collections. +- The `toml` tag gained the `inline` option: a struct or map field tagged + `toml:"retry,inline"` emits as `retry = {…}` instead of a header section, + whatever its size, a named embedded struct included. Forcing it on an array + of tables is an error, because the inline form would re-parse as a value + array and change the value's Go type. - The output takes the TOML 1.1 form. A date-time writes its seconds only when the value carries them and drops the trailing zeros of a fractional second, so `07:32:00` is written `07:32` and half a second as `00.5`. Both are the diff --git a/docs/API.md b/docs/API.md index f6744fd..f5595fa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -475,22 +475,30 @@ nil map emits nothing. ### Tag options -The part of a `toml` tag after the first comma carries options. Both options -shape emission only; the decoder ignores them. +The part of a `toml` tag after the first comma carries options. They shape +emission only; the decoder ignores them, so a value that round-trips keeps +its key whether the table it came from was written inline or under a header. - `omitzero` skips the field when its value is the zero value of its type. A type with an `IsZero() bool` method (time.Time among them) decides through that method, so a zero `time.Time` or an all-zero struct disappears from the output. -- `omitempty` skips the field when it holds an empty collection: a nil or - empty slice or array, or a nil or empty map. Strings and other scalars are - not covered by `omitempty`; use `omitzero` for those. +- `omitempty` skips the field when it holds an empty value in the + encoding/json sense: an empty string, a zero number, `false`, a nil pointer + or interface, and a nil or empty slice, array or map. This is a change of + semantics against 1.x, where only collections were covered. +- `inline` forces a struct or map field to emit as `name = {…}`, the inline + table form, instead of a header section, whatever its size; a named + embedded struct tagged this way does the same. A field holding an array of + tables is an error under `inline`, because the inline form would re-parse + as a value array and change the value's Go type. ```go type Config struct { Host string `toml:"host,omitzero"` Started time.Time `toml:"started,omitzero"` Tags []string `toml:"tags,omitempty"` + Retry Retry `toml:"retry,inline"` } ``` diff --git a/encode.go b/encode.go index 7b13d07..55d5fe5 100644 --- a/encode.go +++ b/encode.go @@ -275,7 +275,7 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error { // A nil value has no TOML form, the rule nil pointer fields follow. continue } - if err := addField(doc, key, rv, path); err != nil { + if err := addField(doc, key, rv, path, false); err != nil { return err } } @@ -304,6 +304,10 @@ type entry struct { doc *tomlDoc // entryTable docs []*tomlDoc + // inline forces a table entry to emit as `key = {…}`; it is set by the + // `,inline` tag option. An array of tables keeps the header form. + inline bool + // emitted records that the grouped emission wrote this table inline, so // the header pass that follows skips it. The representation is built // fresh per Marshal call. @@ -347,8 +351,10 @@ func (d *tomlDoc) addScalar(key string, val any) { d.entries = append(d.entries, entry{kind: entryScalar, key: key, val: val}) } -func (d *tomlDoc) addTable(key string, sub *tomlDoc) { - d.entries = append(d.entries, entry{kind: entryTable, key: key, doc: sub}) +func (d *tomlDoc) addTable(key string, sub *tomlDoc) *entry { + e := entry{kind: entryTable, key: key, doc: sub} + d.entries = append(d.entries, e) + return &d.entries[len(d.entries)-1] } func (d *tomlDoc) addArray(key string, subs []*tomlDoc) { @@ -482,7 +488,7 @@ func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, sc if fieldOmitted(f, v.Field(i)) { continue } - if err := addField(doc, name, v.Field(i), path); err != nil { + if err := addField(doc, name, v.Field(i), path, tagHasOption(f.Tag.Get("toml"), "inline")); err != nil { return err } } @@ -493,30 +499,69 @@ func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, sc // state decides through that method before reflection is consulted. type isZeroer interface{ IsZero() bool } +// tagOptions returns the option part of a `toml` tag, the part after the +// first comma. +func tagOptions(tag string) string { + _, opts, _ := strings.Cut(tag, ",") + return opts +} + +// tagHasOption reports whether want is one of the tag's comma-separated +// options. +func tagHasOption(tag, want string) bool { + opts := tagOptions(tag) + for opts != "" { + var opt string + opt, opts, _ = strings.Cut(opts, ",") + if opt == want { + return true + } + } + return false +} + // fieldOmitted reports whether the field's tag options drop it from the // output: omitzero skips the zero value of the field's type, omitempty skips -// an empty collection (slice, array, or map). The decoder ignores both -// options; they shape emission only. +// an empty value in the encoding/json sense, an empty string, a zero number, +// false, a nil pointer or interface, and an empty slice, array or map. The +// decoder ignores both options; they shape emission only. func fieldOmitted(f reflect.StructField, v reflect.Value) bool { tag, ok := f.Tag.Lookup("toml") if !ok { return false } - _, opts, _ := strings.Cut(tag, ",") - for opts != "" { - var opt string - opt, opts, _ = strings.Cut(opts, ",") - switch opt { - case "omitzero": - if isZeroValue(v) { + if tagHasOption(tag, "omitzero") && isZeroValue(v) { + return true + } + if tagHasOption(tag, "omitempty") { + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + if v.Len() == 0 { return true } - case "omitempty": - switch v.Kind() { - case reflect.Slice, reflect.Array, reflect.Map: - if v.Len() == 0 { - return true - } + case reflect.String: + if v.Len() == 0 { + return true + } + case reflect.Bool: + if !v.Bool() { + return true + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if v.Int() == 0 { + return true + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if v.Uint() == 0 { + return true + } + case reflect.Float32, reflect.Float64: + if v.Float() == 0 { + return true + } + case reflect.Pointer, reflect.Interface: + if v.IsNil() { + return true } } } @@ -569,7 +614,7 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, path encPath) error { return err } } - if err := addField(doc, k.String(), v.MapIndex(k), path); err != nil { + if err := addField(doc, k.String(), v.MapIndex(k), path, false); err != nil { return err } } @@ -584,7 +629,11 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, path encPath) error { // contract violation. var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value") -func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error { +// addField adds one value under name, the shape addField picks deciding +// whether it is a scalar line, a sub-table or an array. forceInline marks a +// table-valued field carrying the `,inline` tag option: it emits as +// `key = {…}` instead of a header section. +func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInline bool) error { if m, ok := marshalerOf(v); ok { mv, err := m.MarshalTOML() if err != nil { @@ -624,7 +673,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error { if err := buildOrderedDoc(&om, sub, path.key(name)); err != nil { return err } - doc.addTable(name, sub) + doc.addTable(name, sub).inline = forceInline return nil } switch v.Kind() { @@ -633,11 +682,11 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error { doc.addScalar(name, v.Interface()) return nil } - return addSubTable(doc, name, v, path) + return addSubTable(doc, name, v, path, forceInline) case reflect.Map: - return addSubTable(doc, name, v, path) + return addSubTable(doc, name, v, path, forceInline) case reflect.Slice, reflect.Array: - return addArrayValue(doc, name, v, path) + return addArrayValue(doc, name, v, path, forceInline) default: val, err := normaliseValue(v) if err != nil { @@ -648,7 +697,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error { } } -func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath) error { +func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInline bool) error { sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} if atDepthLimit(sub.depth) { return &EncodeError{Path: path.key(name).String(), Err: errDepthLimit()} @@ -663,11 +712,11 @@ func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath) error return err } } - doc.addTable(name, sub) + doc.addTable(name, sub).inline = forceInline return nil } -func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath) error { +func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInline bool) error { if v.Kind() == reflect.Slice && v.IsNil() { // A nil slice has no explicit representation in TOML, so it is skipped. return nil @@ -723,6 +772,11 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath) err if v.Type().Elem().Kind() == reflect.Interface { allTables = false } + // A `,inline` tag on an array of tables asks for a form that would change + // the value's Go type on re-parse, so the error is the honest answer. + if forceInline && allTables { + return &EncodeError{Path: path.key(name).String(), Err: errors.New("an array of tables has no inline form")} + } if allTables { subs := make([]*tomlDoc, n) for i, ev := range elems { @@ -1104,7 +1158,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { if t.kind != entryTable { continue } - inlined, err := e.writeInlineSubTableIfSmall(t.key, t.doc) + inlined, err := e.writeInlineSubTableIfSmall(t) if err != nil { return err } @@ -1159,7 +1213,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { return err } case entryTable: - inlined, err := e.writeInlineSubTableIfSmall(ent.key, ent.doc) + inlined, err := e.writeInlineSubTableIfSmall(&ent) if err != nil { return err } @@ -1516,29 +1570,34 @@ func (e *encoder) writeInlineDocMultiline(doc *tomlDoc) error { // writeInlineSubTableIfSmall writes "key = {…}" for a sub-table whose // single-line rendering fits the compact threshold, and reports whether it did -// so. An array of tables is never inlined, because its inline form would -// re-parse as a value array and change the value's Go type. -func (e *encoder) writeInlineSubTableIfSmall(name string, doc *tomlDoc) (bool, error) { - if e.opts.inlineTablesAt <= 0 || !inlinableDoc(doc) { +// so. An entry the `,inline` tag option marks is written regardless of the +// threshold. An array of tables is never inlined, because its inline form +// would re-parse as a value array and change the value's Go type; a forced +// inline of one is an error rather than a silent form change. +func (e *encoder) writeInlineSubTableIfSmall(t *entry) (bool, error) { + if !t.inline && (e.opts.inlineTablesAt <= 0 || !inlinableDoc(t.doc)) { return false, nil } + if !inlinableDoc(t.doc) { + return false, fmt.Errorf("interpres: field %q holds an array of tables and has no inline form", t.key) + } flat := e.flat() - if err := flat.writeInlineDoc(doc); err != nil { + if err := flat.writeInlineDoc(t.doc); err != nil { flat.release() return false, err } - if flat.buf.Len() > e.opts.inlineTablesAt { + if !t.inline && flat.buf.Len() > e.opts.inlineTablesAt { flat.release() return false, nil } - if err := e.writeKey(name); err != nil { + if err := e.writeKey(t.key); err != nil { flat.release() return false, err } e.buf.WriteString(" = ") if e.column()+flat.buf.Len() <= e.limit { e.buf.Write(flat.buf.Bytes()) - } else if err := e.writeInlineDocMultiline(doc); err != nil { + } else if err := e.writeInlineDocMultiline(t.doc); err != nil { flat.release() return false, err } diff --git a/encode_test.go b/encode_test.go index 8e09535..3b4d1c9 100644 --- a/encode_test.go +++ b/encode_test.go @@ -2126,3 +2126,111 @@ func TestUnmarshalWithOptions(t *testing.T) { } }) } + +func TestInlineTag(t *testing.T) { + type Inner struct { + A int `toml:"a"` + B int `toml:"b"` + } + t.Run("a struct field writes inline", func(t *testing.T) { + type Cfg struct { + Inner Inner `toml:"inner,inline"` + } + out, err := Marshal(Cfg{Inner: Inner{1, 2}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "inner = {a = 1, b = 2}\n" { + t.Errorf("output %q", out) + } + }) + t.Run("a map field writes inline", func(t *testing.T) { + type Cfg struct { + Opts map[string]int `toml:"opts,inline"` + } + out, err := Marshal(Cfg{Opts: map[string]int{"x": 1}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "opts = {x = 1}\n" { + t.Errorf("output %q", out) + } + }) + t.Run("a named embedded struct writes inline", func(t *testing.T) { + type Cfg struct { + Inner `toml:"inner,inline"` + } + out, err := Marshal(Cfg{Inner: Inner{1, 2}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "inner = {a = 1, b = 2}\n" { + t.Errorf("output %q", out) + } + }) + t.Run("an inline field decodes back", func(t *testing.T) { + type Cfg struct { + Inner Inner `toml:"inner,inline"` + } + var cfg Cfg + if err := Unmarshal([]byte("inner = {a = 3, b = 4}\n"), &cfg); err != nil { + t.Fatal(err) + } + if cfg.Inner != (Inner{3, 4}) { + t.Errorf("decoded %+v", cfg.Inner) + } + }) + t.Run("a forced inline of an array of tables is an error", func(t *testing.T) { + type Item struct { + N int `toml:"n"` + } + type Cfg struct { + Items []Item `toml:"items,inline"` + } + if _, err := Marshal(Cfg{Items: []Item{{1}}}); err == nil { + t.Error("forced inline of an array of tables succeeded, want an error") + } + }) + t.Run("without the tag the header form stands", func(t *testing.T) { + type Cfg struct { + Inner Inner `toml:"inner"` + } + out, err := Marshal(Cfg{Inner: Inner{1, 2}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "[inner]\na = 1\nb = 2\n" { + t.Errorf("output %q", out) + } + }) +} + +func TestOmitEmptyJSONSemantics(t *testing.T) { + type Cfg struct { + Empty string `toml:"empty,omitempty"` + Full string `toml:"full,omitempty"` + Zero int `toml:"zero,omitempty"` + One int `toml:"one,omitempty"` + Off bool `toml:"off,omitempty"` + On bool `toml:"on,omitempty"` + Nil *string `toml:"nil,omitempty"` + Set *string `toml:"set,omitempty"` + Nothing map[string]string `toml:"nothing,omitempty"` + Somethg map[string]string `toml:"somethg,omitempty"` + } + s := "x" + out, err := Marshal(Cfg{ + Full: "y", + One: 1, + On: true, + Set: &s, + Somethg: map[string]string{"k": "v"}, + }) + if err != nil { + t.Fatal(err) + } + want := "full = \"y\"\none = 1\non = true\nset = \"x\"\n\n[somethg]\nk = \"v\"\n" + if string(out) != want { + t.Errorf("output:\n%q\nwant:\n%q", out, want) + } +}