diff --git a/CHANGELOG.md b/CHANGELOG.md index 0acc549..467eb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- +- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"` + skips a field whose value is the zero value of its type (a type with an + `IsZero() bool` method decides through the method), and + `toml:"name,omitempty"` skips a nil or empty slice, array, or map. The + decoder ignores both options. ### Fixed diff --git a/decode.go b/decode.go index c571f1f..baadcef 100644 --- a/decode.go +++ b/decode.go @@ -230,7 +230,7 @@ func structFields(t reflect.Type) map[string]int { } name := f.Name if tag, ok := f.Tag.Lookup("toml"); ok { - tag = strings.Split(tag, ",")[0] + tag, _, _ = strings.Cut(tag, ",") if tag == "-" { continue } diff --git a/docs/API.md b/docs/API.md index c0ea349..559f6e7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -233,6 +233,30 @@ Keys that match `[A-Za-z0-9_-]+` are emitted bare, all others quoted. A `map[string]V` emits its keys in sorted order for deterministic output, and a 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. + +- `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. + +```go +type Config struct { + Host string `toml:"host,omitzero"` + Started time.Time `toml:"started,omitzero"` + Tags []string `toml:"tags,omitempty"` +} +``` + +Options combine after the name: `toml:"name,omitempty,omitzero"` is valid, and +an unknown option is ignored. + Note the asymmetry: the encoder inlines untagged embedded structs, while the decoder expects them under their lower-cased type name. A struct with an untagged embedded struct therefore does not round-trip through `Unmarshal` into diff --git a/encode.go b/encode.go index 681caa5..03c1c54 100644 --- a/encode.go +++ b/encode.go @@ -189,6 +189,9 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { if name == "-" { continue } + if fieldOmitted(f, v.Field(i)) { + continue + } if err := addField(doc, name, v.Field(i), ctx); err != nil { return err } @@ -196,6 +199,49 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { return nil } +// isZeroer mirrors encoding/json's omitzero: a type that knows its own zero +// state decides through that method before reflection is consulted. +type isZeroer interface{ IsZero() bool } + +// 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. +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) { + return true + } + case "omitempty": + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + if v.Len() == 0 { + return true + } + } + } + } + return false +} + +func isZeroValue(v reflect.Value) bool { + if v.CanInterface() { + if z, ok := v.Interface().(isZeroer); ok { + return z.IsZero() + } + } + return v.IsZero() +} + // fieldName returns the TOML key for a struct field, honouring the `toml` // tag (name or `-`) and falling back to a lower-cased field name. func fieldName(f reflect.StructField) string { diff --git a/encode_test.go b/encode_test.go index 3699f7e..bbfd42a 100644 --- a/encode_test.go +++ b/encode_test.go @@ -748,6 +748,85 @@ func TestMarshalEmbeddedStructAsTable(t *testing.T) { } } +func TestMarshalTagOptionOmitZero(t *testing.T) { + type Server struct { + Host string `toml:"host"` + } + type Cfg struct { + Name string `toml:"name,omitzero"` + Count int `toml:"count,omitzero"` + Ratio float64 `toml:"ratio,omitzero"` + When time.Time `toml:"when,omitzero"` + Server Server `toml:"server,omitzero"` + Always string `toml:"always"` + } + out, err := Marshal(Cfg{Always: "kept"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Every omitzero field sits at its zero value, so only always is emitted. + want := "always = \"kept\"\n" + if string(out) != want { + t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + + when := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + out, err = Marshal(Cfg{Name: "x", Count: 1, Ratio: 0.5, When: when, Server: Server{Host: "h"}, Always: "kept"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n" + if string(out) != want { + t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTagOptionOmitEmpty(t *testing.T) { + type Cfg struct { + Tags []string `toml:"tags,omitempty"` + Ports []int `toml:"ports,omitempty"` + Matrix [][]int `toml:"matrix,omitempty"` + Extra map[string]any `toml:"extra,omitempty"` + Name string `toml:"name,omitempty"` + Keep []string `toml:"keep"` + } + out, err := Marshal(Cfg{ + Ports: []int{}, + Matrix: [][]int{{1}}, + Extra: map[string]any{}, + Name: "set", + Keep: []string{}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // tags is nil (omitted anyway), ports and extra are empty collections + // dropped by omitempty, matrix is populated, name is a string the option + // does not cover, keep is empty but carries no option so it emits []. + want := "matrix = [[1]]\nname = \"set\"\nkeep = []\n" + if string(out) != want { + t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTagOptionOnTaggedEmbeddedStruct(t *testing.T) { + type Inner struct { + N int `toml:"n"` + } + type Cfg struct { + Inner Inner `toml:"inner,omitzero"` + Name string `toml:"name"` + } + out, err := Marshal(Cfg{Name: "x"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "name = \"x\"\n" + if string(out) != want { + t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + func TestMarshalMapKeysSorted(t *testing.T) { m := map[string]any{ "zeta": 1, diff --git a/interpres.go b/interpres.go index f3939ad..8d8df87 100644 --- a/interpres.go +++ b/interpres.go @@ -150,8 +150,11 @@ type Unmarshaler interface { // - The top-level value must be a struct or a map[string]V. Pointers are // followed; a nil top-level pointer is an error. // - Struct fields are matched by `toml:"name"` tag (case-insensitive -// fallback to field name; `-` skips). Anonymous (embedded) fields without -// a tag are inlined. +// fallback to field name; `-` skips). The tag options `omitzero` (skip +// the zero value of the field's type) and `omitempty` (skip an empty +// slice, array, or map) drop a field from the output on encode; the +// decoder ignores them. Anonymous (embedded) fields without a tag are +// inlined. // - Maps use sorted keys for deterministic output. // - Slices and arrays of structs or maps become TOML arrays of tables; a // nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),