feat: support TOML 1.1
Test / test (push) Successful in 1m32s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-17 22:06:26 +02:00
parent 18f1cd51e9
commit d2fc31d260
11 changed files with 175 additions and 29 deletions
+3 -2
View File
@@ -105,5 +105,6 @@ jobs:
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
# interpres implements TOML 1.0; v2 tests 1.1 by default, so the mode is pinned. # interpres implements TOML 1.0 and 1.1; the mode is pinned so an upstream
run: bin/toml-test test -decoder=bin/interpres-decode -toml=1.0 # default change cannot silently move the corpus.
run: bin/toml-test test -decoder=bin/interpres-decode -toml=1.1
+6
View File
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - `interpres-decode -validate [file ...]`: a validate mode beside the
toml-test adapter. It parses each named file, or stdin when none are named, 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 prints one line per invalid document to stderr, and exits 0 when all are
+10 -9
View File
@@ -1,17 +1,18 @@
# interpres # interpres
A TOML 1.0 parser and encoder for Go, written with the standard library alone. A TOML 1.0 and 1.1 parser and encoder for Go, written with the standard
`interpres` (Latin for *interpreter*) gives zero-dependency programs an library alone. `interpres` (Latin for *interpreter*) gives zero-dependency
`encoding/json`-style API for reading and writing TOML, and passes the entire programs an `encoding/json`-style API for reading and writing TOML, and passes
official [toml-test](https://github.com/toml-lang/toml-test) suite: 205 valid the entire official [toml-test](https://github.com/toml-lang/toml-test) suite:
and 474 invalid cases, zero failures. 214 valid and 467 invalid cases, zero failures.
## Features ## Features
- **Full TOML 1.0**: bare, quoted and dotted keys; tables and arrays of tables; - **Full TOML 1.0 and 1.1**: bare, quoted and dotted keys; tables and arrays of
basic and literal strings including multiline; integers in the four radixes tables; basic and literal strings including multiline, with the 1.1 `\e` and
with `_` separators; floats with exponents, `inf` and `nan`; booleans; the `\xHH` escapes; integers in the four radixes with `_` separators; floats with
four date-time kinds; arrays and inline tables. 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 - **Decoding and encoding**: `Parse` for an untyped tree, `Unmarshal` and
`Marshal` for structs and maps, mirroring `encoding/json`. `Marshal` for structs and maps, mirroring `encoding/json`.
- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that - **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that
+11 -4
View File
@@ -59,24 +59,31 @@ var (
"2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05.999999999Z07:00", "2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05Z07: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{ localDateTimeLayouts = []string{
"2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05", "2006-01-02T15:04:05",
"2006-01-02 15:04:05.999999999", "2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05", "2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
} }
localTimeLayouts = []string{ localTimeLayouts = []string{
"15:04:05.999999999", "15:04:05.999999999",
"15:04:05", "15:04:05",
"15:04",
} }
) )
// dateTimeShape enforces the strict TOML grammar (two-digit components) that // dateTimeShape enforces the strict TOML grammar (two-digit components,
// time.Parse would otherwise accept loosely (e.g. a single-digit hour). // 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( var dateTimeShape = regexp.MustCompile(
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?$` + `^\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{2}:\d{2}(:\d{2}(\.\d+)?)?$`,
) )
// offsetBounds extracts the numeric offset of a date-time. The ABNF bounds it // offsetBounds extracts the numeric offset of a date-time. The ABNF bounds it
+1 -1
View File
@@ -46,7 +46,7 @@ The cancellable variant of `Unmarshal`.
### `func Marshal(v any) ([]byte, error)` ### `func Marshal(v any) ([]byte, error)`
Encodes a `struct` or `map[string]V` value, or a non-nil pointer to one, into a 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)`. below. Equivalent to `MarshalContext(context.Background(), v)`.
```go ```go
+3 -3
View File
@@ -6,9 +6,9 @@ source tree; nothing is aspirational.
## Overview ## Overview
interpres is one public library package, one command, and one example. The 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 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. with zero failures; the example demonstrates the API.
```mermaid ```mermaid
@@ -43,7 +43,7 @@ Inside the library package, one file owns one concern:
| File | Responsibility | | 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. | | `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. | | `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. | | `decode.go` | Maps the parsed tree onto Go values by reflection: struct fields, maps, slices, scalar conversion with overflow checks, `Unmarshaler` dispatch. |
+4
View File
@@ -32,6 +32,10 @@ func FuzzParse(f *testing.F) {
"x = \"unterminated\n", "x = \"unterminated\n",
"[a]\n[a]\n", "[a]\n[a]\n",
"n = 0x1_0000_0000_0000_0000\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 { for _, s := range seeds {
f.Add([]byte(s)) f.Add([]byte(s))
+2 -1
View File
@@ -196,7 +196,8 @@ type Unmarshaler interface {
UnmarshalTOML(data any) error 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: // Marshal traverses v using reflection and applies the following rules:
// //
+104 -1
View File
@@ -503,7 +503,8 @@ func TestRejectsSpecInvalid(t *testing.T) {
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n", "dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
"table over array": "[[t]]\n[t]\n", "table over array": "[[t]]\n[t]\n",
"truncated datetime": "a = 2026-01-02T\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 { for name, doc := range cases {
if _, err := Parse([]byte(doc)); err == nil { if _, err := Parse([]byte(doc)); err == nil {
@@ -540,3 +541,105 @@ host = "h2"
t.Errorf("forms[1].smtp.host = %v", h) 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)
}
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ dev:
# 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. # 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 test -decoder=bin/interpres-decode -toml=1.0 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 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
+30 -7
View File
@@ -598,10 +598,16 @@ func (p *parser) readEscape() (rune, error) {
return '\f', nil return '\f', nil
case 'r': case 'r':
return '\r', nil return '\r', nil
case 'e':
// TOML 1.1: the escape character.
return '\x1b', nil
case '"': case '"':
return '"', nil return '"', nil
case '\\': case '\\':
return '\\', nil return '\\', nil
case 'x':
// TOML 1.1: two hex digits, code points 0x00 through 0xFF.
return p.readUnicode(2)
case 'u': case 'u':
return p.readUnicode(4) return p.readUnicode(4)
case 'U': case 'U':
@@ -633,7 +639,7 @@ func (p *parser) parseArray() (any, error) {
p.next() // '[' p.next() // '['
arr := []any{} arr := []any{}
for { for {
if err := p.skipArraySpace(); err != nil { if err := p.skipNestedSpace(); err != nil {
return nil, err return nil, err
} }
if p.eof() { if p.eof() {
@@ -648,7 +654,7 @@ func (p *parser) parseArray() (any, error) {
return nil, err return nil, err
} }
arr = append(arr, v) arr = append(arr, v)
if err := p.skipArraySpace(); err != nil { if err := p.skipNestedSpace(); err != nil {
return nil, err return nil, err
} }
if p.eof() { if p.eof() {
@@ -670,13 +676,20 @@ func (p *parser) parseInlineTable() (any, error) {
p.next() // '{' p.next() // '{'
tbl := map[string]any{} tbl := map[string]any{}
assigned := map[string]bool{} 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() == '}' { if !p.eof() && p.peek() == '}' {
p.next() p.next()
return tbl, nil return tbl, nil
} }
for { for {
p.skipInline() if err := p.skipNestedSpace(); err != nil {
return nil, err
}
key, err := p.parseKeyPath() key, err := p.parseKeyPath()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -720,13 +733,22 @@ func (p *parser) parseInlineTable() (any, error) {
dest[leaf] = val dest[leaf] = val
assigned[pathKey(path)] = true assigned[pathKey(path)] = true
p.skipInline() if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if p.eof() { if p.eof() {
return nil, p.errf("unterminated inline table") return nil, p.errf("unterminated inline table")
} }
switch p.peek() { switch p.peek() {
case ',': case ',':
p.next() p.next()
if err := p.skipNestedSpace(); err != nil {
return nil, err
}
if !p.eof() && p.peek() == '}' {
p.next()
return tbl, nil
}
case '}': case '}':
p.next() p.next()
return tbl, nil return tbl, nil
@@ -796,8 +818,9 @@ func (p *parser) skipInline() {
} }
} }
// skipArraySpace consumes whitespace, newlines, and comments inside arrays. // skipNestedSpace consumes whitespace, newlines, and comments inside a value
func (p *parser) skipArraySpace() error { // container (an array, or an inline table under TOML 1.1).
func (p *parser) skipNestedSpace() error {
for !p.eof() { for !p.eof() {
switch p.peek() { switch p.peek() {
case ' ', '\t': case ' ', '\t':