feat: add OffsetDateTime, nesting limits and uniform Marshaler dispatch
Test / test (push) Successful in 2m18s

Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
2026-09-19 19:36:24 +02:00
parent b695b69768
commit a6e3e3fe31
12 changed files with 631 additions and 117 deletions
+19
View File
@@ -28,6 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
at most `threshold` bytes is written as an inline table instead of a header
section, which shortens a document of small tables. An array of tables keeps
its header form, because its inline form would re-parse as a value array.
- `OffsetDateTime`, the Go type of the offset date-time kind, so that all four
TOML date-time kinds have one of their own. `Parse` and `Unmarshal` hand it
back where they produced a bare `time.Time` before, and `Marshal` accepts it.
Unmarshalling into a struct field of type `time.Time` keeps working, because
the plain type takes an offset date-time as it always did; code that asserts
the tree's type, and `UnmarshalTOML` implementations that expect a
`time.Time`, need the new type.
- `Decoder.MaxDepth(depth)` and `Decoder.MaxInputSize(size)` bound the parse a
`Decode` performs, and every parse carries a nesting limit in any case
(10000 levels, which no hand-written document approaches): a document that
nests arrays or inline tables deeper used to run the stack out and is now
rejected with a `SyntaxError` naming the limit.
### Changed
@@ -40,6 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An inline table that would pass the hundredth column is written across lines
with a trailing comma and one tab of indentation per nesting level, the shape
TOML 1.1 allows an inline table to take.
- `MarshalTOML` reaches every array element and every field, whatever the Go
kind, and its result is normalised like any other value: an element rendering
itself as a table keeps the `[[header]]` form, one rendering itself as a
scalar turns the array into a value array, and the method runs once per
element. It is found on the addressable pointer as well, so a
pointer-receiver `MarshalTOML` is called for a field or an element, exactly
as `MarshalText` is.
- TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance
suite runs the 1.1 corpus alone, and the promise that every 1.0 document
parses exactly as before is withdrawn. Nothing that parses today stops
+7 -2
View File
@@ -285,8 +285,11 @@ func typeMatches(typ string, v any) bool {
_, ok := v.(bool)
return ok
case "datetime":
_, ok := v.(time.Time)
return ok
switch v.(type) {
case time.Time, interpres.OffsetDateTime:
return true
}
return false
case "datetime-local":
_, ok := v.(interpres.LocalDateTime)
return ok
@@ -346,6 +349,8 @@ func tag(v any) (any, error) {
return tagged("float", formatFloat(x)), nil
case time.Time:
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
case interpres.OffsetDateTime:
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
case interpres.LocalDateTime:
return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil
case interpres.LocalDate:
+1 -1
View File
@@ -373,7 +373,7 @@ func TestRunEncoderRejectsBadInput(t *testing.T) {
{"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"},
{"untagged-scalar", `{"x": 1}`, "unsupported JSON value"},
{"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"},
{"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as time.Time"},
{"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as interpres.OffsetDateTime"},
{"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"},
{"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"},
}
+16 -4
View File
@@ -11,9 +11,15 @@ import (
"time"
)
// TOML distinguishes four date-time kinds. interpres decodes an offset
// date-time to a plain time.Time (it carries a zone), and uses the wrapper
// types below for the local variants so callers can tell them apart.
// TOML distinguishes four date-time kinds, and each has its own Go type:
// OffsetDateTime for the offset kind, and the local wrappers below for the
// three that carry no offset. A plain time.Time is accepted wherever an
// offset date-time is, on both the encoding and the decoding side, so a
// timestamp field does not have to name the wrapper.
// OffsetDateTime is a TOML offset date-time, e.g. 1979-05-27T07:32:00-07:00.
// The embedded time.Time is the instant, with the offset the document wrote.
type OffsetDateTime struct{ time.Time }
// LocalDateTime is a TOML local date-time with no offset, e.g.
// 1979-05-27T07:32:00. The embedded time.Time is in UTC.
@@ -27,6 +33,12 @@ type LocalDate struct{ time.Time }
// The embedded time.Time uses the zero date.
type LocalTime struct{ time.Time }
// String returns the TOML-canonical rendering of the offset date-time, e.g.
// "1979-05-27T07:32Z" or "1979-05-27T07:32:00-07:00". The seconds appear only
// when the value carries them, a fractional second drops its trailing zeros,
// and an offset of zero is written "Z".
func (odt OffsetDateTime) String() string { return offsetString(odt.Time) }
// String returns the TOML-canonical rendering of the local date-time, e.g.
// "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a
// fractional second. TOML 1.1 makes the seconds optional, so they appear only
@@ -129,7 +141,7 @@ func parseDateTime(tok string) (any, bool) {
norm := strings.ToUpper(tok)
for _, layout := range offsetDateTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil {
return t, true
return OffsetDateTime{t}, true
}
}
for _, layout := range localDateTimeLayouts {
+33 -5
View File
@@ -95,12 +95,10 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
return setInt(dst, v)
case float64:
return setFloat(dst, v)
case OffsetDateTime:
return setOffsetDateTime(v, dst)
case time.Time:
if dst.Type() != timeType {
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
dst.Set(reflect.ValueOf(v))
return nil
return setDateTime(v, dst)
default:
rv := reflect.ValueOf(data)
if rv.IsValid() && dst.Type() == rv.Type() {
@@ -235,6 +233,36 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
// --- low-level setters -----------------------------------------------------
// setOffsetDateTime stores an offset date-time: in a wrapper destination as it
// is, and in a plain time.Time, which takes the instant with the offset the
// document wrote, so a timestamp field does not have to name the wrapper.
func setOffsetDateTime(v OffsetDateTime, dst reflect.Value) error {
switch dst.Type() {
case offsetDateTimeType:
dst.Set(reflect.ValueOf(v))
case timeType:
dst.Set(reflect.ValueOf(v.Time))
default:
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
return nil
}
// setDateTime stores a time.Time that reached the tree directly, which is the
// shape a tree built by hand carries. Dates the parser produced arrive as
// OffsetDateTime instead.
func setDateTime(v time.Time, dst reflect.Value) error {
switch dst.Type() {
case timeType:
dst.Set(reflect.ValueOf(v))
case offsetDateTimeType:
dst.Set(reflect.ValueOf(OffsetDateTime{v}))
default:
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
return nil
}
func setBasic(dst, val reflect.Value, kind string) error {
if dst.Kind() != val.Kind() {
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
+127
View File
@@ -951,3 +951,130 @@ func TestQuotedStringNeverBecomesDateTime(t *testing.T) {
t.Fatal("expected a quoted string to be rejected for LocalDate")
}
}
func TestDecoderMaxDepth(t *testing.T) {
deep := func(n int) []byte {
return []byte("v = " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n")
}
var cfg struct {
V any `toml:"v"`
}
if err := NewDecoder().MaxDepth(4).Decode(deep(4), &cfg); err != nil {
t.Fatalf("at the limit: %v", err)
}
err := NewDecoder().MaxDepth(4).Decode(deep(5), &cfg)
if err == nil {
t.Fatal("expected a nesting error")
}
if !strings.Contains(err.Error(), "limit of 4") {
t.Errorf("err = %v, want it to name the limit", err)
}
}
func TestDecoderMaxInputSize(t *testing.T) {
doc := []byte("v = \"ab\"\n")
var cfg struct {
V string `toml:"v"`
}
if err := NewDecoder().MaxInputSize(len(doc)).Decode(doc, &cfg); err != nil {
t.Fatalf("at the limit: %v", err)
}
err := NewDecoder().MaxInputSize(len(doc)-1).Decode(doc, &cfg)
if err == nil {
t.Fatal("expected a size error")
}
if !strings.Contains(err.Error(), "over the limit of 8") {
t.Errorf("err = %v, want it to name the limit", err)
}
// Parse carries the nesting default and no size limit.
if _, err := Parse(doc); err != nil {
t.Fatalf("parse: %v", err)
}
}
// --- OffsetDateTime --------------------------------------------------------
func TestOffsetDateTimeIsTheParsedType(t *testing.T) {
// A document's offset date-time arrives as the wrapper, and a plain
// time.Time destination still takes it, so a timestamp field needs no
// change to keep working.
in := []byte("stamp = 2026-06-26T10:00:00-07:00\n")
want := time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600))
var plain struct {
Stamp time.Time `toml:"stamp"`
}
if err := Unmarshal(in, &plain); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !plain.Stamp.Equal(want) {
t.Errorf("time.Time destination = %v, want %v", plain.Stamp, want)
}
var wrapped struct {
Stamp OffsetDateTime `toml:"stamp"`
}
if err := Unmarshal(in, &wrapped); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !wrapped.Stamp.Time.Equal(want) {
t.Errorf("OffsetDateTime destination = %v, want %v", wrapped.Stamp.Time, want)
}
if got := wrapped.Stamp.String(); got != "2026-06-26T10:00-07:00" {
t.Errorf("String() = %q, want 2026-06-26T10:00-07:00", got)
}
}
func TestOffsetDateTimeFromHandBuiltTree(t *testing.T) {
// A tree built by hand may carry a plain time.Time, which is the other
// source of the offset kind; both date-time destinations take it.
tree := map[string]any{"stamp": time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)}
want := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
var plain struct {
Stamp time.Time `toml:"stamp"`
}
if err := newDecoder().decode(tree, &plain); err != nil {
t.Fatalf("decode: %v", err)
}
if !plain.Stamp.Equal(want) {
t.Errorf("time.Time destination = %v, want %v", plain.Stamp, want)
}
var wrapped struct {
Stamp OffsetDateTime `toml:"stamp"`
}
if err := newDecoder().decode(tree, &wrapped); err != nil {
t.Fatalf("decode: %v", err)
}
if !wrapped.Stamp.Time.Equal(want) {
t.Errorf("OffsetDateTime destination = %v, want %v", wrapped.Stamp.Time, want)
}
}
// dateKindReceiver records the Go type UnmarshalTOML was handed.
type dateKindReceiver struct{ Kind string }
func (r *dateKindReceiver) UnmarshalTOML(data any) error {
r.Kind = fmt.Sprintf("%T", data)
return nil
}
func TestUnmarshalerReceivesOffsetDateTime(t *testing.T) {
// The interface sees the wrapper, which names the date-time kind on its
// own; the local kinds keep their own wrappers.
var cfg struct {
O dateKindReceiver `toml:"o"`
L dateKindReceiver `toml:"l"`
}
in := []byte("o = 2026-06-26T10:00:00Z\nl = 2026-06-26T10:00:00\n")
if err := Unmarshal(in, &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.O.Kind != "interpres.OffsetDateTime" {
t.Errorf("offset kind = %q, want interpres.OffsetDateTime", cfg.O.Kind)
}
if cfg.L.Kind != "interpres.LocalDateTime" {
t.Errorf("local kind = %q, want interpres.LocalDateTime", cfg.L.Kind)
}
}
+54 -26
View File
@@ -74,7 +74,7 @@ and every 64 fields during the reflection walk.
| integer | `int64` |
| float | `float64` |
| boolean | `bool` |
| offset date-time | `time.Time` |
| offset date-time | `OffsetDateTime` |
| local date-time | `LocalDateTime` |
| local date | `LocalDate` |
| local time | `LocalTime` |
@@ -133,22 +133,24 @@ The decoder converts to the destination type with explicit overflow checks:
| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | the value must be non-negative and must not overflow the destination's own width, `uint` on a 32-bit platform included; `uint64` accepts any non-negative `int64` |
| `float32`, `float64` | copied verbatim, except that a finite value beyond the `float32` range is an overflow error rather than a silent infinity; an integer also coerces, so TOML `5` decodes into `5.0` |
| `bool`, `string` | exact kind match only, no coercion across kinds |
| `time.Time` | offset date-times only; no implicit conversion to or from the local variants |
| `time.Time`, `OffsetDateTime` | offset date-times only; no implicit conversion to or from the local variants |
A conversion that the rules do not allow produces an error wrapped with the
offending key or index, for example `p: interpres: integer 300 overflows uint8`.
### Date-time values
Offset date-times decode into `time.Time` and keep their offset. The local
variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the
zero date for a local time). Every kind may omit the seconds as of TOML 1.1
(`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the
encoder writes the seconds only when the value carries them, so a document
written without seconds comes back without them. There is no implicit
Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the
instant with the offset the document wrote; a destination of the plain
`time.Time` takes the same value, so a timestamp field does not have to name the
wrapper. The local variants decode into `LocalDateTime`, `LocalDate` and
`LocalTime`, whose embedded `time.Time` is normalised to UTC (midnight UTC for a
local date, the zero date for a local time). Every kind may omit the seconds as
of TOML 1.1 (`07:32`, `1979-05-27T07:32`); such a value carries a zero second,
and the encoder writes the seconds only when the value carries them, so a
document written without seconds comes back without them. There is no implicit
conversion between the offset and local kinds; assigning one to the other is an
error. The four types take a bare timestamp and never a quoted string, so a
error. The date-time types take a bare timestamp and never a quoted string, so a
document that writes a date-time with quotes does not decode into them, and
neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
@@ -170,7 +172,7 @@ type Unmarshaler interface {
```
`data` is whatever the parser produced for that key: `string`, `bool`, `int64`,
`float64`, `time.Time`, `LocalDateTime`, `LocalDate`, `LocalTime`, `[]any`, or
`float64`, `OffsetDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`, `[]any`, or
`map[string]any`. The method inspects the value and mutates its own receiver;
the decoder keeps whatever state the receiver stored.
@@ -201,7 +203,7 @@ prefix `unmarshal text:`, for example `addr: unmarshal text: not an address`.
[`UnmarshalTOML`](#custom-decoding-unmarshaler) wins over `UnmarshalText` when
a type implements both, and the four [date-time
types](#date-time-values) are excluded: a quoted string stays a string and
never becomes a `time.Time` or one of the local wrappers.
never becomes an `OffsetDateTime` or one of the local wrappers.
### Durations
@@ -316,7 +318,7 @@ them.
By default every table is emitted with its entries grouped by kind:
1. scalars (`string`, `int64`, `float64`, `bool`, `time.Time`,
`LocalDateTime`, `LocalDate`, `LocalTime`)
`OffsetDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`)
2. sub-tables (structs and `map[string]V` values)
3. arrays of tables (`[]struct` and `[]map[string]V`)
@@ -351,11 +353,20 @@ type Marshaler interface {
The returned value is encoded as if it had been passed in place of the
receiver, so it may be a scalar, a slice, an array of tables, or another
struct or map, including the `Marshaler` result of another type; the encoder
recurses. An error returned from `MarshalTOML` fails the marshal wrapped with
the key path, for example `interpres: server.port: bad timestamp`. A result
of `nil` with a nil error fails the same way with
`MarshalTOML returned a nil value`: nil has no TOML representation, so
dropping the field silently is not an option.
recurses, and the result is normalised like any other value, so a method may
return a plain `int` or a `time.Duration`.
An error returned from `MarshalTOML` fails the marshal wrapped with the key
path, for example `interpres: server.port: bad timestamp`. A result of `nil` with
a nil error fails the same way with `MarshalTOML returned a nil value`: nil has
no TOML representation, so dropping the field silently is not an option.
The method is reached for every value the walk meets, array elements included:
an element that renders itself as a table keeps the `[[header]]` form, one that
renders itself as a scalar turns the array into a value array, and the method
runs once per element. It is looked up on the value and on its address, so a
pointer-receiver method is called for a field or an element, exactly as
`MarshalText` is.
```go
type Port int
@@ -512,9 +523,11 @@ sequenceDiagram
### `type SyntaxError struct{ Line int; Msg string }`
Describes a malformed TOML document; `Line` is 1-based and `Error()` renders as
`interpres: line N: msg`. Read the structured fields with a type assertion or
`errors.AsType`:
Describes a document the parser rejected, with the 1-based `Line` at which it
gave up and `Error()` rendering as `interpres: line N: msg`. A malformed
document is the usual cause; the nesting limit and an input that is not valid
UTF-8 report through the same type. Read the structured fields with a type
assertion or `errors.AsType`:
```go
if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
@@ -550,6 +563,19 @@ with `DisallowUnknownFields`, then call `Decode` or `DecodeContext` any number
of times. A configured `Decoder` holds no per-call state and is safe for
concurrent use.
| Method | Default | Effect |
|---|---|---|
| `DisallowUnknownFields()` | off | a key with no matching struct field is an error |
| `MaxDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest |
| `MaxInputSize(size int)` | no limit | bound the size of the document, in bytes |
The nesting limit protects the stack, because the parser is a recursive
descent: a deeper document is rejected with a `SyntaxError` naming the limit
rather than running the stack out. `Parse` and `ParseContext` carry that same
default but take no options. The size limit is off by default, because the
caller already holds the bytes and the size is therefore a policy, not a
protection the library can impose on its own.
### `type Encoder`
Configurable emission policy, constructed with `NewEncoder`. The option state
@@ -587,9 +613,10 @@ See [Custom decoding](#custom-decoding-unmarshaler).
### Date-time wrappers
```go
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
type LocalDate struct{ time.Time } // 1979-05-27
type LocalTime struct{ time.Time } // 07:32:00.999999
type OffsetDateTime struct{ time.Time } // 1979-05-27T07:32:00Z
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
type LocalDate struct{ time.Time } // 1979-05-27
type LocalTime struct{ time.Time } // 07:32:00.999999
```
Each carries a `String()` method returning the TOML-canonical rendering: the
@@ -602,11 +629,12 @@ writes them through `String()`.
The entry points return:
- `*SyntaxError` for a malformed document, with the 1-based line
- `*SyntaxError` for a malformed document, with the 1-based line; the nesting
limit reports through it as well
- `*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
context, a key that is not valid UTF-8, an input over the size limit
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
+106 -59
View File
@@ -20,12 +20,13 @@ import (
)
var (
localDateTimeType = reflect.TypeFor[LocalDateTime]()
localDateType = reflect.TypeFor[LocalDate]()
localTimeType = reflect.TypeFor[LocalTime]()
timeGoType = reflect.TypeFor[time.Time]()
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
localDateTimeType = reflect.TypeFor[LocalDateTime]()
localDateType = reflect.TypeFor[LocalDate]()
localTimeType = reflect.TypeFor[LocalTime]()
offsetDateTimeType = reflect.TypeFor[OffsetDateTime]()
timeGoType = reflect.TypeFor[time.Time]()
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
)
// inlineLimit is the column past which an inline table is written across
@@ -342,17 +343,15 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value")
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
if v.CanInterface() {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
if m, ok := marshalerOf(v); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
}
// A type that renders itself as text becomes a TOML string, whether it is
// a scalar kind or a struct.
@@ -431,12 +430,29 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, []any{}, ctx)
}
// Every element is resolved through MarshalTOML first, so an element that
// renders itself as a scalar, a table or a value array is classified by
// what it produces rather than by its Go kind, and its method runs once.
elems := make([]reflect.Value, n)
for i := range n {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev, err := resolveElement(v.Index(i), fmt.Sprintf("%s[%d]", joinKey(ctx, name), i))
if err != nil {
return err
}
elems[i] = ev
}
// 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)) {
for _, ev := range elems {
if !ev.IsValid() || !isTableElementValue(ev) {
allTables = false
break
}
@@ -450,16 +466,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
if allTables {
subs := make([]*tomlDoc, n)
for i := range n {
for i, ev := range elems {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
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:
@@ -485,29 +497,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
// 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 {
for i, ev := range elems {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
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 &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
}
if mv == nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errNilMarshalTOML}
}
ev = reflect.ValueOf(mv)
ev = followPtr(ev)
}
}
val, err := normaliseValue(ev)
if err != nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
@@ -517,6 +512,50 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, items, ctx)
}
// marshalerOf finds the Marshaler a value carries: on the value itself, or on
// its address, so a pointer-receiver MarshalTOML is found on an addressable
// struct field or slice element, exactly as textMarshalerOf finds MarshalText.
func marshalerOf(v reflect.Value) (Marshaler, bool) {
if !v.CanInterface() {
return nil, false
}
if m, ok := v.Interface().(Marshaler); ok {
return m, true
}
if v.CanAddr() {
if m, ok := v.Addr().Interface().(Marshaler); ok {
return m, true
}
}
return nil, false
}
// resolveElement looks through pointers and runs MarshalTOML, so an array
// element is classified by what its method produces. path names the element,
// for the errors the method can raise.
func resolveElement(v reflect.Value, path string) (reflect.Value, error) {
ev := followPtr(v)
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
m, ok := marshalerOf(ev)
if !ok {
return ev, nil
}
mv, err := m.MarshalTOML()
if err != nil {
return reflect.Value{}, &EncodeError{Path: path, Err: err}
}
if mv == nil {
return reflect.Value{}, &EncodeError{Path: path, Err: errNilMarshalTOML}
}
ev = followPtr(reflect.ValueOf(mv))
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
return ev, nil
}
// normaliseValue converts a reflect.Value into one of the canonical scalar or
// nested-array representations the emitter understands. Slices and arrays are
// recursively normalised so that nested arrays (e.g. [][]int) work.
@@ -528,20 +567,25 @@ func normaliseValue(v reflect.Value) (any, error) {
if v.Kind() == reflect.Interface {
return nil, fmt.Errorf("cannot encode nil value")
}
if v.CanInterface() {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return nil, err
}
if mv == nil {
return nil, errNilMarshalTOML
}
return mv, nil
if m, ok := marshalerOf(v); ok {
mv, err := m.MarshalTOML()
if err != nil {
return nil, err
}
if mv == nil {
return nil, errNilMarshalTOML
}
// The result is normalised like any other value, so a method may return
// a duration, a defined type or another Marshaler. A result of the
// receiver's own type is written as it is, because recursing into it
// would never end.
if rv := reflect.ValueOf(mv); rv.Type() != v.Type() {
return normaliseValue(rv)
}
return mv, nil
}
// The datetime structs are TOML scalars; the emitter renders each of them.
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
if isScalarStruct(v.Type()) {
return v.Interface(), nil
}
// TOML has no duration type, so a duration goes out in its canonical Go
@@ -622,24 +666,24 @@ func followPtr(v reflect.Value) reflect.Value {
}
// isScalarStruct reports whether t is a struct type that the encoder treats
// as a TOML scalar (time.Time, LocalDateTime, LocalDate, LocalTime).
// as a TOML scalar: time.Time and the four date-time wrappers.
func isScalarStruct(t reflect.Type) bool {
return t == timeGoType || isLocalDateType(t)
return t == timeGoType || t == offsetDateTimeType || isLocalDateType(t)
}
func isLocalDateType(t reflect.Type) bool {
return t == localDateTimeType || t == localDateType || t == localTimeType
}
// isDateTimeType reports whether t is one of the four TOML date-time types,
// which the encoder emits as bare atoms. Pointers are looked through. The types
// carry time.Time's text methods through an embedded field, and the atom form
// takes precedence over them.
// isDateTimeType reports whether t is one of the date-time types, which the
// encoder emits as bare atoms. Pointers are looked through. The types carry
// time.Time's text methods through an embedded field, and the atom form takes
// precedence over them.
func isDateTimeType(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == timeGoType || isLocalDateType(t)
return isScalarStruct(t)
}
// isTextMarshalerType reports whether t or *t implements
@@ -945,6 +989,9 @@ func (e *encoder) writeValue(val any) error {
case time.Time:
e.buf.WriteString(offsetString(v))
return nil
case OffsetDateTime:
e.buf.WriteString(v.String())
return nil
case LocalDateTime:
e.buf.WriteString(v.String())
return nil
+146
View File
@@ -1599,6 +1599,7 @@ func TestMarshalDateTimeRendering(t *testing.T) {
want string
}{
{"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"},
{"offset-wrapper", OffsetDateTime{Time: base}, "v = 2026-06-26T10:00Z\n"},
{"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"},
{"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"},
{"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"},
@@ -1800,3 +1801,148 @@ func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) {
t.Errorf("items = %#v, want []map[string]any", tree["items"])
}
}
// --- Marshaler on array elements -------------------------------------------
// countingMarshaler reports how often its method ran, so a test can check that
// the encoder resolves an element once.
type countingMarshaler struct{ calls *int }
func (c countingMarshaler) MarshalTOML() (any, error) {
*c.calls++
return map[string]any{"n": int64(*c.calls)}, nil
}
// ptrMarshaler carries MarshalTOML on the pointer receiver only.
type ptrMarshaler struct{ V string }
func (p *ptrMarshaler) MarshalTOML() (any, error) { return map[string]any{"v": p.V}, nil }
func TestMarshalerElementOfArrayOfTables(t *testing.T) {
// An element is classified by what MarshalTOML returns, so methods that
// render tables keep the [[header]] form the Go kind would have given them.
type Cfg struct {
Items []marshalerFunc `toml:"items"`
}
out, err := Marshal(Cfg{Items: []marshalerFunc{
func() (any, error) { return map[string]any{"k": "a"}, nil },
func() (any, error) { return map[string]any{"k": "b"}, nil },
}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nk = \"a\"\n\n[[items]]\nk = \"b\"\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementScalarResultMakesValueArray(t *testing.T) {
// One element rendering itself as a scalar turns the whole array into a
// value array, with the table elements written inline.
type Cfg struct {
Items []marshalerFunc `toml:"items"`
}
out, err := Marshal(Cfg{Items: []marshalerFunc{
func() (any, error) { return map[string]any{"k": "a"}, nil },
func() (any, error) { return "x", nil },
}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "items = [{k = \"a\"}, \"x\"]\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementRunsOnce(t *testing.T) {
// Classification and emission share one result, so the method runs exactly
// once per element even when it decides the array's form.
calls := 0
type Cfg struct {
Items []countingMarshaler `toml:"items"`
}
out, err := Marshal(Cfg{Items: []countingMarshaler{{calls: &calls}, {calls: &calls}, {calls: &calls}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if calls != 3 {
t.Errorf("MarshalTOML ran %d times, want 3", calls)
}
want := "[[items]]\nn = 1\n\n[[items]]\nn = 2\n\n[[items]]\nn = 3\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerPointerReceiverOnElement(t *testing.T) {
// A slice element is addressable, so a pointer-receiver MarshalTOML is
// found there, and the method's table keeps the [[header]] form.
type Cfg struct {
Items []ptrMarshaler `toml:"items"`
}
out, err := Marshal(&Cfg{Items: []ptrMarshaler{{V: "a"}, {V: "b"}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nv = \"a\"\n\n[[items]]\nv = \"b\"\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
// The same method is found on an addressable struct field, whose result is
// a table and so keeps a header section.
type Field struct {
F ptrMarshaler `toml:"f"`
}
out, err = Marshal(&Field{F: ptrMarshaler{V: "c"}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := "[f]\nv = \"c\"\n"; string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementErrorCarriesPath(t *testing.T) {
type Cfg struct {
Items []any `toml:"items"`
}
_, err := Marshal(Cfg{Items: []any{map[string]any{"k": "a"}, failingMarshalerFunc{}}})
if err == nil {
t.Fatal("expected an error from MarshalTOML")
}
if !strings.Contains(err.Error(), "oops") {
t.Errorf("err = %v, want substring \"oops\"", err)
}
ee, ok := errors.AsType[*EncodeError](err)
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "items[1]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[1]")
}
}
func TestMarshalerResultIsNormalised(t *testing.T) {
// A result is normalised like any other value, so a method may return a
// plain int or a duration where the Go kind alone would not encode.
type Cfg struct {
Plain marshalerFunc `toml:"plain"`
Duration marshalerFunc `toml:"duration"`
Elements []any `toml:"elements"`
}
out, err := Marshal(Cfg{
Plain: func() (any, error) { return 7, nil },
Duration: func() (any, error) { return 90 * time.Minute, nil },
Elements: []any{marshalerFunc(func() (any, error) { return 8, nil })},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "plain = 7\nduration = \"1h30m0s\"\nelements = [8]\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
+59 -11
View File
@@ -104,15 +104,33 @@ func Parse(data []byte) (map[string]any, error) {
// ctx. The context is checked between top-level statements so cancellation is
// honoured before the parser has done substantial work.
func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
return parseWithOptions(ctx, data, parseOptions{})
}
// parseOptions bound the work one parse may do. A zero field takes the
// default.
type parseOptions struct {
maxDepth int
maxInputSize int
}
func parseWithOptions(ctx context.Context, data []byte, opts parseOptions) (map[string]any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if opts.maxInputSize > 0 && len(data) > opts.maxInputSize {
return nil, fmt.Errorf("interpres: input is %d bytes, over the limit of %d", len(data), opts.maxInputSize)
}
if !utf8.Valid(data) {
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
}
maxDepth := opts.maxDepth
if maxDepth <= 0 {
maxDepth = maxNestingDepth
}
// The parser scans data in place; it only reads the buffer, and every
// string it stores in the tree is copied out of it.
p := &parser{src: data, line: 1, ctx: ctx}
p := &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth}
return p.parse()
}
@@ -143,9 +161,11 @@ func UnmarshalContext(ctx context.Context, data []byte, v any) error {
}
// A Decoder decodes a TOML document into a Go value with configurable
// strictness.
// strictness and configurable limits on the parse it performs.
type Decoder struct {
disallowUnknown bool
maxDepth int
maxInputSize int
}
// NewDecoder returns a Decoder.
@@ -158,6 +178,28 @@ func (d *Decoder) DisallowUnknownFields() *Decoder {
return d
}
// MaxDepth bounds how deeply arrays and inline tables may nest in a document
// this decoder accepts. The parser is a recursive descent, so a document that
// nests without bound would exhaust the stack; one that nests deeper than the
// limit is rejected with a SyntaxError naming it instead. Use 0 or any
// negative value for the default of 10000, which no hand-written document
// approaches.
func (d *Decoder) MaxDepth(depth int) *Decoder {
d.maxDepth = depth
return d
}
// MaxInputSize bounds the size of a document this decoder accepts, in bytes; a
// larger one is rejected before parsing starts. Use 0 or any negative value for
// no limit, which is the default: the caller already holds the bytes, so the
// size is a policy the caller sets rather than a protection the library
// imposes on its own. Parse and ParseContext take no limit beyond the nesting
// default.
func (d *Decoder) MaxInputSize(size int) *Decoder {
d.maxInputSize = size
return d
}
// Decode parses data and stores the result in the value pointed to by v,
// honouring the decoder's strictness settings.
//
@@ -168,7 +210,10 @@ func (d *Decoder) Decode(data []byte, v any) error {
// DecodeContext is the cancellable variant of Decode.
func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
tree, err := ParseContext(ctx, data)
tree, err := parseWithOptions(ctx, data, parseOptions{
maxDepth: d.maxDepth,
maxInputSize: d.maxInputSize,
})
if err != nil {
return err
}
@@ -193,12 +238,15 @@ type Marshaler interface {
// Unmarshaler is the inverse of Marshaler: a type that wants control over
// how it is decoded from a TOML value may implement UnmarshalTOML. The data
// argument is whatever the parser produced for that key: one of string,
// bool, int64, float64, time.Time, LocalDateTime, LocalDate, LocalTime,
// []any, or map[string]any. UnmarshalTOML may parse, inspect, or transform
// the value however it likes, then store the result by mutating its
// receiver through the standard pointer-indirection rules of the reflect
// package (i.e. via reflect.Value.Set or by reassigning fields through a
// pointer the receiver holds).
// bool, int64, float64, OffsetDateTime, LocalDateTime, LocalDate, LocalTime,
// []any, or map[string]any. A tree built by hand may carry a plain time.Time
// where the parser would put an OffsetDateTime.
//
// UnmarshalTOML may parse, inspect, or transform the value however it likes,
// then store the result by mutating its receiver through the standard
// pointer-indirection rules of the reflect package (i.e. via
// reflect.Value.Set or by reassigning fields through a pointer the receiver
// holds).
//
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
// destination type implements the interface. The decoder does not need to
@@ -231,8 +279,8 @@ type Unmarshaler interface {
// 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). A date-time writes its seconds only when the value carries
// and OffsetDateTime (offset date-time), and LocalDateTime/LocalDate/
// LocalTime (local variants). A date-time writes its seconds only when the value carries
// them, and drops the trailing zeros of a fractional second.
// - A table element of a value array, and a sub-table inlined by
// Encoder.InlineTables, is written as an inline table, across lines when it
+32 -9
View File
@@ -4,6 +4,7 @@
package interpres
import (
"errors"
"math"
"strings"
"testing"
@@ -192,7 +193,7 @@ clock = 07:32:00
if err != nil {
t.Fatalf("parse: %v", err)
}
if off, ok := tree["offset"].(time.Time); !ok || off.Year() != 1979 || off.Hour() != 7 {
if off, ok := tree["offset"].(OffsetDateTime); !ok || off.Year() != 1979 || off.Hour() != 7 {
t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"])
}
if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 {
@@ -211,11 +212,11 @@ func TestDateTimeFormats(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, ok := tree["a"].(time.Time); !ok {
t.Errorf("a is %T, want time.Time", tree["a"])
if _, ok := tree["a"].(OffsetDateTime); !ok {
t.Errorf("a is %T, want OffsetDateTime", tree["a"])
}
if _, ok := tree["b"].(time.Time); !ok {
t.Errorf("b is %T, want time.Time", tree["b"])
if _, ok := tree["b"].(OffsetDateTime); !ok {
t.Errorf("b is %T, want OffsetDateTime", tree["b"])
}
if _, ok := tree["c"].(LocalDateTime); !ok {
t.Errorf("c is %T, want LocalDateTime", tree["c"])
@@ -419,11 +420,11 @@ func TestParseAcceptsOffsetBounds(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
a := tree["a"].(time.Time)
a := tree["a"].(OffsetDateTime)
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)
b := tree["b"].(OffsetDateTime)
if _, offset := b.Zone(); offset != -(23*3600 + 59*60) {
t.Fatalf("b offset = %d", offset)
}
@@ -619,10 +620,10 @@ odt2 = 1979-05-27 07:32-07:00
if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" {
t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32")
}
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
if got := tree["odt1"].(OffsetDateTime).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" {
if got := tree["odt2"].(OffsetDateTime).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.
@@ -703,3 +704,25 @@ func TestParseAcceptsMultilineInlineTables(t *testing.T) {
}
}
}
func TestParseNestingLimit(t *testing.T) {
// The parser is a recursive descent, so a document that nests without bound
// is rejected instead of exhausting the stack.
deep := func(n int) []byte {
return []byte("v = " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n")
}
if _, err := Parse(deep(100)); err != nil {
t.Fatalf("a document well inside the limit: %v", err)
}
_, err := Parse(deep(maxNestingDepth + 1))
if err == nil {
t.Fatal("expected a nesting error")
}
var se *SyntaxError
if !errors.As(err, &se) {
t.Fatalf("expected a *SyntaxError, got %T: %v", err, err)
}
if !strings.Contains(se.Msg, "nesting") {
t.Errorf("Msg = %q, want it to name the nesting limit", se.Msg)
}
}
+31
View File
@@ -30,6 +30,12 @@ type parser struct {
line int
ctx context.Context
// maxDepth and depth bound the nesting the recursive descent may follow:
// arrays and inline tables nest through parseValue, and without a limit a
// hostile document would exhaust the stack.
maxDepth int
depth int
root map[string]any
current map[string]any
headers map[string]bool
@@ -40,6 +46,23 @@ type parser struct {
currentPath []string
}
// maxNestingDepth bounds how deeply arrays and inline tables may nest when no
// limit is set. It matches the default encoding/json uses for the same reason,
// and sits far above any document a person writes.
const maxNestingDepth = 10000
// enterNesting counts one level of array or inline-table nesting and reports a
// document that nests deeper than the limit allows.
func (p *parser) enterNesting() error {
p.depth++
if p.depth > p.maxDepth {
return p.errf("nesting exceeds the limit of %d", p.maxDepth)
}
return nil
}
func (p *parser) leaveNesting() { p.depth-- }
func (p *parser) parse() (map[string]any, error) {
p.root = map[string]any{}
p.current = p.root
@@ -676,6 +699,10 @@ func (p *parser) readUnicode(n int) (rune, error) {
// --- arrays and inline tables ---------------------------------------------
func (p *parser) parseArray() (any, error) {
if err := p.enterNesting(); err != nil {
return nil, err
}
defer p.leaveNesting()
p.pos++ // '['
arr := []any{}
for {
@@ -713,6 +740,10 @@ func (p *parser) parseArray() (any, error) {
}
func (p *parser) parseInlineTable() (any, error) {
if err := p.enterNesting(); err != nil {
return nil, err
}
defer p.leaveNesting()
p.pos++ // '{'
tbl := map[string]any{}
assigned := map[string]bool{}