11 Commits
Author SHA1 Message Date
petrbalvin 1e3198c8b6 docs: add the contributor terms with the CLA
Test / test (push) Successful in 1m58s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:27:13 +02:00
petrbalvin cdb42de561 ci: record the verified cancel-in-progress behaviour
Test / test (push) Successful in 1m24s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:16:49 +02:00
petrbalvin 0f6d81fe3e ci: second trigger of the cancel-in-progress experiment
Test / test (push) Successful in 1m32s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:12:02 +02:00
petrbalvin 274b8a488c ci: drive the cancel-in-progress experiment
Test / test (push) Canceled after 0s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:12:00 +02:00
petrbalvin b061c97a81 ci: experiment with cancel-in-progress on the test pipeline
Test / test (push) Successful in 1m33s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:08:37 +02:00
petrbalvin fc50e3c49a fix: decode untagged embedded structs and maps inline
Test / test (push) Successful in 1m26s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:05:50 +02:00
petrbalvin 93c36cf376 feat: honour the omitzero and omitempty tag options on encode
Assisted-by: GLM 5.3 Flash
2026-09-17 19:57:23 +02:00
petrbalvin 58e7dfb1d0 test: add parse, marshal and decode benchmarks
Assisted-by: GLM 5.3 Flash
2026-09-17 19:54:27 +02:00
petrbalvin 510cfb5182 test: add the FuzzParse fuzz target
Assisted-by: GLM 5.3 Flash
2026-09-17 19:52:45 +02:00
petrbalvin 3ac0b1e301 fix: encode mixed arrays with inline table elements
Assisted-by: GLM 5.3 Flash
2026-09-17 19:49:50 +02:00
petrbalvin 2737a5ac87 build: bump the go directive to 1.27.1
Assisted-by: GLM 5.3 Flash
2026-09-17 19:41:22 +02:00
14 changed files with 822 additions and 39 deletions
+8
View File
@@ -30,6 +30,14 @@ env:
GOFLAGS: -p=1 GOFLAGS: -p=1
GOMAXPROCS: "2" GOMAXPROCS: "2"
# A superseded run of the same ref is cancelled instead of queueing behind one
# that no longer matters. Verified on this Gitea on 2026-09-17: a queued run
# whose ref moved on is cancelled before it ever reaches the runner, while a
# run already dispatched there runs to completion.
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs: jobs:
test: test:
runs-on: fedora runs-on: fedora
+18 -1
View File
@@ -9,7 +9,24 @@ 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
- Untagged embedded fields now decode symmetrically with encode: an embedded
struct receives its keys inline (a nil embedded pointer struct is
allocated), an embedded map catches the keys no field claims, and a name
clash resolves in favour of the shallower field. A struct with an untagged
embedded field previously decoded with all inline keys dropped and did not
round-trip.
- `Marshal` re-emits arrays that mix tables with scalars: the table elements
render as inline tables inside the value array. A tree that `Parse` accepts
from such a document previously failed with
`cannot encode map[string]interface {}`.
## [1.0.0] - 2026-08-20 ## [1.0.0] - 2026-08-20
+21 -2
View File
@@ -1,10 +1,29 @@
# Contributing # Contributing
Thanks for contributing to **interpres**. Contributions to **interpres** are governed by the Contributor terms
below; submitting one means you accept them.
## Contributor terms
1. This project belongs to its owner alone. The owner decides what is
accepted, in what form and when; the decision is final and needs no
justification.
2. By submitting a contribution you assign to Petr Balvín
<opensource@petrbalvin.org> all present and future copyright and
related rights in it, worldwide, for the full term of the rights,
with the right to relicense and sublicense without restriction,
including under proprietary terms.
3. Where that assignment is not effective, it counts as a perpetual,
irrevocable, royalty-free licence with the same scope.
4. To the fullest extent permitted by law, you waive any right of
attribution and integrity in the contribution. The project names no
contributors and keeps no credits list.
5. By submitting you represent that the work is yours and that you
hold the rights to assign it as above.
## Development setup ## Development setup
Requirements: Go 1.27.0, the version `go.mod` declares, and Requirements: Go 1.27.1, the version `go.mod` declares, and
[just](https://github.com/casey/just) for the recipes. [just](https://github.com/casey/just) for the recipes.
```sh ```sh
+1 -1
View File
@@ -31,7 +31,7 @@ As a library:
go get sourcedock.dev/petrbalvin/interpres go get sourcedock.dev/petrbalvin/interpres
``` ```
Requires Go 1.27.0 or newer. The module imports only the standard library. Requires Go 1.27.1 or newer. The module imports only the standard library.
## Quick start ## Quick start
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"fmt"
"strings"
"testing"
"time"
)
// benchDoc is a representative configuration document: every scalar kind, an
// inline table, sub-tables, and an array of tables.
var benchDoc = []byte(`title = "benchmark configuration"
replicas = 3
ratio = 0.75
enabled = true
when = 2026-09-17T12:00:00Z
local = 2026-09-17T12:00:00
tags = ["alpha", "beta", "gamma"]
limits = { cpu = 4, memory = 1024 }
[server]
host = "localhost"
port = 8080
hosts = ["a.example", "b.example"]
[server.tls]
enabled = true
cert = "/etc/cert.pem"
[[items]]
name = "first"
weight = 10
flags = ["x", "y"]
[[items]]
name = "second"
weight = 20
flags = ["z"]
`)
// longDoc is generated once so the large-input benchmarks measure parsing,
// not document construction. Roughly 2000 array-of-tables entries.
var longDoc = func() []byte {
var b strings.Builder
b.WriteString("title = \"long\"\n")
for i := range 2000 {
fmt.Fprintf(&b, "[[entry]]\nname = \"entry-%d\"\nweight = %d\nwhen = 2026-09-17T12:00:00Z\nratio = 0.5\ntags = [\"a\", \"b\", \"c\"]\n\n", i, i)
}
return []byte(b.String())
}()
type benchTLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert"`
}
type benchServer struct {
Host string `toml:"host"`
Port int `toml:"port"`
Hosts []string `toml:"hosts"`
TLS benchTLS `toml:"tls"`
}
type benchItem struct {
Name string `toml:"name"`
Weight int `toml:"weight"`
Flags []string `toml:"flags"`
}
type benchConfig struct {
Title string `toml:"title"`
Replicas int `toml:"replicas"`
Ratio float64 `toml:"ratio"`
Enabled bool `toml:"enabled"`
When time.Time `toml:"when"`
Local LocalDateTime `toml:"local"`
Tags []string `toml:"tags"`
Limits map[string]any `toml:"limits"`
Server benchServer `toml:"server"`
Items []benchItem `toml:"items"`
}
func BenchmarkParse(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(benchDoc)))
for b.Loop() {
if _, err := Parse(benchDoc); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMarshal(b *testing.B) {
tree, err := Parse(benchDoc)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.SetBytes(int64(len(benchDoc)))
for b.Loop() {
if _, err := Marshal(tree); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkStrictDecode(b *testing.B) {
dec := NewDecoder().DisallowUnknownFields()
b.ReportAllocs()
for b.Loop() {
var cfg benchConfig
if err := dec.Decode(benchDoc, &cfg); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParseLong(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(longDoc)))
for b.Loop() {
if _, err := Parse(longDoc); err != nil {
b.Fatal(err)
}
}
}
+88 -14
View File
@@ -105,16 +105,31 @@ func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
} }
func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error { func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
fields := structFields(dst.Type()) schema := newStructSchema(dst.Type())
for key, val := range tbl { for key, val := range tbl {
field, ok := fields[strings.ToLower(key)] field, ok := schema.byName[strings.ToLower(key)]
if !ok { if !ok {
if d.disallowUnknown { if d.disallowUnknown {
return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type()) return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type())
} }
if schema.embedMaps != nil {
// Leftover keys land in an untagged embedded map, the inverse
// of the encoder inlining that map's entries.
mv, err := fieldByIndex(dst, schema.embedMaps[0])
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
}
continue continue
} }
if err := d.assign(val, dst.Field(field)); err != nil { fv, err := fieldByIndex(dst, field.index)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
if err := d.assign(val, fv); err != nil {
return fmt.Errorf("%s: %w", key, err) return fmt.Errorf("%s: %w", key, err)
} }
} }
@@ -219,26 +234,85 @@ func setFloat(dst reflect.Value, v float64) error {
} }
} }
// structFields builds a lower-cased lookup of field name → field index for the // structFieldLoc locates one destination field by its index path from the
// exported fields of t, honouring `toml:"name"` tags. // struct root and by the depth the field sits at, which breaks name clashes
func structFields(t reflect.Type) map[string]int { // in favour of the shallower field.
fields := make(map[string]int, t.NumField()) type structFieldLoc struct {
index []int
depth int
}
// structSchema flattens the exported fields of t for decode, mirroring the
// encoder: an untagged embedded struct is inlined, so its own fields match
// keys of the same table, and an untagged embedded map is recorded in
// embedMaps (first declaration first) as the destination for leftover keys.
// When two fields resolve to one name, the shallower wins, then the later
// declaration.
type structSchema struct {
byName map[string]structFieldLoc
embedMaps [][]int
}
func newStructSchema(t reflect.Type) structSchema {
s := structSchema{byName: make(map[string]structFieldLoc, t.NumField())}
var walk func(t reflect.Type, prefix []int, depth int)
walk = func(t reflect.Type, prefix []int, depth int) {
for i := range t.NumField() { for i := range t.NumField() {
f := t.Field(i) f := t.Field(i)
if f.PkgPath != "" { // unexported if f.PkgPath != "" { // unexported
continue continue
} }
name := f.Name path := append(append([]int{}, prefix...), i)
name := ""
if tag, ok := f.Tag.Lookup("toml"); ok { if tag, ok := f.Tag.Lookup("toml"); ok {
tag = strings.Split(tag, ",")[0] name, _, _ = strings.Cut(tag, ",")
if tag == "-" { if name == "-" {
continue continue
} }
if tag != "" { }
name = tag if f.Anonymous && name == "" {
ft := f.Type
for ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
switch {
case ft.Kind() == reflect.Struct && !isScalarStruct(ft):
walk(ft, path, depth+1)
continue
case ft.Kind() == reflect.Map && ft.Key().Kind() == reflect.String:
s.embedMaps = append(s.embedMaps, path)
continue
}
name = f.Name
}
if name == "" {
name = f.Name
}
key := strings.ToLower(name)
if existing, ok := s.byName[key]; !ok || depth < existing.depth {
s.byName[key] = structFieldLoc{index: path, depth: depth}
} }
} }
fields[strings.ToLower(name)] = i
} }
return fields walk(t, nil, 0)
return s
}
// fieldByIndex walks an index path from a struct value, allocating nil
// pointers along the way so a key can reach through an embedded pointer
// struct. Every field on the path is exported, so each step is settable.
func fieldByIndex(v reflect.Value, path []int) (reflect.Value, error) {
for i, x := range path {
v = v.Field(x)
if i < len(path)-1 && v.Kind() == reflect.Pointer {
if v.IsNil() {
if !v.CanSet() {
return reflect.Value{}, fmt.Errorf("cannot allocate nil embedded pointer")
}
v.Set(reflect.New(v.Type().Elem()))
}
v = v.Elem()
}
}
return v, nil
} }
+112
View File
@@ -494,3 +494,115 @@ field = "y"
t.Errorf("Field = %q, want \"y\"", cfg.R.Field) t.Errorf("Field = %q, want \"y\"", cfg.R.Field)
} }
} }
// --- embedded field symmetry -----------------------------------------------
type RoundTripBase struct {
ID int `toml:"id"`
Name string `toml:"name"`
}
type RoundTripDerived struct {
RoundTripBase
X string `toml:"x"`
}
func TestUnmarshalEmbeddedStructRoundTrip(t *testing.T) {
orig := RoundTripDerived{ID: 1, Name: "b", X: "x"}
out, err := Marshal(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back RoundTripDerived
if err := Unmarshal(out, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if back != orig {
t.Fatalf("round-trip mismatch:\nwas: %+v\nnow: %+v", orig, back)
}
}
type RoundTripPtrCfg struct {
*RoundTripBase
X string `toml:"x"`
}
func TestUnmarshalEmbeddedPointerStruct(t *testing.T) {
var cfg RoundTripPtrCfg
if err := Unmarshal([]byte("id = 7\nname = \"n\"\nx = \"x\"\n"), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.RoundTripBase == nil || cfg.ID != 7 || cfg.Name != "n" || cfg.X != "x" {
t.Fatalf("decoded: %+v", cfg)
}
}
type RoundTripExtra map[string]int
type RoundTripMapCfg struct {
RoundTripExtra
X string `toml:"x"`
}
func TestUnmarshalEmbeddedMap(t *testing.T) {
var cfg RoundTripMapCfg
if err := Unmarshal([]byte("alpha = 1\nx = \"x\"\n"), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.RoundTripExtra["alpha"] != 1 || cfg.X != "x" {
t.Fatalf("decoded: %+v", cfg)
}
orig := RoundTripMapCfg{RoundTripExtra: RoundTripExtra{"a": 1}, X: "x"}
out, err := Marshal(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back RoundTripMapCfg
if err := Unmarshal(out, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if back.X != "x" || back.RoundTripExtra["a"] != 1 {
t.Fatalf("round-trip mismatch: %+v", back)
}
}
func TestUnmarshalEmbeddedNameClashShallowerWins(t *testing.T) {
type Inner struct {
Name string `toml:"name"`
Deep string `toml:"deep"`
}
type Outer struct {
Inner
Name string `toml:"name"`
}
var v Outer
if err := Unmarshal([]byte("name = \"outer\"\ndeep = \"d\"\n"), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if v.Name != "outer" || v.Deep != "d" {
t.Fatalf("decoded: %+v", v)
}
}
func TestUnmarshalUnknownKeyWithoutEmbeddedMap(t *testing.T) {
var cfg RoundTripDerived
if err := Unmarshal([]byte("rogue = 1\n"), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.ID != 0 || cfg.X != "" {
t.Fatalf("decoded: %+v", cfg)
}
}
func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
type Cfg struct {
RoundTripExtra
Name string `toml:"name"`
}
dec := NewDecoder().DisallowUnknownFields()
err := dec.Decode([]byte("name = \"n\"\nrogue = 1\n"), &Cfg{})
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown field error, got: %v", err)
}
}
+50 -8
View File
@@ -100,16 +100,23 @@ For a struct destination, a TOML key matches a field as follows:
1. The `toml:"name"` tag, using the part before any comma. The literal `-` 1. The `toml:"name"` tag, using the part before any comma. The literal `-`
excludes the field. excludes the field.
2. Without a tag, the lower-cased field name. 2. Without a tag, the lower-cased field name.
3. The key itself is lower-cased before lookup, so the match is 3. An anonymous (embedded) field without a tag is inlined: the decoder walks
into the embedded struct and matches its own fields against the same keys,
mirroring how the encoder flattens it. A nil embedded pointer struct is
allocated on demand. An untagged embedded map receives the keys no field
claims.
4. The key itself is lower-cased before lookup, so the match is
case-insensitive on both sides: `DATABASEURL` matches a field named case-insensitive on both sides: `DATABASEURL` matches a field named
`DatabaseUrl`. `DatabaseUrl`.
The match is exact after lower-casing. No separator is inserted, so a TOML key The match is exact after lower-casing. No separator is inserted, so a TOML key
`database_url` does not match a field named `DatabaseUrl`; tag such a field `database_url` does not match a field named `DatabaseUrl`; tag such a field
(`toml:"database_url"`) or use the lower-cased name as the key. When two fields (`toml:"database_url"`) or use the lower-cased name as the key. When two
resolve to the same name, the one declared later wins. fields resolve to the same name, the shallower one wins; at equal depth, the
one declared later wins.
Unknown keys are ignored by default; see [Strict decoding](#strict-decoding). Unknown keys are ignored by default, landing in an untagged embedded map when
the struct has one; [Strict decoding](#strict-decoding) rejects them instead.
### Numeric conversion ### Numeric conversion
@@ -233,10 +240,33 @@ 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.
Note the asymmetry: the encoder inlines untagged embedded structs, while the ### Tag options
decoder expects them under their lower-cased type name. A struct with an
untagged embedded struct therefore does not round-trip through `Unmarshal` into The part of a `toml` tag after the first comma carries options. Both options
the same type. 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.
Untagged embedded fields round-trip: the decoder inlines embedded structs and
routes unclaimed keys into an embedded map exactly where the encoder flattened
them.
### Group-by-kind layout ### Group-by-kind layout
@@ -289,6 +319,18 @@ func (p Port) MarshalTOML() (any, error) {
} }
``` ```
### Arrays
An array whose every element is a table (`[]struct`, `[]map[string]V`, after
pointer dereference) emits as an array of tables. TOML also lets one array mix
tables with scalars; such an array emits as a plain value array, with the
table elements rendered as inline tables:
```go
tree, _ := interpres.Parse([]byte(`arr = [1, {a = 2}, "x"]`))
out, _ := interpres.Marshal(tree) // arr = [1, {a = 2}, "x"]
```
### Empty arrays ### Empty arrays
A nil slice is always omitted. An empty (length 0) array of tables is always A nil slice is always omitted. An empty (length 0) array of tables is always
+1 -1
View File
@@ -4,7 +4,7 @@ How to work on interpres.
## Prerequisites ## Prerequisites
- Go 1.27.0, the version the `go` directive in `go.mod` declares. - Go 1.27.1, the version the `go` directive in `go.mod` declares.
- [just](https://github.com/casey/just) for the recipes. - [just](https://github.com/casey/just) for the recipes.
- The `toml-test` binary on `PATH` for the compliance recipe, installed with - The `toml-test` binary on `PATH` for the compliance recipe, installed with
`go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0`. `go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0`.
+108 -2
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"maps"
"math" "math"
"reflect" "reflect"
"slices" "slices"
@@ -188,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
} }
@@ -195,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 {
@@ -313,7 +360,17 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, []any{}, ctx) return doc.appendScalar(name, []any{}, ctx)
} }
if isTableElementValue(v.Index(0)) { // An array keeps the [[header]] form only when every element is a table.
// TOML lets one array mix tables with scalars, and that mix renders as a
// value array with the table elements written inline.
allTables := true
for i := range n {
if !isTableElementValue(v.Index(i)) {
allTables = false
break
}
}
if allTables {
subs := make([]*tomlDoc, n) subs := make([]*tomlDoc, n)
for i := range n { for i := range n {
if i%ctxCheckInterval == 0 { if i%ctxCheckInterval == 0 {
@@ -347,7 +404,8 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return nil return nil
} }
// Regular array of scalars. // Value array. Table elements normalise to map[string]any and the emitter
// writes them as inline tables.
items := make([]any, n) items := make([]any, n)
for i := range n { for i := range n {
if i%ctxCheckInterval == 0 { if i%ctxCheckInterval == 0 {
@@ -382,11 +440,22 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
// nested-array representations the emitter understands. Slices and arrays are // nested-array representations the emitter understands. Slices and arrays are
// recursively normalised so that nested arrays (e.g. [][]int) work. // recursively normalised so that nested arrays (e.g. [][]int) work.
func normaliseValue(v reflect.Value) (any, error) { func normaliseValue(v reflect.Value) (any, error) {
// Map and slice elements arrive wrapped in interface{}; look through them.
for v.Kind() == reflect.Interface && !v.IsNil() {
v = v.Elem()
}
if v.Kind() == reflect.Interface {
return nil, fmt.Errorf("cannot encode nil value")
}
if v.CanInterface() { if v.CanInterface() {
if m, ok := v.Interface().(Marshaler); ok { if m, ok := v.Interface().(Marshaler); ok {
return m.MarshalTOML() return m.MarshalTOML()
} }
} }
// The datetime structs are TOML scalars; the emitter renders each of them.
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
return v.Interface(), nil
}
switch v.Kind() { switch v.Kind() {
case reflect.String: case reflect.String:
return v.String(), nil return v.String(), nil
@@ -402,6 +471,21 @@ func normaliseValue(v reflect.Value) (any, error) {
return int64(u), nil return int64(u), nil
case reflect.Float32, reflect.Float64: case reflect.Float32, reflect.Float64:
return v.Float(), nil return v.Float(), nil
case reflect.Map:
// A table nested in a value array has no header form, so it renders
// inline; the keys normalise to strings for the emitter.
if v.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("map key must be string, got %s", v.Type().Key())
}
out := make(map[string]any, v.Len())
for _, k := range v.MapKeys() {
val, err := normaliseValue(v.MapIndex(k))
if err != nil {
return nil, fmt.Errorf("[%s]: %w", k.String(), err)
}
out[k.String()] = val
}
return out, nil
case reflect.Slice, reflect.Array: case reflect.Slice, reflect.Array:
items := make([]any, v.Len()) items := make([]any, v.Len())
for i := range v.Len() { for i := range v.Len() {
@@ -683,6 +767,8 @@ func (e *encoder) writeValue(val any) error {
} }
e.buf.WriteByte(']') e.buf.WriteByte(']')
return nil return nil
case map[string]any:
return e.writeInlineTable(v)
case nil: case nil:
return fmt.Errorf("interpres: cannot encode nil value") return fmt.Errorf("interpres: cannot encode nil value")
default: default:
@@ -690,6 +776,26 @@ func (e *encoder) writeValue(val any) error {
} }
} }
// writeInlineTable renders m as a TOML inline table with sorted keys, the
// order buildMapDoc uses for header tables. It backs the table elements of a
// value array, where the [[header]] form is not available.
func (e *encoder) writeInlineTable(m map[string]any) error {
keys := slices.Sorted(maps.Keys(m))
e.buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
e.buf.WriteString(", ")
}
e.writeKey(k)
e.buf.WriteString(" = ")
if err := e.writeValue(m[k]); err != nil {
return err
}
}
e.buf.WriteByte('}')
return nil
}
func (e *encoder) writeStringVal(s string) error { func (e *encoder) writeStringVal(s string) error {
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt { if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
return writeLiteralMultilineString(&e.buf, s) return writeLiteralMultilineString(&e.buf, s)
+152
View File
@@ -504,6 +504,79 @@ func TestMarshalNestedArrays(t *testing.T) {
} }
} }
func TestMarshalMixedArrayWithInlineTable(t *testing.T) {
// Parse accepts a mixed array (TOML allows any value kinds in one array),
// so Marshal of the parsed tree must re-emit it. The table element has no
// header form inside a value array and renders inline.
tree, err := Parse([]byte("arr = [1, {a = 2}, \"x\"]\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "arr = [1, {a = 2}, \"x\"]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
re, err := Parse(out)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
if !reflect.DeepEqual(tree, re) {
t.Fatalf("round-trip changed the tree:\nwas: %#v\nnow: %#v", tree, re)
}
}
func TestMarshalNestedInlineTables(t *testing.T) {
tree := map[string]any{
"mix": []any{
int64(1),
map[string]any{"deep": map[string]any{"n": int64(0)}, "list": []any{"a", true}},
map[string]any{},
},
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "mix = [1, {deep = {n = 0}, list = [\"a\", true]}, {}]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalInlineTableWithDatetime(t *testing.T) {
when := time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)
tree := map[string]any{
"mix": []any{when, map[string]any{"t": LocalDateTime{when}}},
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "mix = [1979-05-27T07:32:00Z, {t = 1979-05-27T07:32:00}]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalArrayOfTablesStaysHeaderForm(t *testing.T) {
tree, err := Parse([]byte("[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) { func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in // strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
// exponent). The encoder must strip it so the output is "1e+6". // exponent). The encoder must strip it so the output is "1e+6".
@@ -675,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,
+119
View File
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"math"
"reflect"
"testing"
"time"
)
// FuzzParse drives the parser with arbitrary input and holds it to the
// round-trip invariant: every document Parse accepts must survive its own
// re-emission. Marshal of the parsed tree must succeed, the emitted document
// must parse again, and the re-parsed tree must equal the original one.
func FuzzParse(f *testing.F) {
seeds := []string{
"",
"title = \"interpres\"\n",
"[server]\nhost = \"localhost\"\nport = 8080\n\n[server.tls]\nenabled = true\n",
"[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n",
"inline = { a = 1, b = [2, 3], c = { d = 4 } }\n",
"arr = [1, 2.5, \"three\", true, 1979-05-27T07:32:00Z]\n",
"mix = [1, {a = 2}, \"x\"]\n",
"when = 1979-05-27T07:32:00Z\nlocal = 1979-05-27T07:32:00.999\nd = 1979-05-27\nt = 07:32:00\n",
"multi = \"\"\"\nlines\n\"\"\"\nlit = 'literal'\n",
"esc = \"\\u0000\\t\\n\\\"\\\\\"\n",
"neg = -0.0\nnan = nan\ninf = -inf\nexp = 1e6\n",
"\"quoted key\" = 'value'\n'1979-05-27' = 1\na.b.c = { d = \"dotted\" }\n",
"hex = 0xFF\noct = 0o755\nbin = 0b1010\nsep = 1_000_000\n",
"x = \"unterminated\n",
"[a]\n[a]\n",
"n = 0x1_0000_0000_0000_0000\n",
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
tree, err := Parse(data)
if err != nil {
return
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal of a parsed tree failed: %v\ntree: %#v", err, tree)
}
re, err := Parse(out)
if err != nil {
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
}
if !tomlEqual(tree, re) {
t.Fatalf("round-trip changed the tree\ninput: %q\ndoc:\n%s\nwas: %#v\nnow: %#v", data, out, tree, re)
}
})
}
// tomlEqual reports whether two parsed trees are equal as TOML values. It
// differs from reflect.DeepEqual where DeepEqual is wrong for this domain:
// NaN compares equal to itself, date-times compare by their canonical TOML
// rendering so two parses of one document stay equal, and the local variants
// compare through their String form, which fully determines the value.
func tomlEqual(a, b any) bool {
switch av := a.(type) {
case nil:
return b == nil
case float64:
bv, ok := b.(float64)
return ok && (av == bv || (math.IsNaN(av) && math.IsNaN(bv)))
case time.Time:
bv, ok := b.(time.Time)
return ok && av.Format(time.RFC3339Nano) == bv.Format(time.RFC3339Nano)
case LocalDateTime:
bv, ok := b.(LocalDateTime)
return ok && av.String() == bv.String()
case LocalDate:
bv, ok := b.(LocalDate)
return ok && av.String() == bv.String()
case LocalTime:
bv, ok := b.(LocalTime)
return ok && av.String() == bv.String()
case []any:
bv, ok := b.([]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case []map[string]any:
bv, ok := b.([]map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case map[string]any:
bv, ok := b.(map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
other, ok := bv[k]
if !ok || !tomlEqual(v, other) {
return false
}
}
return true
default:
return reflect.DeepEqual(a, b)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
module sourcedock.dev/petrbalvin/interpres module sourcedock.dev/petrbalvin/interpres
go 1.27.0 go 1.27.1
+8 -3
View File
@@ -150,13 +150,18 @@ 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]]`),
// while other empty arrays emit as `key = []`. // while other empty arrays emit as `key = []`.
// - Other slices and arrays become TOML arrays. // - Other slices and arrays become TOML arrays; a table element inside a
// value array (for example an inline table in a mixed array) emits as an
// inline table.
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time // - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local // (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
// variants). // variants).