17 Commits
Author SHA1 Message Date
petrbalvin d2fc31d260 feat: support TOML 1.1
Test / test (push) Successful in 1m32s
Assisted-by: GLM 5.3 Flash
2026-09-17 22:06:26 +02:00
petrbalvin 18f1cd51e9 build: upgrade the compliance suite to toml-test v2.2.0
Test / test (push) Successful in 1m42s
Assisted-by: GLM 5.3 Flash
2026-09-17 21:30:41 +02:00
petrbalvin 3cd538fad6 fix: reject an out-of-range date-time offset
Assisted-by: GLM 5.3 Flash
2026-09-17 21:30:34 +02:00
petrbalvin e19a6f35f1 fix: reject a float exponent without digits
Assisted-by: GLM 5.3 Flash
2026-09-17 21:29:07 +02:00
petrbalvin 5a270d0879 feat: add the -validate mode to interpres-decode
Assisted-by: GLM 5.3 Flash
2026-09-17 21:23:29 +02:00
petrbalvin 3f41266710 feat: typed decode and encode errors with the key path
Assisted-by: GLM 5.3 Flash
2026-09-17 21:20:47 +02:00
petrbalvin 1e3198c8b6 docs: add the contributor terms with the CLA
Test / test (push) Successful in 1m58s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:27:13 +02:00
petrbalvin cdb42de561 ci: record the verified cancel-in-progress behaviour
Test / test (push) Successful in 1m24s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:16:49 +02:00
petrbalvin 0f6d81fe3e ci: second trigger of the cancel-in-progress experiment
Test / test (push) Successful in 1m32s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:12:02 +02:00
petrbalvin 274b8a488c ci: drive the cancel-in-progress experiment
Test / test (push) Canceled after 0s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:12:00 +02:00
petrbalvin b061c97a81 ci: experiment with cancel-in-progress on the test pipeline
Test / test (push) Successful in 1m33s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:08:37 +02:00
petrbalvin fc50e3c49a fix: decode untagged embedded structs and maps inline
Test / test (push) Successful in 1m26s
Assisted-by: GLM 5.3 Flash
2026-09-17 20:05:50 +02:00
petrbalvin 93c36cf376 feat: honour the omitzero and omitempty tag options on encode
Assisted-by: GLM 5.3 Flash
2026-09-17 19:57:23 +02:00
petrbalvin 58e7dfb1d0 test: add parse, marshal and decode benchmarks
Assisted-by: GLM 5.3 Flash
2026-09-17 19:54:27 +02:00
petrbalvin 510cfb5182 test: add the FuzzParse fuzz target
Assisted-by: GLM 5.3 Flash
2026-09-17 19:52:45 +02:00
petrbalvin 3ac0b1e301 fix: encode mixed arrays with inline table elements
Assisted-by: GLM 5.3 Flash
2026-09-17 19:49:50 +02:00
petrbalvin 2737a5ac87 build: bump the go directive to 1.27.1
Assisted-by: GLM 5.3 Flash
2026-09-17 19:41:22 +02:00
23 changed files with 1471 additions and 115 deletions
+12 -2
View File
@@ -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
@@ -91,10 +99,12 @@ jobs:
# output has to be captured into a variable.
env:
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
run: go build -o bin/interpres-decode ./cmd/interpres-decode
- name: Compliance suite
run: bin/toml-test bin/interpres-decode
# interpres implements TOML 1.0 and 1.1; the mode is pinned so an upstream
# default change cannot silently move the corpus.
run: bin/toml-test test -decoder=bin/interpres-decode -toml=1.1
+46 -1
View File
@@ -9,7 +9,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-
- TOML 1.1 support, on by default: date-times and times without seconds
(`07:32`, `1979-05-27T07:32`, normalised to full seconds on output), the
`\e` and `\xHH` escape sequences, and multi-line inline tables with
comments and trailing commas. The compliance suite runs in TOML 1.1 mode:
214 valid and 467 invalid cases, zero failures. Every TOML 1.0 document
parses exactly as before.
- `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
+21 -2
View File
@@ -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
+12 -11
View File
@@ -1,17 +1,18 @@
# interpres
A TOML 1.0 parser and encoder for Go, written with the standard library alone.
`interpres` (Latin for *interpreter*) gives zero-dependency programs an
`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
and 371 invalid cases, zero failures.
A TOML 1.0 and 1.1 parser and encoder for Go, written with the standard
library alone. `interpres` (Latin for *interpreter*) gives zero-dependency
programs an `encoding/json`-style API for reading and writing TOML, and passes
the entire official [toml-test](https://github.com/toml-lang/toml-test) suite:
214 valid and 467 invalid cases, zero failures.
## Features
- **Full TOML 1.0**: bare, quoted and dotted keys; tables and arrays of tables;
basic and literal strings including multiline; integers in the four radixes
with `_` separators; floats with exponents, `inf` and `nan`; booleans; the
four date-time kinds; arrays and inline tables.
- **Full TOML 1.0 and 1.1**: bare, quoted and dotted keys; tables and arrays of
tables; basic and literal strings including multiline, with the 1.1 `\e` and
`\xHH` escapes; integers in the four radixes with `_` separators; floats with
exponents, `inf` and `nan`; booleans; the four date-time kinds, seconds
optional as of 1.1; arrays and inline tables, multi-line as of 1.1.
- **Decoding and encoding**: `Parse` for an untyped tree, `Unmarshal` and
`Marshal` for structs and maps, mirroring `encoding/json`.
- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that
@@ -31,7 +32,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
@@ -160,7 +161,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [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
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"fmt"
"strings"
"testing"
"time"
)
// benchDoc is a representative configuration document: every scalar kind, an
// inline table, sub-tables, and an array of tables.
var benchDoc = []byte(`title = "benchmark configuration"
replicas = 3
ratio = 0.75
enabled = true
when = 2026-09-17T12:00:00Z
local = 2026-09-17T12:00:00
tags = ["alpha", "beta", "gamma"]
limits = { cpu = 4, memory = 1024 }
[server]
host = "localhost"
port = 8080
hosts = ["a.example", "b.example"]
[server.tls]
enabled = true
cert = "/etc/cert.pem"
[[items]]
name = "first"
weight = 10
flags = ["x", "y"]
[[items]]
name = "second"
weight = 20
flags = ["z"]
`)
// longDoc is generated once so the large-input benchmarks measure parsing,
// not document construction. Roughly 2000 array-of-tables entries.
var longDoc = func() []byte {
var b strings.Builder
b.WriteString("title = \"long\"\n")
for i := range 2000 {
fmt.Fprintf(&b, "[[entry]]\nname = \"entry-%d\"\nweight = %d\nwhen = 2026-09-17T12:00:00Z\nratio = 0.5\ntags = [\"a\", \"b\", \"c\"]\n\n", i, i)
}
return []byte(b.String())
}()
type benchTLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert"`
}
type benchServer struct {
Host string `toml:"host"`
Port int `toml:"port"`
Hosts []string `toml:"hosts"`
TLS benchTLS `toml:"tls"`
}
type benchItem struct {
Name string `toml:"name"`
Weight int `toml:"weight"`
Flags []string `toml:"flags"`
}
type benchConfig struct {
Title string `toml:"title"`
Replicas int `toml:"replicas"`
Ratio float64 `toml:"ratio"`
Enabled bool `toml:"enabled"`
When time.Time `toml:"when"`
Local LocalDateTime `toml:"local"`
Tags []string `toml:"tags"`
Limits map[string]any `toml:"limits"`
Server benchServer `toml:"server"`
Items []benchItem `toml:"items"`
}
func BenchmarkParse(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(benchDoc)))
for b.Loop() {
if _, err := Parse(benchDoc); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMarshal(b *testing.B) {
tree, err := Parse(benchDoc)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.SetBytes(int64(len(benchDoc)))
for b.Loop() {
if _, err := Marshal(tree); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkStrictDecode(b *testing.B) {
dec := NewDecoder().DisallowUnknownFields()
b.ReportAllocs()
for b.Loop() {
var cfg benchConfig
if err := dec.Decode(benchDoc, &cfg); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParseLong(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(longDoc)))
for b.Loop() {
if _, err := Parse(longDoc); err != nil {
b.Fatal(err)
}
}
}
+64 -9
View File
@@ -1,17 +1,23 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command interpres-decode reads a TOML document from standard input and writes
// the toml-test "tagged JSON" representation to standard output.
// Command interpres-decode is the toml-test harness adapter and a TOML
// 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
// that invalid documents are rejected. Run the official suite against it with:
// interpres-decode -validate config.toml
//
// Run the official suite against the adapter with:
//
// toml-test ./interpres-decode
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math"
@@ -23,13 +29,29 @@ import (
)
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
// on stdout, and returns the process exit code (0 success, 1 parse error,
// 2 I/O, encoding, or unsupported-value error).
func Run(stdin io.Reader, stdout, stderr io.Writer) int {
// Run runs the command line and returns the process exit code: 0 success,
// 1 an invalid document, 2 a usage, reading, encoding, or
// unsupported-value error.
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)
if err != nil {
fmt.Fprintln(stderr, "read stdin:", err)
@@ -54,6 +76,39 @@ func Run(stdin io.Reader, stdout, stderr io.Writer) int {
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
// 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
+77 -4
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
@@ -20,7 +21,7 @@ func TestRunParsesValidTOML(t *testing.T) {
port = 8080
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())
}
var got map[string]any
@@ -41,7 +42,7 @@ enabled = true
func TestRunRejectsInvalidInput(t *testing.T) {
var stdout, stderr bytes.Buffer
in := bytes.NewReader([]byte("v = \n"))
code := Run(in, &stdout, &stderr)
code := Run(nil, in, &stdout, &stderr)
if code != 1 {
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) {
var stdout, stderr bytes.Buffer
code := Run(errorReader{}, &stdout, &stderr)
code := Run(nil, errorReader{}, &stdout, &stderr)
if code != 2 {
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
w := errorWriter{}
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
code := Run(in, w, &stderr)
code := Run(nil, in, w, &stderr)
if code != 2 {
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)
}
}
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())
}
}
+25 -4
View File
@@ -6,6 +6,7 @@ package interpres
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@@ -58,26 +59,39 @@ var (
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05Z07:00",
// TOML 1.1 makes the seconds optional.
"2006-01-02T15:04Z07:00",
"2006-01-02 15:04Z07:00",
}
localDateTimeLayouts = []string{
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
}
localTimeLayouts = []string{
"15:04:05.999999999",
"15:04:05",
"15:04",
}
)
// dateTimeShape enforces the strict TOML grammar (two-digit components) that
// time.Parse would otherwise accept loosely (e.g. a single-digit hour).
// dateTimeShape enforces the strict TOML grammar (two-digit components,
// seconds optional since 1.1, a fraction only after seconds) that time.Parse
// would otherwise accept loosely (e.g. a single-digit hour).
var dateTimeShape = regexp.MustCompile(
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
`|^\d{2}:\d{2}:\d{2}(\.\d+)?$`,
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
`|^\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.
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
// LocalTime) and whether the token was a date-time at all.
@@ -91,6 +105,13 @@ func parseDateTime(tok string) (any, bool) {
if !dateTimeShape.MatchString(tok) {
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.
norm := strings.ToUpper(tok)
for _, layout := range offsetDateTimeLayouts {
+98 -24
View File
@@ -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 {
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 newDecodeError(key, err)
}
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
return newDecodeError(key, err)
}
}
continue
}
if err := d.assign(val, dst.Field(field)); err != nil {
return fmt.Errorf("%s: %w", key, err)
fv, err := fieldByIndex(dst, field.index)
if err != nil {
return newDecodeError(key, err)
}
if err := d.assign(val, fv); err != nil {
return newDecodeError(key, err)
}
}
return nil
@@ -132,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
for key, val := range tbl {
elem := reflect.New(elemType).Elem()
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)
}
@@ -146,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
for i, item := range items {
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)
@@ -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))
for i, item := range items {
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)
@@ -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())
for i := range t.NumField() {
f := t.Field(i)
if f.PkgPath != "" { // unexported
continue
}
name := f.Name
if tag, ok := f.Tag.Lookup("toml"); ok {
tag = strings.Split(tag, ",")[0]
if tag == "-" {
// 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
}
if tag != "" {
name = tag
path := append(append([]int{}, prefix...), i)
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
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"math"
"slices"
"strings"
"testing"
)
@@ -494,3 +495,162 @@ 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)
}
}
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)
}
}
+79 -13
View File
@@ -46,7 +46,7 @@ The cancellable variant of `Unmarshal`.
### `func Marshal(v any) ([]byte, error)`
Encodes a `struct` or `map[string]V` value, or a non-nil pointer to one, into a
TOML 1.0 document. The emission rules are in the [Encoding](#encoding) section
TOML document. The emission rules are in the [Encoding](#encoding) section
below. Equivalent to `MarshalContext(context.Background(), v)`.
```go
@@ -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
@@ -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`
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:
- `*SyntaxError` for a malformed document, with the 1-based line
- a plain error for everything else: a non-pointer decode target, a type
mismatch, an overflow, a marshal policy violation, a cancelled context
- `*DecodeError` for a decoding failure, with the key path in `Path`
- `*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
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
Decode and encode failures carry the key path or element index in the typed
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
path reads from a field instead of the message text.
## Notes
+3 -3
View File
@@ -6,9 +6,9 @@ source tree; nothing is aspirational.
## Overview
interpres is one public library package, one command, and one example. The
library implements the whole of TOML 1.0, decoding and encoding, in the
library implements the whole of TOML 1.0 and 1.1, decoding and encoding, in the
standard library alone; the command wraps the parser for the toml-test
compliance harness, against which it stands at 185 valid and 371 invalid cases
compliance harness, against which it stands at 214 valid and 467 invalid cases
with zero failures; the example demonstrates the API.
```mermaid
@@ -43,7 +43,7 @@ Inside the library package, one file owns one concern:
| File | Responsibility |
|---|---|
| `parser.go` | The recursive-descent parser. Produces the `map[string]any` tree and enforces the structural rules of TOML 1.0 (table redefinitions, dotted keys, arrays of tables). Reports a 1-based line on failure. |
| `parser.go` | The recursive-descent parser. Produces the `map[string]any` tree and enforces the structural rules of TOML 1.0 and 1.1 (table redefinitions, dotted keys, arrays of tables, multi-line inline tables). Reports a 1-based line on failure. |
| `number.go` | Strict numeric tokens: integers in the four radixes with `_` separators, and floats including `inf` and `nan`. Rejects leading zeros, misplaced underscores and malformed fractions. |
| `datetime.go` | The three local date-time wrapper types and `parseDateTime`, which classifies a token into the four date-time kinds under the strict TOML grammar. |
| `decode.go` | Maps the parsed tree onto Go values by reflection: struct fields, maps, slices, scalar conversion with overflow checks, `Unmarshaler` dispatch. |
+47 -13
View File
@@ -1,26 +1,45 @@
# Command line
The reference below is taken from the program itself. `interpres-decode` is the
toml-test harness adapter, not a general-purpose tool: it takes no flags and no
arguments, reads one TOML document from stdin, and writes the toml-test
tagged-JSON form to stdout.
The reference below is taken from the program itself. `interpres-decode` is
the toml-test harness adapter, and it also validates documents. Install it
with Go itself, no release assets involved:
```sh
go install sourcedock.dev/petrbalvin/interpres/cmd/interpres-decode@latest
```
## Synopsis
```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
run it straight from the module directory with `just run`.
Without `-validate` the program is the toml-test adapter: it takes no
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
| Code | Meaning |
|---|---|
| `0` | the document parsed, tagged JSON written to stdout |
| `1` | parse error, the document is malformed; the message goes to stderr |
| `2` | reading stdin failed, or a value has no tagged representation |
| `0` | adapter: the document parsed and the tagged JSON was written; validate: every document parsed |
| `1` | adapter: parse error; validate: at least one document is invalid |
| `2` | a usage error, a read failure, or a value with no tagged representation |
## Wire format
@@ -59,13 +78,28 @@ port = 9090
' | ./bin/interpres-decode
```
The output is the equivalent value tree as one JSON object. Run the official
compliance suite against the binary:
The output is the equivalent value tree as one JSON object. Validate the
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
just toml-test
```
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).
+2 -2
View File
@@ -4,10 +4,10 @@ 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`.
`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.
+116 -9
View File
@@ -6,7 +6,9 @@ package interpres
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"math"
"reflect"
"slices"
@@ -188,6 +190,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 +200,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 {
@@ -240,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
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)
}
@@ -313,7 +361,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 {
@@ -323,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
ev := followPtr(v.Index(i))
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}
switch ev.Kind() {
case reflect.Struct:
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 {
return err
@@ -339,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return err
}
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
}
@@ -347,7 +405,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 {
@@ -357,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
ev := followPtr(v.Index(i))
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 m, ok := ev.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
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 = followPtr(ev)
@@ -371,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
val, err := normaliseValue(ev)
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
}
@@ -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
// 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 +472,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 +768,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 +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 {
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
return writeLiteralMultilineString(&e.buf, s)
+222
View File
@@ -504,6 +504,79 @@ func TestMarshalNestedArrays(t *testing.T) {
}
}
func TestMarshalMixedArrayWithInlineTable(t *testing.T) {
// Parse accepts a mixed array (TOML allows any value kinds in one array),
// so Marshal of the parsed tree must re-emit it. The table element has no
// header form inside a value array and renders inline.
tree, err := Parse([]byte("arr = [1, {a = 2}, \"x\"]\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "arr = [1, {a = 2}, \"x\"]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
re, err := Parse(out)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
if !reflect.DeepEqual(tree, re) {
t.Fatalf("round-trip changed the tree:\nwas: %#v\nnow: %#v", tree, re)
}
}
func TestMarshalNestedInlineTables(t *testing.T) {
tree := map[string]any{
"mix": []any{
int64(1),
map[string]any{"deep": map[string]any{"n": int64(0)}, "list": []any{"a", true}},
map[string]any{},
},
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "mix = [1, {deep = {n = 0}, list = [\"a\", true]}, {}]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalInlineTableWithDatetime(t *testing.T) {
when := time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)
tree := map[string]any{
"mix": []any{when, map[string]any{"t": LocalDateTime{when}}},
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "mix = [1979-05-27T07:32:00Z, {t = 1979-05-27T07:32:00}]\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalArrayOfTablesStaysHeaderForm(t *testing.T) {
tree, err := Parse([]byte("[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"
if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
// 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,
@@ -1008,3 +1160,73 @@ type Custom struct {
}
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]")
}
}
+123
View File
@@ -0,0 +1,123 @@
// 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",
// TOML 1.1 forms.
"t = 13:37\ndt = 1979-05-27T07:32\nodt = 1979-05-27 07:32Z\n",
"esc = \"\\e\\x41\\x7f\\x00\"\n",
"m = {\n\ta = 1,\n\tb = [1, 2,],\n\tc = { d = 2 },\n} # close\n",
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
tree, err := Parse(data)
if err != nil {
return
}
out, err := Marshal(tree)
if err != nil {
t.Fatalf("marshal of a parsed tree failed: %v\ntree: %#v", err, tree)
}
re, err := Parse(out)
if err != nil {
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
}
if !tomlEqual(tree, re) {
t.Fatalf("round-trip changed the tree\ninput: %q\ndoc:\n%s\nwas: %#v\nnow: %#v", data, out, tree, re)
}
})
}
// tomlEqual reports whether two parsed trees are equal as TOML values. It
// differs from reflect.DeepEqual where DeepEqual is wrong for this domain:
// NaN compares equal to itself, date-times compare by their canonical TOML
// rendering so two parses of one document stay equal, and the local variants
// compare through their String form, which fully determines the value.
func tomlEqual(a, b any) bool {
switch av := a.(type) {
case nil:
return b == nil
case float64:
bv, ok := b.(float64)
return ok && (av == bv || (math.IsNaN(av) && math.IsNaN(bv)))
case time.Time:
bv, ok := b.(time.Time)
return ok && av.Format(time.RFC3339Nano) == bv.Format(time.RFC3339Nano)
case LocalDateTime:
bv, ok := b.(LocalDateTime)
return ok && av.String() == bv.String()
case LocalDate:
bv, ok := b.(LocalDate)
return ok && av.String() == bv.String()
case LocalTime:
bv, ok := b.(LocalTime)
return ok && av.String() == bv.String()
case []any:
bv, ok := b.([]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case []map[string]any:
bv, ok := b.([]map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !tomlEqual(av[i], bv[i]) {
return false
}
}
return true
case map[string]any:
bv, ok := b.(map[string]any)
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
other, ok := bv[k]
if !ok || !tomlEqual(v, other) {
return false
}
}
return true
default:
return reflect.DeepEqual(a, b)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
module sourcedock.dev/petrbalvin/interpres
go 1.27.0
go 1.27.1
+63 -4
View File
@@ -21,6 +21,7 @@ package interpres
import (
"context"
"errors"
"fmt"
"unicode/utf8"
)
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
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.
//
// Values are mapped to Go types as follows: strings to string, integers to
@@ -143,20 +196,26 @@ type Unmarshaler interface {
UnmarshalTOML(data any) error
}
// Marshal returns the TOML 1.0 encoding of v.
// Marshal returns the TOML encoding of v. The output stays within TOML 1.0,
// so it is valid under both TOML 1.0 and 1.1.
//
// Marshal traverses v using reflection and applies the following rules:
//
// - 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).
+133 -1
View File
@@ -370,6 +370,7 @@ func TestRejectsInvalidNumbers(t *testing.T) {
"01", "-01", "00",
"1__0", "_1", "1_", "0x_1", "1_.0",
"1.", ".5", "1.2.3", "1.e2",
"1e", "1e+", "1e-", "0.0E", "0.0e", "1.5e+",
"0x", "0o", "0b", "0b2", "0o8", "0xG",
"+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) {
cases := map[string]any{
"0": int64(0),
@@ -474,7 +503,8 @@ func TestRejectsSpecInvalid(t *testing.T) {
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
"table over array": "[[t]]\n[t]\n",
"truncated datetime": "a = 2026-01-02T\n",
"datetime no seconds": "a = 2026-01-02T07:32\n",
// "datetime no seconds" moved to the acceptance tests: TOML 1.1
// makes the seconds optional.
}
for name, doc := range cases {
if _, err := Parse([]byte(doc)); err == nil {
@@ -511,3 +541,105 @@ host = "h2"
t.Errorf("forms[1].smtp.host = %v", h)
}
}
// --- TOML 1.1 --------------------------------------------------------------
func TestParseAcceptsNoSecondsDatetimes(t *testing.T) {
tree, err := Parse([]byte(`t = 13:37
dt = 1979-05-27T07:32
odt1 = 1979-05-27 07:32Z
odt2 = 1979-05-27 07:32-07:00
`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := tree["t"].(LocalTime).String(); got != "13:37:00" {
t.Errorf("t = %q, want %q", got, "13:37:00")
}
if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32:00" {
t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32:00")
}
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
t.Errorf("odt1 = %q", got)
}
if got := tree["odt2"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00-07:00" {
t.Errorf("odt2 = %q", got)
}
// The fraction still requires the seconds it belongs to.
if _, err := Parse([]byte("a = 07:32.5\n")); err == nil {
t.Error("07:32.5: expected an error, got none")
}
}
func TestParseAcceptsEscapeAndHexEscapes(t *testing.T) {
tree, err := Parse([]byte(`esc = "\e"
hex = "\x20\x7f\xf8"
nul = "\x00"
multi = """\x68\x65"""
lit = '\x20'
`))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := tree["esc"].(string); got != "\x1b" {
t.Errorf("esc = %q, want the escape character", got)
}
if got := tree["hex"].(string); got != " \x7f\u00f8" {
t.Errorf("hex = %q", got)
}
if got := tree["nul"].(string); got != "\x00" {
t.Errorf("nul = %q", got)
}
if got := tree["multi"].(string); got != "he" {
t.Errorf("multi = %q", got)
}
// A literal string carries the sequence verbatim.
if got := tree["lit"].(string); got != `\x20` {
t.Errorf("lit = %q, want the verbatim sequence", got)
}
// Two digits exactly; a short or non-hex escape is an error.
for _, doc := range []string{`a = "\x4"`, `a = "\x"`, `a = "\xgg"`} {
if _, err := Parse([]byte(doc)); err == nil {
t.Errorf("%s: expected an error, got none", doc)
}
}
}
func TestParseAcceptsMultilineInlineTables(t *testing.T) {
tree, err := Parse([]byte("tbl = {\n\thello = \"world\",\n\tarr = [1,\n\t\t2,\n\t],\n\tsub = {\n\t\tk = 1,\n\t},\n\tbare = 2}\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
tbl := tree["tbl"].(map[string]any)
if tbl["hello"] != "world" || tbl["bare"] != int64(2) {
t.Fatalf("tbl = %#v", tbl)
}
if arr := tbl["arr"].([]any); len(arr) != 2 {
t.Errorf("arr = %#v", tbl["arr"])
}
if sub := tbl["sub"].(map[string]any); sub["k"] != int64(1) {
t.Errorf("sub = %#v", tbl["sub"])
}
// Comments inside the table, and a trailing comma at both depths.
tree, err = Parse([]byte("m = { # one\n\t# two\n\ta = 1, # three\n\t# four\n}\n"))
if err != nil {
t.Fatalf("parse with comments: %v", err)
}
if m := tree["m"].(map[string]any); m["a"] != int64(1) {
t.Errorf("m = %#v", m)
}
// The old single-line shapes keep working, with and without the comma.
if _, err := Parse([]byte("a = { b = 1, c = 2 }\n")); err != nil {
t.Errorf("single line: %v", err)
}
// Still rejected: two commas, a missing value, and an unclosed table.
for name, doc := range map[string]string{
"double comma": "a = { b = 1,, c = 2 }\n",
"missing value": "a = {\n\tb =\n}\n",
"unterminated": "a = { b = 1,\n",
} {
if _, err := Parse([]byte(doc)); err == nil {
t.Errorf("%s: expected an error, got none", name)
}
}
}
+2 -2
View File
@@ -92,9 +92,9 @@ run:
dev:
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 bin/interpres-decode
toml-test test -decoder=bin/interpres-decode -toml=1.1
# 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
+6 -3
View File
@@ -74,15 +74,16 @@ func decodeFloat(tok string) (any, error) {
sign, s := splitSign(tok)
mantissa, exp := s, ""
hasExp := false
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
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
}
if !hasDot && exp == "" {
if !hasDot && !hasExp {
return nil, fmt.Errorf("invalid float %q", tok)
}
@@ -102,7 +103,9 @@ func decodeFloat(tok string) (any, error) {
}
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)
ed, err := joinDigits(edigits, isDecDigit)
if err != nil {
+30 -7
View File
@@ -598,10 +598,16 @@ func (p *parser) readEscape() (rune, error) {
return '\f', nil
case 'r':
return '\r', nil
case 'e':
// TOML 1.1: the escape character.
return '\x1b', nil
case '"':
return '"', nil
case '\\':
return '\\', nil
case 'x':
// TOML 1.1: two hex digits, code points 0x00 through 0xFF.
return p.readUnicode(2)
case 'u':
return p.readUnicode(4)
case 'U':
@@ -633,7 +639,7 @@ func (p *parser) parseArray() (any, error) {
p.next() // '['
arr := []any{}
for {
if err := p.skipArraySpace(); err != nil {
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if p.eof() {
@@ -648,7 +654,7 @@ func (p *parser) parseArray() (any, error) {
return nil, err
}
arr = append(arr, v)
if err := p.skipArraySpace(); err != nil {
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if p.eof() {
@@ -670,13 +676,20 @@ func (p *parser) parseInlineTable() (any, error) {
p.next() // '{'
tbl := map[string]any{}
assigned := map[string]bool{}
p.skipInline()
// TOML 1.1 lets an inline table span lines: interior whitespace includes
// newlines and comments, and a trailing comma is allowed before the
// closing brace.
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if !p.eof() && p.peek() == '}' {
p.next()
return tbl, nil
}
for {
p.skipInline()
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
key, err := p.parseKeyPath()
if err != nil {
return nil, err
@@ -720,13 +733,22 @@ func (p *parser) parseInlineTable() (any, error) {
dest[leaf] = val
assigned[pathKey(path)] = true
p.skipInline()
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if p.eof() {
return nil, p.errf("unterminated inline table")
}
switch p.peek() {
case ',':
p.next()
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if !p.eof() && p.peek() == '}' {
p.next()
return tbl, nil
}
case '}':
p.next()
return tbl, nil
@@ -796,8 +818,9 @@ func (p *parser) skipInline() {
}
}
// skipArraySpace consumes whitespace, newlines, and comments inside arrays.
func (p *parser) skipArraySpace() error {
// skipNestedSpace consumes whitespace, newlines, and comments inside a value
// container (an array, or an inline table under TOML 1.1).
func (p *parser) skipNestedSpace() error {
for !p.eof() {
switch p.peek() {
case ' ', '\t':