feat: add the inline tag and json-style omitempty
Test / test (push) Successful in 1m30s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 00:28:52 +02:00
parent 13ac6dd521
commit a8a2fcf8c3
4 changed files with 227 additions and 43 deletions
+9
View File
@@ -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
+13 -5
View File
@@ -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"`
}
```
+97 -38
View File
@@ -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
}
+108
View File
@@ -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)
}
}