feat: add field comments, local time zone decoding and in-value cancellation
Test / test (push) Successful in 1m32s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 00:44:23 +02:00
parent 71bd82a7a5
commit d18935ebc2
8 changed files with 307 additions and 11 deletions
+12
View File
@@ -49,6 +49,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ParseAs[T](data)`, the generic one-line decode, and `NewSchema[T]()`, - `ParseAs[T](data)`, the generic one-line decode, and `NewSchema[T]()`,
which precompiles the struct schema and the interface flags for a hot path which precompiles the struct schema and the interface flags for a hot path
before the first document arrives. before the first document arrives.
- `Encoder.EmitFieldComments()` prints the comment a field's `toml` tag
carries in a `comment=` option above the field's line or header, the
comments a round trip through the Go type would otherwise drop. Go doc
comments are not visible to reflection, so the tag is the channel that
carries the text.
- `Decoder.LocalTimeLocation(loc)` lets a local date-time fill a plain
`time.Time` destination in the location given, relabelled rather than
shifted: `07:32` in the document is `07:32` in the zone. Without the
option the wrapper types remain the only destinations a local kind fills.
- The parse checks its context inside a value as well as between statements:
an array, an inline table and a multi-line string check every 64 elements
or lines, so one huge value cannot hold the parse past its cancellation.
- `OrderedMap`, the string-keyed table that remembers the order its keys were - `OrderedMap`, the string-keyed table that remembers the order its keys were
set in: decoding into one fills it in the order the document wrote the set in: decoding into one fills it in the order the document wrote the
keys, and `Marshal` writes one back in that order, where a map carries no keys, and `Marshal` writes one back in that order, where a map carries no
+41 -1
View File
@@ -20,11 +20,13 @@ import (
// UnmarshalerContext destination; entry points without one leave it nil. // UnmarshalerContext destination; entry points without one leave it nil.
// nodes is the document's node index, present only when a destination can // nodes is the document's node index, present only when a destination can
// reach an OrderedMap and the parse built the tree its key order is read // reach an OrderedMap and the parse built the tree its key order is read
// from. // from. loc is the zone a local date-time is carried in when it decodes into
// a time.Time destination; nil keeps the wrapper-only default.
type decoder struct { type decoder struct {
disallowUnknown bool disallowUnknown bool
ctx context.Context ctx context.Context
nodes nodeIndex nodes nodeIndex
loc *time.Location
} }
func newDecoder() *decoder { return &decoder{} } func newDecoder() *decoder { return &decoder{} }
@@ -244,6 +246,24 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
return setOffsetDateTime(v, dst) return setOffsetDateTime(v, dst)
case time.Time: case time.Time:
return setDateTime(v, dst) return setDateTime(v, dst)
case LocalDateTime:
if dst.Type() == localDateTimeType {
dst.Set(reflect.ValueOf(v))
return nil
}
return d.setLocalTimeValue(v.Time, dst)
case LocalDate:
if dst.Type() == localDateType {
dst.Set(reflect.ValueOf(v))
return nil
}
return d.setLocalTimeValue(v.Time, dst)
case LocalTime:
if dst.Type() == localTimeType {
dst.Set(reflect.ValueOf(v))
return nil
}
return d.setLocalTimeValue(v.Time, dst)
default: default:
rv := reflect.ValueOf(data) rv := reflect.ValueOf(data)
if rv.IsValid() && dst.Type() == rv.Type() { if rv.IsValid() && dst.Type() == rv.Type() {
@@ -464,6 +484,26 @@ func setDateTime(v time.Time, dst reflect.Value) error {
return nil return nil
} }
// setLocalTimeValue stores a local date-time value into a plain time.Time
// destination, which the decoder permits only when LocalTimeLocation fixed
// the zone the wall-clock value is carried in; without it the wrapper types
// are the only destinations a local kind fills, as they always have been.
func (d *decoder) setLocalTimeValue(t time.Time, dst reflect.Value) error {
if dst.Type() == timeType {
if d.loc != nil {
// A local value is a wall clock, so the zone choice relabels it
// rather than shifting the instant: 07:32 in the document is
// 07:32 in the location, not an hour later.
dst.Set(reflect.ValueOf(time.Date(
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), d.loc)))
return nil
}
return fmt.Errorf("interpres: cannot assign local date-time to time.Time; set Decoder.LocalTimeLocation to choose the zone")
}
return fmt.Errorf("interpres: cannot assign local date-time to %s", dst.Type())
}
func setBasic(dst, val reflect.Value, kind string) error { func setBasic(dst, val reflect.Value, kind string) error {
if dst.Kind() != val.Kind() { if dst.Kind() != val.Kind() {
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type()) return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
+87
View File
@@ -11,6 +11,7 @@ import (
"net" "net"
"slices" "slices"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -1487,3 +1488,89 @@ func TestPathString(t *testing.T) {
t.Errorf("String() of an empty path = %q, want the empty string", got) t.Errorf("String() of an empty path = %q, want the empty string", got)
} }
} }
func TestLocalTimeLocation(t *testing.T) {
zone := time.FixedZone("CET", 3600)
t.Run("without the option a local kind fills only its wrapper", func(t *testing.T) {
var cfg struct {
When time.Time `toml:"when"`
}
err := Unmarshal([]byte("when = 1979-05-27T07:32:00\n"), &cfg)
if err == nil || !strings.Contains(err.Error(), "LocalTimeLocation") {
t.Errorf("err = %v, want the option hint", err)
}
})
t.Run("with the option the value lands in the zone", func(t *testing.T) {
var cfg struct {
When time.Time `toml:"when"`
Date LocalDate `toml:"date"`
Wall LocalDateTime `toml:"wall"`
}
dec := NewDecoder().LocalTimeLocation(zone)
in := []byte("when = 1979-05-27T07:32:00\ndate = 1979-05-27\nwall = 1979-05-27T07:32:00\n")
if err := dec.Decode(in, &cfg); err != nil {
t.Fatal(err)
}
if got := cfg.When.Format("15:04:05 MST"); got != "07:32:00 CET" {
t.Errorf("when = %s, want 07:32:00 CET", got)
}
if cfg.Date != (LocalDate{time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)}) {
t.Errorf("date = %v", cfg.Date)
}
})
t.Run("the wrapper still takes the value with the option on", func(t *testing.T) {
var cfg struct {
Wall LocalDateTime `toml:"wall"`
}
dec := NewDecoder().LocalTimeLocation(zone)
if err := dec.Decode([]byte("wall = 1979-05-27T07:32:00\n"), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Wall.Hour() != 7 {
t.Errorf("wall = %v", cfg.Wall)
}
})
}
// errAfterN is a context that reports cancelled once its Err has been read
// more than n times, which drives the in-value cancellation checks: the
// parser reads Err a fixed number of times per statement, so a huge array
// fails only where the checks inside the value run.
type errAfterN struct {
context.Context
n int
how atomic.Int32
}
func (c *errAfterN) Err() error {
if c.how.Add(1) > int32(c.n) {
return context.Canceled
}
return nil
}
func TestCancelInsideValue(t *testing.T) {
// Two top-level checks happen before the value (the entry check and the
// statement loop's first); the array checks follow inside the value, so
// the third read is the first that can fail today. The document only
// parses to the end when the checks inside the value are missing, which
// is the defect this test pins.
var b strings.Builder
b.WriteString("a = [")
for i := range 4000 {
if i > 0 {
b.WriteByte(',')
}
b.WriteString("1")
}
b.WriteString("]\n")
ctx := &errAfterN{Context: context.Background(), n: 2}
var tree map[string]any
err := NewDecoder().DecodeContext(ctx, []byte(b.String()), &tree)
if err == nil {
t.Fatal("a cancelled context did not stop the parse inside the value")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
+15 -1
View File
@@ -314,6 +314,12 @@ 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 document that writes a date-time with quotes does not decode into them, and
neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
`NewDecoder().LocalTimeLocation(loc)` lets a local date-time fill a plain
`time.Time` destination as well: the wall-clock value is carried in the
location given, relabelled rather than shifted, so `07:32` in the document is
`07:32` in the zone. Without the option the wrapper types are the only
destinations a local kind fills.
### Arrays of tables ### Arrays of tables
A `[[a]]` block parses into a `[]map[string]any` element of the tree. When the A `[[a]]` block parses into a `[]map[string]any` element of the tree. When the
@@ -438,7 +444,9 @@ one, so it does not depend on map iteration order.
`ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a `ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a
`context.Context`. An already-cancelled context short-circuits with `context.Context`. An already-cancelled context short-circuits with
`context.Canceled` before any work begins; afterwards the context is checked `context.Canceled` before any work begins; afterwards the context is checked
every 64 top-level statements. every 64 top-level statements, and inside a value too: an array, an inline
table and a multi-line string check every 64 elements or lines, so one huge
value cannot hold the parse past its cancellation.
### Flow ### Flow
@@ -509,6 +517,11 @@ its key whether the table it came from was written inline or under a header.
embedded struct tagged this way does the same. A field holding an array of embedded struct tagged this way does the same. A field holding an array of
tables is an error under `inline`, because the inline form would re-parse tables is an error under `inline`, because the inline form would re-parse
as a value array and change the value's Go type. as a value array and change the value's Go type.
- `comment=text` carries a comment for the field, which
`NewEncoder().EmitFieldComments()` prints above the field's line or
header, each line of a multi-line text with its own `# ` marker. Go doc
comments are not visible to reflection, so the tag is the channel that
carries the text; without the encoder option the tag is ignored.
```go ```go
type Config struct { type Config struct {
@@ -817,6 +830,7 @@ encoder:
| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays | | `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays |
| `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` | | `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` |
| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes | | `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes |
| `EmitFieldComments()` | off | print the `comment=` tag option of a field above its line or header |
```go ```go
out, err := interpres.NewEncoder(). out, err := interpres.NewEncoder().
+53 -7
View File
@@ -308,6 +308,11 @@ type entry struct {
// `,inline` tag option. An array of tables keeps the header form. // `,inline` tag option. An array of tables keeps the header form.
inline bool inline bool
// comments are the comment lines written above this entry's line or
// header, which the `comment=` tag option carries when
// Encoder.EmitFieldComments is on.
comments []string
// emitted records that the grouped emission wrote this table inline, so // emitted records that the grouped emission wrote this table inline, so
// the header pass that follows skips it. The representation is built // the header pass that follows skips it. The representation is built
// fresh per Marshal call. // fresh per Marshal call.
@@ -482,9 +487,17 @@ func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, sc
if fieldOmitted(f, v.Field(i)) { if fieldOmitted(f, v.Field(i)) {
continue continue
} }
before := len(doc.entries)
if err := addField(doc, name, v.Field(i), path, tagHasOption(f.Tag.Get("toml"), "inline")); err != nil { if err := addField(doc, name, v.Field(i), path, tagHasOption(f.Tag.Get("toml"), "inline")); err != nil {
return err return err
} }
// The comment a `comment=` tag option carries lands on the entry the
// field emitted, when the option to print field comments is on.
if len(doc.entries) > before && doc.opts.emitFieldComments {
if text := tagComment(f.Tag.Get("toml")); text != "" {
doc.entries[len(doc.entries)-1].comments = strings.Split(text, "\n")
}
}
} }
return nil return nil
} }
@@ -514,6 +527,20 @@ func tagHasOption(tag, want string) bool {
return false return false
} }
// tagComment returns the text a `comment=` tag option carries, without the
// option name. An unset comment comes back empty.
func tagComment(tag string) string {
opts := tagOptions(tag)
for opts != "" {
var opt string
opt, opts, _ = strings.Cut(opts, ",")
if text, ok := strings.CutPrefix(opt, "comment="); ok {
return text
}
}
return ""
}
// fieldOmitted reports whether the field's tag options drop it from the // fieldOmitted reports whether the field's tag options drop it from the
// output: omitzero skips the zero value of the field's type, omitempty skips // output: omitzero skips the zero value of the field's type, omitempty skips
// an empty value in the encoding/json sense, an empty string, a zero number, // an empty value in the encoding/json sense, an empty string, a zero number,
@@ -1140,7 +1167,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
if kv.kind != entryScalar { if kv.kind != entryScalar {
continue continue
} }
if err := e.writeKV(kv.key, kv.val); err != nil { if err := e.writeKV(kv); err != nil {
return err return err
} }
} }
@@ -1165,6 +1192,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
} }
path := append(append([]string{}, prefix...), t.key) path := append(append([]string{}, prefix...), t.key)
e.writeBlankLine() e.writeBlankLine()
e.writeComments(t.comments)
e.buf.WriteByte('[') e.buf.WriteByte('[')
if err := e.writeKeyPath(path); err != nil { if err := e.writeKeyPath(path); err != nil {
return err return err
@@ -1180,8 +1208,11 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
continue continue
} }
path := append(append([]string{}, prefix...), a.key) path := append(append([]string{}, prefix...), a.key)
for _, sub := range a.docs { for j, sub := range a.docs {
e.writeBlankLine() e.writeBlankLine()
if j == 0 {
e.writeComments(a.comments)
}
e.buf.WriteString("[[") e.buf.WriteString("[[")
if err := e.writeKeyPath(path); err != nil { if err := e.writeKeyPath(path); err != nil {
return err return err
@@ -1203,7 +1234,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
for _, ent := range doc.entries { for _, ent := range doc.entries {
switch ent.kind { switch ent.kind {
case entryScalar: case entryScalar:
if err := e.writeKV(ent.key, ent.val); err != nil { if err := e.writeKV(&ent); err != nil {
return err return err
} }
case entryTable: case entryTable:
@@ -1216,6 +1247,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
} }
path := append(append([]string{}, prefix...), ent.key) path := append(append([]string{}, prefix...), ent.key)
e.writeBlankLine() e.writeBlankLine()
e.writeComments(ent.comments)
e.buf.WriteByte('[') e.buf.WriteByte('[')
if err := e.writeKeyPath(path); err != nil { if err := e.writeKeyPath(path); err != nil {
return err return err
@@ -1226,8 +1258,11 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
} }
case entryArray: case entryArray:
path := append(append([]string{}, prefix...), ent.key) path := append(append([]string{}, prefix...), ent.key)
for _, sub := range ent.docs { for j, sub := range ent.docs {
e.writeBlankLine() e.writeBlankLine()
if j == 0 {
e.writeComments(ent.comments)
}
e.buf.WriteString("[[") e.buf.WriteString("[[")
if err := e.writeKeyPath(path); err != nil { if err := e.writeKeyPath(path); err != nil {
return err return err
@@ -1242,12 +1277,23 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
return nil return nil
} }
func (e *encoder) writeKV(key string, val any) error { // writeComments writes comment lines above an entry, each prefixed with the
if err := e.writeKey(key); err != nil { // "# " marker the parser strips on the way in.
func (e *encoder) writeComments(lines []string) {
for _, line := range lines {
e.buf.WriteString("# ")
e.buf.WriteString(line)
e.buf.WriteByte('\n')
}
}
func (e *encoder) writeKV(ent *entry) error {
e.writeComments(ent.comments)
if err := e.writeKey(ent.key); err != nil {
return err return err
} }
e.buf.WriteString(" = ") e.buf.WriteString(" = ")
if err := e.writeValue(val); err != nil { if err := e.writeValue(ent.val); err != nil {
return err return err
} }
e.buf.WriteByte('\n') e.buf.WriteByte('\n')
+54
View File
@@ -2234,3 +2234,57 @@ func TestOmitEmptyJSONSemantics(t *testing.T) {
t.Errorf("output:\n%q\nwant:\n%q", out, want) t.Errorf("output:\n%q\nwant:\n%q", out, want)
} }
} }
func TestEmitFieldComments(t *testing.T) {
type Cfg struct {
Host string `toml:"host,comment=The host to dial"`
Port int `toml:"port,comment=The port to listen on.\nThe default is 8080."`
User string `toml:"user"`
}
cfg := Cfg{Host: "db", Port: 5432, User: "admin"}
t.Run("off by default", func(t *testing.T) {
out, err := Marshal(cfg)
if err != nil {
t.Fatal(err)
}
want := "host = \"db\"\nport = 5432\nuser = \"admin\"\n"
if string(out) != want {
t.Errorf("output:\n%q", out)
}
})
t.Run("on, the comments print above their lines", func(t *testing.T) {
out, err := NewEncoder().EmitFieldComments().Marshal(cfg)
if err != nil {
t.Fatal(err)
}
want := "# The host to dial\nhost = \"db\"\n" +
"# The port to listen on.\n# The default is 8080.\nport = 5432\n" +
"user = \"admin\"\n"
if string(out) != want {
t.Errorf("output:\n%q\nwant:\n%q", out, want)
}
var back Cfg
if err := Unmarshal(out, &back); err != nil {
t.Fatalf("the output does not re-parse: %v", err)
}
if back != cfg {
t.Errorf("round trip = %+v", back)
}
})
t.Run("a table header carries its comment", func(t *testing.T) {
type Inner struct {
A int `toml:"a,comment=The a"`
}
type Nested struct {
Inner Inner `toml:"inner,comment=The inner table"`
}
out, err := NewEncoder().EmitFieldComments().Marshal(Nested{Inner: Inner{1}})
if err != nil {
t.Fatal(err)
}
want := "# The inner table\n[inner]\n# The a\na = 1\n"
if string(out) != want {
t.Errorf("output:\n%q\nwant:\n%q", out, want)
}
})
}
+30
View File
@@ -29,6 +29,7 @@ import (
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
"time"
) )
// A SyntaxError describes a malformed TOML document. Line is the 1-based line // A SyntaxError describes a malformed TOML document. Line is the 1-based line
@@ -321,6 +322,7 @@ type Decoder struct {
useNumber bool useNumber bool
maxDepth int maxDepth int
maxInputSize int maxInputSize int
localLoc *time.Location
} }
// NewDecoder returns a Decoder. // NewDecoder returns a Decoder.
@@ -343,6 +345,17 @@ func (d *Decoder) UseNumber() *Decoder {
return d return d
} }
// LocalTimeLocation sets the zone a local date-time is placed in when it
// decodes into a time.Time destination. Without the option a local date-time
// fills only its own wrapper type (LocalDateTime, LocalDate, LocalTime),
// whose embedded time.Time is UTC; with the option, a time.Time destination
// takes the value too, carried in the location given. A nil location restores
// the default.
func (d *Decoder) LocalTimeLocation(loc *time.Location) *Decoder {
d.localLoc = loc
return d
}
// MaxDepth bounds how deeply arrays and inline tables may nest in a document // 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 // 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 // nests without bound would exhaust the stack; one that nests deeper than the
@@ -388,6 +401,7 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
dec.disallowUnknown = d.disallowUnknown dec.disallowUnknown = d.disallowUnknown
dec.ctx = ctx dec.ctx = ctx
dec.nodes = indexNodes(doc.Root()) dec.nodes = indexNodes(doc.Root())
dec.loc = d.localLoc
return dec.decode(tree, v) return dec.decode(tree, v)
} }
@@ -552,6 +566,7 @@ type Encoder struct {
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
inlineTablesAt int // default 0; set via (*Encoder).InlineTables inlineTablesAt int // default 0; set via (*Encoder).InlineTables
emitFieldComments bool // default false; set via (*Encoder).EmitFieldComments
} }
// NewEncoder returns an Encoder with default options. // NewEncoder returns an Encoder with default options.
@@ -602,6 +617,21 @@ func (e *Encoder) InlineTables(threshold int) *Encoder {
return e return e
} }
// EmitFieldComments turns on printing the comment a field's `toml` tag
// carries in a `comment=` option, above the field's line or header, the
// comments a round trip through the Go type would otherwise drop:
//
// Port int `toml:"port,comment=The port to listen on"`
//
// Go doc comments are not visible to reflection, so the tag is the channel
// that carries the text. Off by default, and a field without a `comment=`
// option prints none. Multi-line comments carry newlines in the tag, each
// line printed with its own "# " marker.
func (e *Encoder) EmitFieldComments() *Encoder {
e.emitFieldComments = true
return e
}
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v. // Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
// //
// Marshal is equivalent to MarshalContext with context.Background. // Marshal is equivalent to MarshalContext with context.Background.
+15 -2
View File
@@ -1079,7 +1079,15 @@ func (p *parser) parseArray() (val any, err error) {
} }
}() }()
} }
for { for i := 0; ; i++ {
// A container the size of memory should answer cancellation inside the
// value, not only between statements, so the element loops check the
// context on their own cadence.
if i%ctxCheckInterval == 0 {
if err := p.checkCtx(); err != nil {
return nil, err
}
}
if err := p.skipNestedSpace(); err != nil { if err := p.skipNestedSpace(); err != nil {
return nil, err return nil, err
} }
@@ -1148,7 +1156,12 @@ func (p *parser) parseInlineTable() (val any, err error) {
p.pos++ p.pos++
return tbl, nil return tbl, nil
} }
for { for i := 0; ; i++ {
if i%ctxCheckInterval == 0 {
if err := p.checkCtx(); err != nil {
return nil, err
}
}
if err := p.skipNestedSpace(); err != nil { if err := p.skipNestedSpace(); err != nil {
return nil, err return nil, err
} }