feat: honour the omitzero and omitempty tag options on encode

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-17 19:57:23 +02:00
parent 58e7dfb1d0
commit 93c36cf376
6 changed files with 160 additions and 4 deletions
+5 -1
View File
@@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 ### Fixed
+1 -1
View File
@@ -230,7 +230,7 @@ func structFields(t reflect.Type) map[string]int {
} }
name := f.Name name := f.Name
if tag, ok := f.Tag.Lookup("toml"); ok { if tag, ok := f.Tag.Lookup("toml"); ok {
tag = strings.Split(tag, ",")[0] tag, _, _ = strings.Cut(tag, ",")
if tag == "-" { if tag == "-" {
continue continue
} }
+24
View File
@@ -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 `map[string]V` emits its keys in sorted order for deterministic output, and a
nil map emits nothing. 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 Note the asymmetry: the encoder inlines untagged embedded structs, while the
decoder expects them under their lower-cased type name. A struct with an decoder expects them under their lower-cased type name. A struct with an
untagged embedded struct therefore does not round-trip through `Unmarshal` into untagged embedded struct therefore does not round-trip through `Unmarshal` into
+46
View File
@@ -189,6 +189,9 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
if name == "-" { if name == "-" {
continue continue
} }
if fieldOmitted(f, v.Field(i)) {
continue
}
if err := addField(doc, name, v.Field(i), ctx); err != nil { if err := addField(doc, name, v.Field(i), ctx); err != nil {
return err return err
} }
@@ -196,6 +199,49 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
return nil 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` // fieldName returns the TOML key for a struct field, honouring the `toml`
// tag (name or `-`) and falling back to a lower-cased field name. // tag (name or `-`) and falling back to a lower-cased field name.
func fieldName(f reflect.StructField) string { func fieldName(f reflect.StructField) string {
+79
View File
@@ -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) { func TestMarshalMapKeysSorted(t *testing.T) {
m := map[string]any{ m := map[string]any{
"zeta": 1, "zeta": 1,
+5 -2
View File
@@ -150,8 +150,11 @@ type Unmarshaler interface {
// - The top-level value must be a struct or a map[string]V. Pointers are // - The top-level value must be a struct or a map[string]V. Pointers are
// followed; a nil top-level pointer is an error. // followed; a nil top-level pointer is an error.
// - Struct fields are matched by `toml:"name"` tag (case-insensitive // - Struct fields are matched by `toml:"name"` tag (case-insensitive
// fallback to field name; `-` skips). Anonymous (embedded) fields without // fallback to field name; `-` skips). The tag options `omitzero` (skip
// a tag are inlined. // 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. // - Maps use sorted keys for deterministic output.
// - Slices and arrays of structs or maps become TOML arrays of tables; a // - 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]]`), // nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),