Compare commits
11
Commits
v1.0.0
...
1e3198c8b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3198c8b6 | ||
|
|
cdb42de561 | ||
|
|
0f6d81fe3e | ||
|
|
274b8a488c | ||
|
|
b061c97a81 | ||
|
|
fc50e3c49a | ||
|
|
93c36cf376 | ||
|
|
58e7dfb1d0 | ||
|
|
510cfb5182 | ||
|
|
3ac0b1e301 | ||
|
|
2737a5ac87 |
@@ -30,6 +30,14 @@ env:
|
||||
GOFLAGS: -p=1
|
||||
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:
|
||||
test:
|
||||
runs-on: fedora
|
||||
|
||||
+18
-1
@@ -9,7 +9,24 @@ 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
+21
-2
@@ -1,10 +1,29 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
```sh
|
||||
|
||||
@@ -31,7 +31,7 @@ As a library:
|
||||
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
|
||||
|
||||
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
fields := structFields(dst.Type())
|
||||
schema := newStructSchema(dst.Type())
|
||||
for key, val := range tbl {
|
||||
field, ok := fields[strings.ToLower(key)]
|
||||
field, ok := schema.byName[strings.ToLower(key)]
|
||||
if !ok {
|
||||
if d.disallowUnknown {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// exported fields of t, honouring `toml:"name"` tags.
|
||||
func structFields(t reflect.Type) map[string]int {
|
||||
fields := make(map[string]int, t.NumField())
|
||||
// structFieldLoc locates one destination field by its index path from the
|
||||
// struct root and by the depth the field sits at, which breaks name clashes
|
||||
// in favour of the shallower field.
|
||||
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() {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" { // unexported
|
||||
continue
|
||||
}
|
||||
name := f.Name
|
||||
path := append(append([]int{}, prefix...), i)
|
||||
name := ""
|
||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||
tag = strings.Split(tag, ",")[0]
|
||||
if tag == "-" {
|
||||
name, _, _ = strings.Cut(tag, ",")
|
||||
if name == "-" {
|
||||
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
@@ -494,3 +494,115 @@ field = "y"
|
||||
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
@@ -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 `-`
|
||||
excludes the field.
|
||||
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
|
||||
`DatabaseUrl`.
|
||||
|
||||
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
|
||||
(`toml:"database_url"`) or use the lower-cased name as the key. When two fields
|
||||
resolve to the same name, the one declared later wins.
|
||||
(`toml:"database_url"`) or use the lower-cased name as the key. When two
|
||||
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
|
||||
|
||||
@@ -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
|
||||
nil map emits nothing.
|
||||
|
||||
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
|
||||
the same type.
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
A nil slice is always omitted. An empty (length 0) array of tables is always
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ How to work on interpres.
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -188,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
|
||||
}
|
||||
@@ -195,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 {
|
||||
@@ -313,7 +360,17 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
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)
|
||||
for i := range n {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
@@ -347,7 +404,8 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
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)
|
||||
for i := range n {
|
||||
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
|
||||
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
||||
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 m, ok := v.Interface().(Marshaler); ok {
|
||||
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() {
|
||||
case reflect.String:
|
||||
return v.String(), nil
|
||||
@@ -402,6 +471,21 @@ func normaliseValue(v reflect.Value) (any, error) {
|
||||
return int64(u), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
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:
|
||||
items := make([]any, v.Len())
|
||||
for i := range v.Len() {
|
||||
@@ -683,6 +767,8 @@ func (e *encoder) writeValue(val any) error {
|
||||
}
|
||||
e.buf.WriteByte(']')
|
||||
return nil
|
||||
case map[string]any:
|
||||
return e.writeInlineTable(v)
|
||||
case nil:
|
||||
return fmt.Errorf("interpres: cannot encode nil value")
|
||||
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 {
|
||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
||||
return writeLiteralMultilineString(&e.buf, s)
|
||||
|
||||
+152
@@ -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) {
|
||||
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
||||
// 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) {
|
||||
m := map[string]any{
|
||||
"zeta": 1,
|
||||
|
||||
+119
@@ -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,3 +1,3 @@
|
||||
module sourcedock.dev/petrbalvin/interpres
|
||||
|
||||
go 1.27.0
|
||||
go 1.27.1
|
||||
|
||||
+8
-3
@@ -150,13 +150,18 @@ 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]]`),
|
||||
// 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
|
||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||
// variants).
|
||||
|
||||
Reference in New Issue
Block a user