5 Commits
Author SHA1 Message Date
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
17 changed files with 482 additions and 55 deletions
+3 -2
View File
@@ -99,10 +99,11 @@ 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; v2 tests 1.1 by default, so the mode is pinned.
run: bin/toml-test test -decoder=bin/interpres-decode -toml=1.0
+22
View File
@@ -9,14 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
+3 -3
View File
@@ -3,8 +3,8 @@
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.
official [toml-test](https://github.com/toml-lang/toml-test) suite: 205 valid
and 474 invalid cases, zero failures.
## Features
@@ -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/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
+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())
}
}
+14
View File
@@ -6,6 +6,7 @@ package interpres
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@@ -78,6 +79,12 @@ var dateTimeShape = regexp.MustCompile(
`|^\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 +98,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 {
+7 -7
View File
@@ -117,20 +117,20 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
// of the encoder inlining that map's entries.
mv, err := fieldByIndex(dst, schema.embedMaps[0])
if err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
}
continue
}
fv, err := fieldByIndex(dst, field.index)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
if err := d.assign(val, fv); err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
}
return nil
@@ -147,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)
}
@@ -161,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)
@@ -175,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)
+48
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"math"
"slices"
"strings"
"testing"
)
@@ -606,3 +607,50 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
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)
}
}
+28 -4
View File
@@ -404,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
@@ -460,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
+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).
+1 -1
View File
@@ -7,7 +7,7 @@ How to work on interpres.
- 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.
+8 -7
View File
@@ -6,6 +6,7 @@ package interpres
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"math"
@@ -287,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)
}
@@ -380,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
@@ -396,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
}
@@ -415,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)
@@ -429,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
}
+70
View File
@@ -1160,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]")
}
}
+53
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
+29
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),
+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.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-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 {