feat: add field comments, local time zone decoding and in-value cancellation
Test / test (push) Successful in 1m32s
Test / test (push) Successful in 1m32s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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().
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user