From d18935ebc2f4f77728ac87d54005a0192636f685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 00:44:23 +0200 Subject: [PATCH] feat: add field comments, local time zone decoding and in-value cancellation Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 12 +++++++ decode.go | 42 +++++++++++++++++++++++- decode_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/API.md | 16 +++++++++- encode.go | 60 ++++++++++++++++++++++++++++++---- encode_test.go | 54 +++++++++++++++++++++++++++++++ interpres.go | 30 +++++++++++++++++ parser.go | 17 ++++++++-- 8 files changed, 307 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c6627..7bf04ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]()`, which precompiles the struct schema and the interface flags for a hot path 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 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 diff --git a/decode.go b/decode.go index 1630c73..ba6c484 100644 --- a/decode.go +++ b/decode.go @@ -20,11 +20,13 @@ import ( // UnmarshalerContext destination; entry points without one leave it nil. // 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 -// 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 { disallowUnknown bool ctx context.Context nodes nodeIndex + loc *time.Location } func newDecoder() *decoder { return &decoder{} } @@ -244,6 +246,24 @@ func (d *decoder) assign(data any, dst reflect.Value) error { return setOffsetDateTime(v, dst) case time.Time: 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: rv := reflect.ValueOf(data) if rv.IsValid() && dst.Type() == rv.Type() { @@ -464,6 +484,26 @@ func setDateTime(v time.Time, dst reflect.Value) error { 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 { if dst.Kind() != val.Kind() { return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type()) diff --git a/decode_test.go b/decode_test.go index f93b0f3..57bb5a6 100644 --- a/decode_test.go +++ b/decode_test.go @@ -11,6 +11,7 @@ import ( "net" "slices" "strings" + "sync/atomic" "testing" "time" ) @@ -1487,3 +1488,89 @@ func TestPathString(t *testing.T) { 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) + } +} diff --git a/docs/API.md b/docs/API.md index a43b57b..52e43c2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 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 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 `context.Context`. An already-cancelled context short-circuits with `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 @@ -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 tables is an error under `inline`, because the inline form would re-parse 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 type Config struct { @@ -817,6 +830,7 @@ encoder: | `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays | | `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 | +| `EmitFieldComments()` | off | print the `comment=` tag option of a field above its line or header | ```go out, err := interpres.NewEncoder(). diff --git a/encode.go b/encode.go index 6b89a20..3aaf302 100644 --- a/encode.go +++ b/encode.go @@ -308,6 +308,11 @@ type entry struct { // `,inline` tag option. An array of tables keeps the header form. 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 // the header pass that follows skips it. The representation is built // 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)) { continue } + before := len(doc.entries) if err := addField(doc, name, v.Field(i), path, tagHasOption(f.Tag.Get("toml"), "inline")); err != nil { 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 } @@ -514,6 +527,20 @@ func tagHasOption(tag, want string) bool { 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 // 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, @@ -1140,7 +1167,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { if kv.kind != entryScalar { continue } - if err := e.writeKV(kv.key, kv.val); err != nil { + if err := e.writeKV(kv); err != nil { return err } } @@ -1165,6 +1192,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { } path := append(append([]string{}, prefix...), t.key) e.writeBlankLine() + e.writeComments(t.comments) e.buf.WriteByte('[') if err := e.writeKeyPath(path); err != nil { return err @@ -1180,8 +1208,11 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { continue } path := append(append([]string{}, prefix...), a.key) - for _, sub := range a.docs { + for j, sub := range a.docs { e.writeBlankLine() + if j == 0 { + e.writeComments(a.comments) + } e.buf.WriteString("[[") if err := e.writeKeyPath(path); err != nil { return err @@ -1203,7 +1234,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { for _, ent := range doc.entries { switch ent.kind { case entryScalar: - if err := e.writeKV(ent.key, ent.val); err != nil { + if err := e.writeKV(&ent); err != nil { return err } case entryTable: @@ -1216,6 +1247,7 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { } path := append(append([]string{}, prefix...), ent.key) e.writeBlankLine() + e.writeComments(ent.comments) e.buf.WriteByte('[') if err := e.writeKeyPath(path); err != nil { return err @@ -1226,8 +1258,11 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { } case entryArray: path := append(append([]string{}, prefix...), ent.key) - for _, sub := range ent.docs { + for j, sub := range ent.docs { e.writeBlankLine() + if j == 0 { + e.writeComments(ent.comments) + } e.buf.WriteString("[[") if err := e.writeKeyPath(path); err != nil { return err @@ -1242,12 +1277,23 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { return nil } -func (e *encoder) writeKV(key string, val any) error { - if err := e.writeKey(key); err != nil { +// writeComments writes comment lines above an entry, each prefixed with the +// "# " 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 } e.buf.WriteString(" = ") - if err := e.writeValue(val); err != nil { + if err := e.writeValue(ent.val); err != nil { return err } e.buf.WriteByte('\n') diff --git a/encode_test.go b/encode_test.go index af3a087..52664b2 100644 --- a/encode_test.go +++ b/encode_test.go @@ -2234,3 +2234,57 @@ func TestOmitEmptyJSONSemantics(t *testing.T) { 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) + } + }) +} diff --git a/interpres.go b/interpres.go index edaf8be..8933fce 100644 --- a/interpres.go +++ b/interpres.go @@ -29,6 +29,7 @@ import ( "reflect" "slices" "strings" + "time" ) // A SyntaxError describes a malformed TOML document. Line is the 1-based line @@ -321,6 +322,7 @@ type Decoder struct { useNumber bool maxDepth int maxInputSize int + localLoc *time.Location } // NewDecoder returns a Decoder. @@ -343,6 +345,17 @@ func (d *Decoder) UseNumber() *Decoder { 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 // 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 @@ -388,6 +401,7 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { dec.disallowUnknown = d.disallowUnknown dec.ctx = ctx dec.nodes = indexNodes(doc.Root()) + dec.loc = d.localLoc return dec.decode(tree, v) } @@ -552,6 +566,7 @@ type Encoder struct { omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline inlineTablesAt int // default 0; set via (*Encoder).InlineTables + emitFieldComments bool // default false; set via (*Encoder).EmitFieldComments } // NewEncoder returns an Encoder with default options. @@ -602,6 +617,21 @@ func (e *Encoder) InlineTables(threshold int) *Encoder { 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 is equivalent to MarshalContext with context.Background. diff --git a/parser.go b/parser.go index 9daa0f2..786fc30 100644 --- a/parser.go +++ b/parser.go @@ -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 { return nil, err } @@ -1148,7 +1156,12 @@ func (p *parser) parseInlineTable() (val any, err error) { p.pos++ 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 { return nil, err }