Compare commits
16
Commits
v1.0.0
...
18f1cd51e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f1cd51e9 | ||
|
|
3cd538fad6 | ||
|
|
e19a6f35f1 | ||
|
|
5a270d0879 | ||
|
|
3f41266710 | ||
|
|
1e3198c8b6 | ||
|
|
cdb42de561 | ||
|
|
0f6d81fe3e | ||
|
|
274b8a488c | ||
|
|
b061c97a81 | ||
|
|
fc50e3c49a | ||
|
|
93c36cf376 | ||
|
|
58e7dfb1d0 | ||
|
|
510cfb5182 | ||
|
|
3ac0b1e301 | ||
|
|
2737a5ac87 |
@@ -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
|
||||||
@@ -91,10 +99,11 @@ jobs:
|
|||||||
# output has to be captured into a variable.
|
# output has to be captured into a variable.
|
||||||
env:
|
env:
|
||||||
GOBIN: ${{ gitea.workspace }}/bin
|
GOBIN: ${{ gitea.workspace }}/bin
|
||||||
run: go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0
|
run: go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0
|
||||||
|
|
||||||
- name: Build the decoder
|
- name: Build the decoder
|
||||||
run: go build -o bin/interpres-decode ./cmd/interpres-decode
|
run: go build -o bin/interpres-decode ./cmd/interpres-decode
|
||||||
|
|
||||||
- name: Compliance suite
|
- name: Compliance suite
|
||||||
run: bin/toml-test bin/interpres-decode
|
# interpres implements TOML 1.0; v2 tests 1.1 by default, so the mode is pinned.
|
||||||
|
run: bin/toml-test test -decoder=bin/interpres-decode -toml=1.0
|
||||||
|
|||||||
+40
-1
@@ -9,7 +9,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
-
|
- `interpres-decode -validate [file ...]`: a validate mode beside the
|
||||||
|
toml-test adapter. It parses each named file, or stdin when none are named,
|
||||||
|
prints one line per invalid document to stderr, and exits 0 when all are
|
||||||
|
valid, 1 when one is not, and 2 on a usage or read failure. Install it with
|
||||||
|
`go install .../cmd/interpres-decode@latest`; releases still ship no
|
||||||
|
binaries.
|
||||||
|
- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
|
||||||
|
typed errors carrying the key path, read with `errors.AsType` instead of
|
||||||
|
parsing the message text. The rendered messages keep their shape; the only
|
||||||
|
visible change is that an encode failure on a top-level field no longer
|
||||||
|
gains a meaningless leading dot in its path.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The compliance suite is [toml-test](https://github.com/toml-lang/toml-test)
|
||||||
|
v2.2.0, run in TOML 1.0 mode. The new corpus holds 205 valid and 474 invalid
|
||||||
|
cases (v1.6.0 had 185 and 371), and it caught the two documents the parser
|
||||||
|
still accepted, fixed below.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A float with an exponent marker but no digits (`1e`, `0.0E`) is rejected;
|
||||||
|
the exponent requires at least one digit.
|
||||||
|
- A date-time offset outside 00:00 through 23:59 is rejected; such offsets
|
||||||
|
were accepted and silently rolled over (`+00:60` decoded as `+01:00`).
|
||||||
|
- 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
@@ -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
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
A TOML 1.0 parser and encoder for Go, written with the standard library alone.
|
A TOML 1.0 parser and encoder for Go, written with the standard library alone.
|
||||||
`interpres` (Latin for *interpreter*) gives zero-dependency programs an
|
`interpres` (Latin for *interpreter*) gives zero-dependency programs an
|
||||||
`encoding/json`-style API for reading and writing TOML, and passes the entire
|
`encoding/json`-style API for reading and writing TOML, and passes the entire
|
||||||
official [toml-test](https://github.com/toml-lang/toml-test) suite: 185 valid
|
official [toml-test](https://github.com/toml-lang/toml-test) suite: 205 valid
|
||||||
and 371 invalid cases, zero failures.
|
and 474 invalid cases, zero failures.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
|
|||||||
|
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
||||||
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules
|
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules
|
||||||
- [docs/CLI.md](docs/CLI.md): the interpres-decode toml-test adapter
|
- [docs/CLI.md](docs/CLI.md): the interpres-decode toml-test adapter and validator
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Command interpres-decode reads a TOML document from standard input and writes
|
// Command interpres-decode is the toml-test harness adapter and a TOML
|
||||||
// the toml-test "tagged JSON" representation to standard output.
|
// validator. Without flags it reads a TOML document from standard input and
|
||||||
|
// writes the toml-test "tagged JSON" representation to standard output. With
|
||||||
|
// -validate it checks the named documents, or standard input when none are
|
||||||
|
// named, and exits non-zero on the first invalid one:
|
||||||
//
|
//
|
||||||
// It exits non-zero on a parse error, which is how the toml-test harness checks
|
// interpres-decode -validate config.toml
|
||||||
// that invalid documents are rejected. Run the official suite against it with:
|
//
|
||||||
|
// Run the official suite against the adapter with:
|
||||||
//
|
//
|
||||||
// toml-test ./interpres-decode
|
// toml-test ./interpres-decode
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -23,13 +29,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(Run(os.Stdin, os.Stdout, os.Stderr))
|
os.Exit(Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run reads a TOML document from stdin, emits the toml-test tagged-JSON form
|
// Run runs the command line and returns the process exit code: 0 success,
|
||||||
// on stdout, and returns the process exit code (0 success, 1 parse error,
|
// 1 an invalid document, 2 a usage, reading, encoding, or
|
||||||
// 2 I/O, encoding, or unsupported-value error).
|
// unsupported-value error.
|
||||||
func Run(stdin io.Reader, stdout, stderr io.Writer) int {
|
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
validate := fs.Bool("validate", false, "validate the documents instead of emitting tagged JSON")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *validate {
|
||||||
|
return validatePaths(fs.Args(), stdin, stderr)
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
fmt.Fprintln(stderr, "interpres-decode: the adapter mode takes no arguments; name files with -validate")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
data, err := io.ReadAll(stdin)
|
data, err := io.ReadAll(stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, "read stdin:", err)
|
fmt.Fprintln(stderr, "read stdin:", err)
|
||||||
@@ -54,6 +76,39 @@ func Run(stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePaths parses every named file, or standard input when none are
|
||||||
|
// named, and reports each invalid document on stderr. It returns 0 when all
|
||||||
|
// documents parse, 1 when one does not, and 2 on a usage or read failure.
|
||||||
|
func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
paths = []string{"-"}
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, p := range paths {
|
||||||
|
name := p
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
if p == "-" {
|
||||||
|
data, err = io.ReadAll(stdin)
|
||||||
|
name = "<stdin>"
|
||||||
|
} else {
|
||||||
|
data, err = os.ReadFile(p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "interpres-decode: %s: %v\n", name, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if _, err := interpres.Parse(data); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "%s: %v\n", name, err)
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// tag converts an interpres value into its toml-test tagged-JSON form. Tables
|
// tag converts an interpres value into its toml-test tagged-JSON form. Tables
|
||||||
// become JSON objects and arrays become JSON arrays; scalars are wrapped in a
|
// become JSON objects and arrays become JSON arrays; scalars are wrapped in a
|
||||||
// {"type", "value"} object. An error is returned for value types the encoder
|
// {"type", "value"} object. An error is returned for value types the encoder
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,7 +21,7 @@ func TestRunParsesValidTOML(t *testing.T) {
|
|||||||
port = 8080
|
port = 8080
|
||||||
enabled = true
|
enabled = true
|
||||||
`))
|
`))
|
||||||
if code := Run(in, &stdout, &stderr); code != 0 {
|
if code := Run(nil, in, &stdout, &stderr); code != 0 {
|
||||||
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
var got map[string]any
|
var got map[string]any
|
||||||
@@ -41,7 +42,7 @@ enabled = true
|
|||||||
func TestRunRejectsInvalidInput(t *testing.T) {
|
func TestRunRejectsInvalidInput(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
in := bytes.NewReader([]byte("v = \n"))
|
in := bytes.NewReader([]byte("v = \n"))
|
||||||
code := Run(in, &stdout, &stderr)
|
code := Run(nil, in, &stdout, &stderr)
|
||||||
if code != 1 {
|
if code != 1 {
|
||||||
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ func TestRunRejectsInvalidInput(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
code := Run(errorReader{}, &stdout, &stderr)
|
code := Run(nil, errorReader{}, &stdout, &stderr)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ func TestRunEncodeErrorReturnsTwo(t *testing.T) {
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
w := errorWriter{}
|
w := errorWriter{}
|
||||||
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
||||||
code := Run(in, w, &stderr)
|
code := Run(nil, in, w, &stderr)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -210,3 +211,75 @@ func TestTaggedHelper(t *testing.T) {
|
|||||||
t.Errorf("tagged = %#v", got)
|
t.Errorf("tagged = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateStdinAcceptsValidDocument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("title = \"ok\"\n"))
|
||||||
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("validate should be quiet on success, stdout %q stderr %q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStdinRejectsInvalidDocument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("title = \"unterminated\n"))
|
||||||
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 1 {
|
||||||
|
t.Fatalf("Run returned %d, want 1; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "<stdin>") || !strings.Contains(stderr.String(), "line 1") {
|
||||||
|
t.Fatalf("stderr = %q, want the name and the line", stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout should stay empty, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
good := dir + "/good.toml"
|
||||||
|
bad := dir + "/bad.toml"
|
||||||
|
if err := os.WriteFile(good, []byte("a = 1\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(bad, []byte("a =\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-validate", good}, nil, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("one valid file: Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if code := Run([]string{"-validate", good, bad}, nil, &stdout, &stderr); code != 1 {
|
||||||
|
t.Fatalf("valid plus invalid: Run returned %d, want 1; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), bad) || !strings.Contains(stderr.String(), "line 1") {
|
||||||
|
t.Fatalf("stderr = %q, want the file name and the line", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingFileReturnsTwo(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-validate", "no-such-file.toml"}, nil, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterModeRejectsPositionalArgument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("a = 1\n"))
|
||||||
|
if code := Run([]string{"file.toml"}, in, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "-validate") {
|
||||||
|
t.Fatalf("stderr = %q, want it to point at -validate", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownFlagReturnsTwo(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-nope"}, nil, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
@@ -6,6 +6,7 @@ package interpres
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -78,6 +79,12 @@ var dateTimeShape = regexp.MustCompile(
|
|||||||
`|^\d{2}:\d{2}:\d{2}(\.\d+)?$`,
|
`|^\d{2}:\d{2}:\d{2}(\.\d+)?$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// offsetBounds extracts the numeric offset of a date-time. The ABNF bounds it
|
||||||
|
// to 00:00 through 23:59, but time.Parse accepts values outside that range
|
||||||
|
// and rolls them over (for example "+00:60" becomes "+01:00"), so the bounds
|
||||||
|
// are enforced here.
|
||||||
|
var offsetBounds = regexp.MustCompile(`([+-])(\d{2}):(\d{2})$`)
|
||||||
|
|
||||||
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
||||||
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
||||||
// LocalTime) and whether the token was a date-time at all.
|
// LocalTime) and whether the token was a date-time at all.
|
||||||
@@ -91,6 +98,13 @@ func parseDateTime(tok string) (any, bool) {
|
|||||||
if !dateTimeShape.MatchString(tok) {
|
if !dateTimeShape.MatchString(tok) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if m := offsetBounds.FindStringSubmatch(tok); m != nil {
|
||||||
|
hour, _ := strconv.Atoi(m[2])
|
||||||
|
minute, _ := strconv.Atoi(m[3])
|
||||||
|
if hour > 23 || minute > 59 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
||||||
norm := strings.ToUpper(tok)
|
norm := strings.ToUpper(tok)
|
||||||
for _, layout := range offsetDateTimeLayouts {
|
for _, layout := range offsetDateTimeLayouts {
|
||||||
|
|||||||
@@ -105,17 +105,32 @@ 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 newDecodeError(key, err)
|
||||||
|
}
|
||||||
|
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := d.assign(val, dst.Field(field)); err != nil {
|
fv, err := fieldByIndex(dst, field.index)
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
if err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
|
}
|
||||||
|
if err := d.assign(val, fv); err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -132,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
|||||||
for key, val := range tbl {
|
for key, val := range tbl {
|
||||||
elem := reflect.New(elemType).Elem()
|
elem := reflect.New(elemType).Elem()
|
||||||
if err := d.assign(val, elem); err != nil {
|
if err := d.assign(val, elem); err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
||||||
}
|
}
|
||||||
@@ -146,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
@@ -160,7 +175,7 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
@@ -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 {
|
||||||
for i := range t.NumField() {
|
index []int
|
||||||
f := t.Field(i)
|
depth int
|
||||||
if f.PkgPath != "" { // unexported
|
}
|
||||||
continue
|
|
||||||
}
|
// structSchema flattens the exported fields of t for decode, mirroring the
|
||||||
name := f.Name
|
// encoder: an untagged embedded struct is inlined, so its own fields match
|
||||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
// keys of the same table, and an untagged embedded map is recorded in
|
||||||
tag = strings.Split(tag, ",")[0]
|
// embedMaps (first declaration first) as the destination for leftover keys.
|
||||||
if tag == "-" {
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
if tag != "" {
|
path := append(append([]int{}, prefix...), i)
|
||||||
name = tag
|
name := ""
|
||||||
|
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||||
|
name, _, _ = strings.Cut(tag, ",")
|
||||||
|
if name == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+160
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -494,3 +495,162 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Weight uint8 `toml:"weight"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
err := Unmarshal([]byte("[[items]]\nname = \"a\"\nweight = 300\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an overflow error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
want := []string{"items", "[0]", "weight"}
|
||||||
|
if !slices.Equal(de.Path, want) {
|
||||||
|
t.Fatalf("Path = %v, want %v", de.Path, want)
|
||||||
|
}
|
||||||
|
if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") {
|
||||||
|
t.Fatalf("Err = %v", de.Err)
|
||||||
|
}
|
||||||
|
// The rendered message keeps its shape: segments joined with ": ".
|
||||||
|
wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8"
|
||||||
|
if err.Error() != wantMsg {
|
||||||
|
t.Fatalf("message = %q, want %q", err.Error(), wantMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorOnMapDestination(t *testing.T) {
|
||||||
|
var m map[string]uint8
|
||||||
|
err := Unmarshal([]byte("count = -1\n"), &m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(de.Path, []string{"count"}) {
|
||||||
|
t.Fatalf("Path = %v", de.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+78
-12
@@ -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
|
||||||
@@ -362,6 +404,27 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `type DecodeError struct{ Path []string; Err error }`
|
||||||
|
|
||||||
|
Wraps a decoding failure with the key path at which it happened. `Path` lists
|
||||||
|
one segment per level from the document root, the outermost key first: a key
|
||||||
|
contributes its name, an array element its bracketed index, so the path of the
|
||||||
|
`weight` field in the first item reads `["items", "[0]", "weight"]`. The
|
||||||
|
rendered message is unchanged by the type; read the fields instead of parsing
|
||||||
|
the message:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
fmt.Println(de.Path, de.Err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `type EncodeError struct{ Path string; Err error }`
|
||||||
|
|
||||||
|
Wraps an encoding failure with the key path of the value that failed, in the
|
||||||
|
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the
|
||||||
|
same way.
|
||||||
|
|
||||||
### `type Decoder`
|
### `type Decoder`
|
||||||
|
|
||||||
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
||||||
@@ -418,11 +481,14 @@ types are produced by `Parse` and accepted by `Marshal`.
|
|||||||
The entry points return:
|
The entry points return:
|
||||||
|
|
||||||
- `*SyntaxError` for a malformed document, with the 1-based line
|
- `*SyntaxError` for a malformed document, with the 1-based line
|
||||||
- a plain error for everything else: a non-pointer decode target, a type
|
- `*DecodeError` for a decoding failure, with the key path in `Path`
|
||||||
mismatch, an overflow, a marshal policy violation, a cancelled context
|
- `*EncodeError` for an encoding failure, with the key path in `Path`
|
||||||
|
- a plain error for the rest: a non-pointer decode target, a cancelled
|
||||||
|
context, a key that is not valid UTF-8
|
||||||
|
|
||||||
Decode and encode failures are wrapped with the key path or element index using
|
Decode and encode failures carry the key path or element index in the typed
|
||||||
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
|
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
|
||||||
|
path reads from a field instead of the message text.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+47
-13
@@ -1,26 +1,45 @@
|
|||||||
# Command line
|
# Command line
|
||||||
|
|
||||||
The reference below is taken from the program itself. `interpres-decode` is the
|
The reference below is taken from the program itself. `interpres-decode` is
|
||||||
toml-test harness adapter, not a general-purpose tool: it takes no flags and no
|
the toml-test harness adapter, and it also validates documents. Install it
|
||||||
arguments, reads one TOML document from stdin, and writes the toml-test
|
with Go itself, no release assets involved:
|
||||||
tagged-JSON form to stdout.
|
|
||||||
|
```sh
|
||||||
|
go install sourcedock.dev/petrbalvin/interpres/cmd/interpres-decode@latest
|
||||||
|
```
|
||||||
|
|
||||||
## Synopsis
|
## Synopsis
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
interpres-decode < document.toml
|
interpres-decode [flags]
|
||||||
|
interpres-decode -validate [file ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
Build it with `just build`, which compiles it into `bin/interpres-decode`, or
|
Without `-validate` the program is the toml-test adapter: it takes no
|
||||||
run it straight from the module directory with `just run`.
|
arguments, reads one TOML document from stdin, and writes the toml-test
|
||||||
|
tagged-JSON form to stdout. Build it locally with `just build`, which
|
||||||
|
compiles it into `bin/interpres-decode`, or run it straight from the module
|
||||||
|
directory with `just run`.
|
||||||
|
|
||||||
|
With `-validate` the program parses each named file instead, or stdin when no
|
||||||
|
file is named, and prints one line per invalid document to stderr. It is
|
||||||
|
quiet on valid documents, which is the shape a CI step wants. The `-` name
|
||||||
|
means stdin.
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `-validate` | validate the documents instead of emitting tagged JSON |
|
||||||
|
| `-h` | print the usage |
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `0` | the document parsed, tagged JSON written to stdout |
|
| `0` | adapter: the document parsed and the tagged JSON was written; validate: every document parsed |
|
||||||
| `1` | parse error, the document is malformed; the message goes to stderr |
|
| `1` | adapter: parse error; validate: at least one document is invalid |
|
||||||
| `2` | reading stdin failed, or a value has no tagged representation |
|
| `2` | a usage error, a read failure, or a value with no tagged representation |
|
||||||
|
|
||||||
## Wire format
|
## Wire format
|
||||||
|
|
||||||
@@ -59,13 +78,28 @@ port = 9090
|
|||||||
' | ./bin/interpres-decode
|
' | ./bin/interpres-decode
|
||||||
```
|
```
|
||||||
|
|
||||||
The output is the equivalent value tree as one JSON object. Run the official
|
The output is the equivalent value tree as one JSON object. Validate the
|
||||||
compliance suite against the binary:
|
TOML files of another repository in CI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
interpres-decode -validate config.toml deploy/example.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
An invalid document reports the file and the library's line number:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ interpres-decode -validate bad.toml
|
||||||
|
bad.toml: interpres: line 1: expected a value
|
||||||
|
$ echo $?
|
||||||
|
1
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the official compliance suite against the adapter:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just toml-test
|
just toml-test
|
||||||
```
|
```
|
||||||
|
|
||||||
That recipe needs the `toml-test` binary on `PATH`, installed with
|
That recipe needs the `toml-test` binary on `PATH`, installed with
|
||||||
`go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0`. The full
|
`go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0`. The full
|
||||||
reference for the library itself is [API.md](API.md).
|
reference for the library itself is [API.md](API.md).
|
||||||
|
|||||||
+2
-2
@@ -4,10 +4,10 @@ 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/v2/cmd/toml-test@v2.2.0`.
|
||||||
|
|
||||||
The module has no third-party dependencies, so there is nothing else to fetch.
|
The module has no third-party dependencies, so there is nothing else to fetch.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ package interpres
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -188,6 +190,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 +200,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 {
|
||||||
@@ -240,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
|||||||
if m, ok := v.Interface().(Marshaler); ok {
|
if m, ok := v.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||||
}
|
}
|
||||||
v = reflect.ValueOf(mv)
|
v = reflect.ValueOf(mv)
|
||||||
}
|
}
|
||||||
@@ -313,7 +361,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 {
|
||||||
@@ -323,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
||||||
switch ev.Kind() {
|
switch ev.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
if isScalarStruct(ev.Type()) {
|
if isScalarStruct(ev.Type()) {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")}
|
||||||
}
|
}
|
||||||
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -339,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name)
|
return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")}
|
||||||
}
|
}
|
||||||
subs[i] = sub
|
subs[i] = sub
|
||||||
}
|
}
|
||||||
@@ -347,7 +405,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 {
|
||||||
@@ -357,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
if ev.CanInterface() {
|
if ev.CanInterface() {
|
||||||
if m, ok := ev.Interface().(Marshaler); ok {
|
if m, ok := ev.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
}
|
}
|
||||||
ev = reflect.ValueOf(mv)
|
ev = reflect.ValueOf(mv)
|
||||||
ev = followPtr(ev)
|
ev = followPtr(ev)
|
||||||
@@ -371,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
val, err := normaliseValue(ev)
|
val, err := normaliseValue(ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
}
|
}
|
||||||
items[i] = val
|
items[i] = val
|
||||||
}
|
}
|
||||||
@@ -382,11 +441,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 +472,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 +768,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 +777,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)
|
||||||
|
|||||||
+222
@@ -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,
|
||||||
@@ -1008,3 +1160,73 @@ type Custom struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
||||||
|
|
||||||
|
// encodeErrBad is a Marshaler whose MarshalTOML always fails.
|
||||||
|
type encodeErrBad struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (encodeErrBad) MarshalTOML() (any, error) { return nil, errors.New("bad timestamp") }
|
||||||
|
|
||||||
|
func TestEncodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Server Inner `toml:"server"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{Server: Inner{Port: encodeErrBad{}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a marshal error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "server.port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "server.port")
|
||||||
|
}
|
||||||
|
if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
|
||||||
|
t.Fatalf("Err = %v", ee.Err)
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: server.port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{})
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "port")
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
cfg := map[string]any{
|
||||||
|
"items": []any{Item{}, 3},
|
||||||
|
}
|
||||||
|
_, err := Marshal(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a heterogeneous array error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "items[0]" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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
|
module sourcedock.dev/petrbalvin/interpres
|
||||||
|
|
||||||
go 1.27.0
|
go 1.27.1
|
||||||
|
|||||||
+61
-3
@@ -21,6 +21,7 @@ package interpres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
|
|||||||
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A DecodeError wraps a decoding failure with the key path at which it
|
||||||
|
// happened. Path lists one segment per level from the document root, the
|
||||||
|
// outermost key first: a key contributes its name and an array element its
|
||||||
|
// bracketed index, so the path of the weight field in the first item reads
|
||||||
|
// ["items", "[0]", "weight"]. The rendered message is unchanged by the type;
|
||||||
|
// read it programmatically with errors.AsType:
|
||||||
|
//
|
||||||
|
// if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
// fmt.Println(de.Path, de.Err)
|
||||||
|
// }
|
||||||
|
type DecodeError struct {
|
||||||
|
// Path is the key path from the document root, outermost key first.
|
||||||
|
Path []string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *DecodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// newDecodeError wraps err with one path segment. The rest of the path comes
|
||||||
|
// from the DecodeError err already carries, if any: the decoder wraps each
|
||||||
|
// key and index on its way down, so the innermost wrap holds the deepest
|
||||||
|
// segments and each outer wrap prepends one.
|
||||||
|
func newDecodeError(key string, err error) *DecodeError {
|
||||||
|
path := make([]string, 0, 4)
|
||||||
|
path = append(path, key)
|
||||||
|
if de, ok := errors.AsType[*DecodeError](err); ok {
|
||||||
|
path = append(path, de.Path...)
|
||||||
|
}
|
||||||
|
return &DecodeError{Path: path, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EncodeError wraps an encoding failure with the key path of the value
|
||||||
|
// that failed, in the notation of a TOML document: fields join with dots and
|
||||||
|
// an array element carries its bracketed index, so the path of the third
|
||||||
|
// port under server reads "server.ports[2]". The rendered message is
|
||||||
|
// unchanged by the type; read it programmatically with errors.AsType.
|
||||||
|
type EncodeError struct {
|
||||||
|
// Path is the key path of the failing value.
|
||||||
|
Path string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *EncodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// Parse decodes a TOML document into a nested map[string]any.
|
// Parse decodes a TOML document into a nested map[string]any.
|
||||||
//
|
//
|
||||||
// Values are mapped to Go types as follows: strings to string, integers to
|
// Values are mapped to Go types as follows: strings to string, integers to
|
||||||
@@ -150,13 +203,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).
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ func TestRejectsInvalidNumbers(t *testing.T) {
|
|||||||
"01", "-01", "00",
|
"01", "-01", "00",
|
||||||
"1__0", "_1", "1_", "0x_1", "1_.0",
|
"1__0", "_1", "1_", "0x_1", "1_.0",
|
||||||
"1.", ".5", "1.2.3", "1.e2",
|
"1.", ".5", "1.2.3", "1.e2",
|
||||||
|
"1e", "1e+", "1e-", "0.0E", "0.0e", "1.5e+",
|
||||||
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
||||||
"+0x1",
|
"+0x1",
|
||||||
} {
|
} {
|
||||||
@@ -379,6 +380,34 @@ func TestRejectsInvalidNumbers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsOffsetOutOfRange(t *testing.T) {
|
||||||
|
for _, tok := range []string{
|
||||||
|
"1979-05-27T07:32:00+00:60",
|
||||||
|
"1979-05-27T07:32:00-00:99",
|
||||||
|
"1979-05-27T07:32:00+24:00",
|
||||||
|
"1979-05-27T07:32:00+99:99",
|
||||||
|
} {
|
||||||
|
if _, err := Parse([]byte("v = " + tok + "\n")); err == nil {
|
||||||
|
t.Errorf("%q: expected an error, got none", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAcceptsOffsetBounds(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("a = 1979-05-27T07:32:00+23:59\nb = 1979-05-27T07:32:00-23:59\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
a := tree["a"].(time.Time)
|
||||||
|
if _, offset := a.Zone(); offset != 23*3600+59*60 {
|
||||||
|
t.Fatalf("a offset = %d, want %d", offset, 23*3600+59*60)
|
||||||
|
}
|
||||||
|
b := tree["b"].(time.Time)
|
||||||
|
if _, offset := b.Zone(); offset != -(23*3600 + 59*60) {
|
||||||
|
t.Fatalf("b offset = %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
||||||
cases := map[string]any{
|
cases := map[string]any{
|
||||||
"0": int64(0),
|
"0": int64(0),
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ run:
|
|||||||
dev:
|
dev:
|
||||||
go run -buildvcs=true {{package}}
|
go run -buildvcs=true {{package}}
|
||||||
|
|
||||||
# Runs the official toml-test compliance suite against the built adapter; toml-test must be on PATH (go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0); not standard because no canonical recipe covers a domain compliance suite.
|
# Runs the official toml-test compliance suite against the built adapter; toml-test must be on PATH (go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0); not standard because no canonical recipe covers a domain compliance suite.
|
||||||
toml-test: build
|
toml-test: build
|
||||||
toml-test bin/interpres-decode
|
toml-test test -decoder=bin/interpres-decode -toml=1.0
|
||||||
|
|
||||||
# Coverage report as an HTML map from the gate's profile; not standard because the gate needs only the numeric floor, and a browser artefact is exploration, not a gate.
|
# Coverage report as an HTML map from the gate's profile; not standard because the gate needs only the numeric floor, and a browser artefact is exploration, not a gate.
|
||||||
coverage-html: test
|
coverage-html: test
|
||||||
|
|||||||
@@ -74,15 +74,16 @@ func decodeFloat(tok string) (any, error) {
|
|||||||
sign, s := splitSign(tok)
|
sign, s := splitSign(tok)
|
||||||
|
|
||||||
mantissa, exp := s, ""
|
mantissa, exp := s, ""
|
||||||
|
hasExp := false
|
||||||
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||||
mantissa, exp = s[:i], s[i+1:]
|
mantissa, exp, hasExp = s[:i], s[i+1:], true
|
||||||
}
|
}
|
||||||
|
|
||||||
intPart, frac, hasDot := mantissa, "", false
|
intPart, frac, hasDot := mantissa, "", false
|
||||||
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
||||||
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
||||||
}
|
}
|
||||||
if !hasDot && exp == "" {
|
if !hasDot && !hasExp {
|
||||||
return nil, fmt.Errorf("invalid float %q", tok)
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,9 @@ func decodeFloat(tok string) (any, error) {
|
|||||||
}
|
}
|
||||||
build += "." + fp
|
build += "." + fp
|
||||||
}
|
}
|
||||||
if exp != "" {
|
// The ABNF requires at least one digit after the exponent marker, so a
|
||||||
|
// trailing e or E is an error even though strconv would accept it.
|
||||||
|
if hasExp {
|
||||||
esign, edigits := splitSign(exp)
|
esign, edigits := splitSign(exp)
|
||||||
ed, err := joinDigits(edigits, isDecDigit)
|
ed, err := joinDigits(edigits, isDecDigit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user