Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
+11
-1
@@ -9,7 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
-
|
||||
- `encoding.TextMarshaler` and `encoding.TextUnmarshaler` are honoured by
|
||||
default, with no option to switch them off. A type that implements them is
|
||||
encoded as a TOML string and decoded from one: `net.IP` becomes
|
||||
`"192.0.2.1"`, and a user type with `MarshalText` or `UnmarshalText` follows.
|
||||
`MarshalTOML` and `UnmarshalTOML` still win over the text methods, and the
|
||||
four date-time types keep their bare timestamp form instead of becoming a
|
||||
quoted string. A struct type that implements the interface now encodes as a
|
||||
string where it was a table before, which is the breaking part of the change.
|
||||
- `time.Duration` is encoded in its canonical Go form as a TOML string,
|
||||
`1h30m0s`, because TOML has no duration type; the decoder reads that string
|
||||
back and still accepts a bare integer as the nanosecond count.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ the entire official [toml-test](https://github.com/toml-lang/toml-test) suite:
|
||||
- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that
|
||||
match no destination field, at every struct depth.
|
||||
- **Custom types**: `Marshaler` and `Unmarshaler` let a type control its own
|
||||
TOML representation in both directions.
|
||||
TOML representation in both directions, and `encoding.TextMarshaler` and
|
||||
`TextUnmarshaler` are honoured by default, so `net.IP`, `time.Duration` and
|
||||
user types with text methods need no configuration.
|
||||
- **Cancellation**: every entry point has a `*Context` sibling that honours a
|
||||
`context.Context`.
|
||||
- **Configurable emission**: `Encoder` options for declaration-order output,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -63,6 +64,19 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||
}
|
||||
}
|
||||
|
||||
// A TOML string fills a destination that implements
|
||||
// encoding.TextUnmarshaler, the rule encoding/json follows. Every other
|
||||
// value kind keeps its own rule, so an integer still reaches a numeric
|
||||
// destination.
|
||||
if s, isString := data.(string); isString {
|
||||
if tu, ok := textUnmarshalerOf(dst); ok {
|
||||
if err := tu.UnmarshalText([]byte(s)); err != nil {
|
||||
return fmt.Errorf("unmarshal text: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
return d.assignTable(v, dst)
|
||||
@@ -71,6 +85,9 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||
case []any:
|
||||
return d.assignSlice(v, dst)
|
||||
case string:
|
||||
if dst.Type() == durationType {
|
||||
return setDuration(dst, v)
|
||||
}
|
||||
return setBasic(dst, reflect.ValueOf(v), "string")
|
||||
case bool:
|
||||
return setBasic(dst, reflect.ValueOf(v), "bool")
|
||||
@@ -94,6 +111,26 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||
}
|
||||
}
|
||||
|
||||
// textUnmarshalerOf finds the encoding.TextUnmarshaler for dst: on the value
|
||||
// itself, or on its address, so a pointer-receiver UnmarshalText is invoked on
|
||||
// an addressable struct field. The TOML date-time types are excluded, because
|
||||
// they carry time.Time's UnmarshalText through an embedded field while their
|
||||
// only accepted form is a bare timestamp.
|
||||
func textUnmarshalerOf(dst reflect.Value) (encoding.TextUnmarshaler, bool) {
|
||||
if !dst.CanInterface() || isDateTimeType(dst.Type()) {
|
||||
return nil, false
|
||||
}
|
||||
if u, ok := dst.Interface().(encoding.TextUnmarshaler); ok {
|
||||
return u, true
|
||||
}
|
||||
if dst.CanAddr() {
|
||||
if u, ok := dst.Addr().Interface().(encoding.TextUnmarshaler); ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
||||
switch dst.Kind() {
|
||||
case reflect.Struct:
|
||||
@@ -209,6 +246,19 @@ func setBasic(dst, val reflect.Value, kind string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDuration reads a duration literal into a time.Duration destination. TOML
|
||||
// has no duration type, so the encoder writes the canonical Go form and the
|
||||
// decoder reads that back; a bare integer stays the nanosecond count it has
|
||||
// always been, and reaches the destination through setInt.
|
||||
func setDuration(dst reflect.Value, s string) error {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interpres: invalid duration %q", s)
|
||||
}
|
||||
dst.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setInt(dst reflect.Value, v int64) error {
|
||||
switch dst.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
|
||||
+204
@@ -8,9 +8,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSyntaxErrorMessage(t *testing.T) {
|
||||
@@ -747,3 +749,205 @@ func TestUnmarshalIntoDefinedScalarTypes(t *testing.T) {
|
||||
t.Error("F = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// --- encoding.TextUnmarshaler and time.Duration ----------------------------
|
||||
|
||||
// textReceiver implements encoding.TextUnmarshaler on the pointer receiver.
|
||||
type textReceiver struct{ Text string }
|
||||
|
||||
func (t *textReceiver) UnmarshalText(text []byte) error {
|
||||
t.Text = "got:" + string(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// upperText is a defined string type whose UnmarshalText transforms the
|
||||
// content, so a plain string assignment would leave the wrong value behind.
|
||||
type upperText string
|
||||
|
||||
func (u *upperText) UnmarshalText(text []byte) error {
|
||||
*u = upperText(strings.ToUpper(string(text)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// failingTextUnmarshaler fails the decode from UnmarshalText.
|
||||
type failingTextUnmarshaler struct{}
|
||||
|
||||
func (f *failingTextUnmarshaler) UnmarshalText(_ []byte) error { return errors.New("text boom") }
|
||||
|
||||
// textAndTOMLReceiver implements both decode interfaces; the TOML method wins.
|
||||
type textAndTOMLReceiver struct{ From string }
|
||||
|
||||
func (t *textAndTOMLReceiver) UnmarshalTOML(any) error { t.From = "toml"; return nil }
|
||||
|
||||
func (t *textAndTOMLReceiver) UnmarshalText([]byte) error { t.From = "text"; return nil }
|
||||
|
||||
func TestTextUnmarshalerByPointer(t *testing.T) {
|
||||
type Cfg struct {
|
||||
R textReceiver `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`r = "hello"`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.R.Text != "got:hello" {
|
||||
t.Errorf("Text = %q, want \"got:hello\"", cfg.R.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextUnmarshalerWinsOverKindAssignment(t *testing.T) {
|
||||
type Cfg struct {
|
||||
U upperText `toml:"u"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`u = "abc"`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.U != "ABC" {
|
||||
t.Errorf("U = %q, want \"ABC\"", cfg.U)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextUnmarshalerForNetIP(t *testing.T) {
|
||||
type Cfg struct {
|
||||
V4 net.IP `toml:"v4"`
|
||||
V6 net.IP `toml:"v6"`
|
||||
IPs []net.IP `toml:"ips"`
|
||||
}
|
||||
in := "v4 = \"192.0.2.1\"\nv6 = \"2001:db8::68\"\nips = [\"198.51.100.7\", \"203.0.113.9\"]\n"
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(in), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got := cfg.V4.String(); got != "192.0.2.1" {
|
||||
t.Errorf("V4 = %q, want \"192.0.2.1\"", got)
|
||||
}
|
||||
if got := cfg.V6.String(); got != "2001:db8::68" {
|
||||
t.Errorf("V6 = %q, want \"2001:db8::68\"", got)
|
||||
}
|
||||
if len(cfg.IPs) != 2 || cfg.IPs[0].String() != "198.51.100.7" || cfg.IPs[1].String() != "203.0.113.9" {
|
||||
t.Errorf("IPs = %v, want two addresses", cfg.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextUnmarshalerSeesStringsOnly(t *testing.T) {
|
||||
// An integer keeps its own rule: the text method is not consulted, and the
|
||||
// value does not reach the receiver.
|
||||
type Cfg struct {
|
||||
R textReceiver `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
err := Unmarshal([]byte("r = 1\n"), &cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected an integer to be rejected for a text receiver")
|
||||
}
|
||||
if cfg.R.Text != "" {
|
||||
t.Errorf("Text = %q, want it untouched", cfg.R.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalTOMLWinsOverTextUnmarshaler(t *testing.T) {
|
||||
type Cfg struct {
|
||||
B textAndTOMLReceiver `toml:"b"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`b = "x"`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.B.From != "toml" {
|
||||
t.Errorf("From = %q, want \"toml\"", cfg.B.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextUnmarshalerErrorCarriesPath(t *testing.T) {
|
||||
type Inner struct {
|
||||
F failingTextUnmarshaler `toml:"f"`
|
||||
}
|
||||
type Cfg struct {
|
||||
Inner Inner `toml:"inner"`
|
||||
}
|
||||
var cfg Cfg
|
||||
err := Unmarshal([]byte("[inner]\nf = \"x\"\n"), &cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from UnmarshalText")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unmarshal text: text boom") {
|
||||
t.Errorf("err = %v, want the text error wrapped", err)
|
||||
}
|
||||
de, ok := errors.AsType[*DecodeError](err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||
}
|
||||
if !slices.Equal(de.Path, []string{"inner", "f"}) {
|
||||
t.Fatalf("Path = %v, want [inner f]", de.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextUnmarshalerReportsBadText(t *testing.T) {
|
||||
var cfg struct {
|
||||
IP net.IP `toml:"ip"`
|
||||
}
|
||||
err := Unmarshal([]byte(`ip = "not-an-ip"`), &cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a malformed address")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unmarshal text:") {
|
||||
t.Errorf("err = %v, want it wrapped as a text error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalDurations(t *testing.T) {
|
||||
type Cfg struct {
|
||||
FromText time.Duration `toml:"from_text"`
|
||||
FromInt time.Duration `toml:"from_int"`
|
||||
Fraction time.Duration `toml:"fraction"`
|
||||
}
|
||||
in := "from_text = \"1h30m\"\nfrom_int = 5400000000000\nfraction = \"1.5s\"\n"
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(in), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.FromText != 90*time.Minute {
|
||||
t.Errorf("FromText = %v, want %v", cfg.FromText, 90*time.Minute)
|
||||
}
|
||||
if cfg.FromInt != 90*time.Minute {
|
||||
t.Errorf("FromInt = %v, want %v", cfg.FromInt, 90*time.Minute)
|
||||
}
|
||||
if cfg.Fraction != 1500*time.Millisecond {
|
||||
t.Errorf("Fraction = %v, want %v", cfg.Fraction, 1500*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalDurationRejectsMalformedText(t *testing.T) {
|
||||
var cfg struct {
|
||||
D time.Duration `toml:"d"`
|
||||
}
|
||||
err := Unmarshal([]byte("d = \"90\"\n"), &cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a duration without a unit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid duration") {
|
||||
t.Errorf("err = %v, want an invalid-duration message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotedStringNeverBecomesDateTime(t *testing.T) {
|
||||
// The date-time types take a bare timestamp only, so the text path is
|
||||
// excluded for them and a quoted string stays a string.
|
||||
var stamp struct {
|
||||
S time.Time `toml:"s"`
|
||||
}
|
||||
err := Unmarshal([]byte("s = \"2026-06-26T10:00:00Z\"\n"), &stamp)
|
||||
if err == nil {
|
||||
t.Fatal("expected a quoted string to be rejected for time.Time")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot assign string") {
|
||||
t.Errorf("err = %v, want a cannot-assign message", err)
|
||||
}
|
||||
|
||||
var day struct {
|
||||
D LocalDate `toml:"d"`
|
||||
}
|
||||
if err := Unmarshal([]byte("d = \"1979-05-27\"\n"), &day); err == nil {
|
||||
t.Fatal("expected a quoted string to be rejected for LocalDate")
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -147,6 +147,9 @@ 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
|
||||
canonical rendering writes full seconds. 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 document
|
||||
that writes a date-time with quotes does not decode into them, and neither
|
||||
`encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
|
||||
|
||||
### Arrays of tables
|
||||
|
||||
@@ -177,6 +180,38 @@ automatically, and a nil pointer destination is allocated first. An error
|
||||
returned from `UnmarshalTOML` halts the decode and propagates wrapped with the
|
||||
key path, for example `addr: unmarshal: not a string`.
|
||||
|
||||
### Custom decoding: `encoding.TextUnmarshaler`
|
||||
|
||||
A destination type that implements `encoding.TextUnmarshaler` receives a TOML
|
||||
string as its text content, the rule `encoding/json` follows:
|
||||
|
||||
```go
|
||||
func (ip *IP) UnmarshalText(text []byte) error
|
||||
```
|
||||
|
||||
The decoder looks for the method on the destination and on its address, so a
|
||||
pointer-receiver `UnmarshalText` is invoked on an addressable struct field, and
|
||||
the elements of a slice destination are reached the same way. The text path
|
||||
applies to TOML strings only: every other value kind keeps its own rule, so
|
||||
`r = 1` does not reach a receiver that expects text. An error from
|
||||
`UnmarshalText` halts the decode and propagates with the key path and the
|
||||
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.
|
||||
|
||||
### Durations
|
||||
|
||||
TOML has no duration type, so `time.Duration` has a rule of its own. The
|
||||
encoder writes the canonical Go form in a TOML string, `1h30m0s`, and the
|
||||
decoder reads that string back with `time.ParseDuration`. A bare integer is
|
||||
still the nanosecond count it has always been, so `from_int = 5400000000000`
|
||||
and `from_text = "1h30m"` decode to the same duration. Text that
|
||||
`time.ParseDuration` rejects, `d = "90"` among it, fails with
|
||||
`interpres: invalid duration "90"`.
|
||||
|
||||
### Strict decoding
|
||||
|
||||
By default unknown keys are dropped silently. A `Decoder` built with
|
||||
@@ -329,6 +364,27 @@ func (p Port) MarshalTOML() (any, error) {
|
||||
}
|
||||
```
|
||||
|
||||
### Custom encoding: `encoding.TextMarshaler`
|
||||
|
||||
A type that implements `encoding.TextMarshaler` is encoded as a TOML string
|
||||
holding the text the method returns, which is the rule `encoding/json` follows:
|
||||
|
||||
```go
|
||||
func (ip IP) MarshalText() ([]byte, error)
|
||||
```
|
||||
|
||||
The encoder looks for the method on the value and on its address, so a
|
||||
pointer-receiver `MarshalText` is found on a struct field of an addressable
|
||||
value (pass a pointer to `Marshal`) and always on a slice element. `net.IP`,
|
||||
`netip.Addr` and user types follow this rule, and a struct that implements the
|
||||
interface becomes a string rather than a table. `MarshalTOML` wins when a type
|
||||
implements both, the four [date-time types](#date-time-values) keep their bare
|
||||
timestamp form, and text that is not valid UTF-8 is an error rather than a
|
||||
replacement character.
|
||||
|
||||
A duration carries no text method of its own; see [Durations](#durations) for
|
||||
its rule.
|
||||
|
||||
### Arrays
|
||||
|
||||
An array whose every element is a table (`[]struct`, `[]map[string]V`, after
|
||||
|
||||
@@ -6,6 +6,7 @@ package interpres
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -23,6 +24,8 @@ var (
|
||||
localDateType = reflect.TypeFor[LocalDate]()
|
||||
localTimeType = reflect.TypeFor[LocalTime]()
|
||||
timeGoType = reflect.TypeFor[time.Time]()
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||
)
|
||||
|
||||
// encoder produces a TOML document from a Go value via a small intermediate
|
||||
@@ -319,6 +322,15 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
v = reflect.ValueOf(mv)
|
||||
}
|
||||
}
|
||||
// A type that renders itself as text becomes a TOML string, whether it is
|
||||
// a scalar kind or a struct.
|
||||
s, isText, err := textValue(v)
|
||||
if err != nil {
|
||||
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||
}
|
||||
if isText {
|
||||
return doc.appendScalar(name, s, ctx)
|
||||
}
|
||||
v = followPtr(v)
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
@@ -500,6 +512,20 @@ func normaliseValue(v reflect.Value) (any, error) {
|
||||
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
|
||||
return v.Interface(), nil
|
||||
}
|
||||
// TOML has no duration type, so a duration goes out in its canonical Go
|
||||
// form, the shape it comes back in.
|
||||
if v.Type() == durationType {
|
||||
return time.Duration(v.Int()).String(), nil
|
||||
}
|
||||
// A type that renders itself as text becomes a TOML string, scalar kinds
|
||||
// and structs alike.
|
||||
s, isText, err := textValue(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isText {
|
||||
return s, nil
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String(), nil
|
||||
@@ -573,10 +599,75 @@ 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.
|
||||
func isDateTimeType(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t == timeGoType || isLocalDateType(t)
|
||||
}
|
||||
|
||||
// isTextMarshalerType reports whether t or *t implements
|
||||
// encoding.TextMarshaler. An array of such values stays a value array, because
|
||||
// each element's TOML form is a string.
|
||||
func isTextMarshalerType(t reflect.Type) bool {
|
||||
if isDateTimeType(t) {
|
||||
return false
|
||||
}
|
||||
return t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType)
|
||||
}
|
||||
|
||||
// textValue returns the string a value renders itself as through
|
||||
// encoding.TextMarshaler. The date-time types are excluded, because their
|
||||
// embedded time.Time would answer with an RFC 3339 string where the TOML form
|
||||
// is a bare timestamp. A nil pointer offers no text and is left to the ordinary
|
||||
// nil handling, which omits the field.
|
||||
func textValue(v reflect.Value) (string, bool, error) {
|
||||
for v.Kind() == reflect.Interface && !v.IsNil() {
|
||||
v = v.Elem()
|
||||
}
|
||||
if !v.IsValid() || isDateTimeType(v.Type()) {
|
||||
return "", false, nil
|
||||
}
|
||||
if v.Kind() == reflect.Pointer && v.IsNil() {
|
||||
return "", false, nil
|
||||
}
|
||||
m, ok := textMarshalerOf(v)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
b, err := m.MarshalText()
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return string(b), true, nil
|
||||
}
|
||||
|
||||
// textMarshalerOf finds the encoding.TextMarshaler for v: on the value itself,
|
||||
// or on its address, so a pointer-receiver MarshalText is found on an
|
||||
// addressable struct field.
|
||||
func textMarshalerOf(v reflect.Value) (encoding.TextMarshaler, bool) {
|
||||
if !v.CanInterface() {
|
||||
return nil, false
|
||||
}
|
||||
if m, ok := v.Interface().(encoding.TextMarshaler); ok {
|
||||
return m, true
|
||||
}
|
||||
if v.CanAddr() {
|
||||
if m, ok := v.Addr().Interface().(encoding.TextMarshaler); ok {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func isTableElementType(t reflect.Type) bool {
|
||||
switch t.Kind() {
|
||||
case reflect.Struct:
|
||||
return !isScalarStruct(t)
|
||||
return !isScalarStruct(t) && !isTextMarshalerType(t)
|
||||
case reflect.Map:
|
||||
return t.Key().Kind() == reflect.String
|
||||
}
|
||||
|
||||
+200
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1372,3 +1373,202 @@ func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
|
||||
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]")
|
||||
}
|
||||
}
|
||||
|
||||
// --- encoding.TextMarshaler and time.Duration ------------------------------
|
||||
|
||||
// textTag is a value-receiver encoding.TextMarshaler, so the encoder finds the
|
||||
// method on the value itself.
|
||||
type textTag string
|
||||
|
||||
func (t textTag) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil }
|
||||
|
||||
// textPointer carries MarshalText on the pointer receiver only, so the encoder
|
||||
// has to look at the address of an addressable field.
|
||||
type textPointer struct{ V string }
|
||||
|
||||
func (t *textPointer) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(t.V)), nil }
|
||||
|
||||
// textAndTOML implements both encoding interfaces; the TOML method wins.
|
||||
type textAndTOML struct{}
|
||||
|
||||
func (textAndTOML) MarshalTOML() (any, error) { return "toml", nil }
|
||||
func (textAndTOML) MarshalText() ([]byte, error) { return []byte("text"), nil }
|
||||
|
||||
// brokenText fails the marshal from MarshalText.
|
||||
type brokenText struct{}
|
||||
|
||||
func (brokenText) MarshalText() ([]byte, error) { return nil, errors.New("text boom") }
|
||||
|
||||
// notUTF8 renders bytes that no TOML string can carry.
|
||||
type notUTF8 struct{}
|
||||
|
||||
func (notUTF8) MarshalText() ([]byte, error) { return []byte{0xff, 0xfe}, nil }
|
||||
|
||||
// textTagBoth renders itself with a prefix and strips it again on decode, so
|
||||
// the round trip through a TOML string is lossless.
|
||||
type textTagBoth string
|
||||
|
||||
func (t textTagBoth) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil }
|
||||
|
||||
func (t *textTagBoth) UnmarshalText(text []byte) error {
|
||||
trimmed, ok := strings.CutPrefix(string(text), "tag:")
|
||||
if !ok {
|
||||
return errors.New("textTagBoth: missing the tag prefix")
|
||||
}
|
||||
*t = textTagBoth(trimmed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMarshalTextValues(t *testing.T) {
|
||||
// The pointer receiver is reachable only through an addressable field, so
|
||||
// the whole value is marshalled through a pointer here.
|
||||
type Cfg struct {
|
||||
IP net.IP `toml:"ip"`
|
||||
Duration time.Duration `toml:"duration"`
|
||||
Tag textTag `toml:"tag"`
|
||||
Pointer textPointer `toml:"pointer"`
|
||||
Both textAndTOML `toml:"both"`
|
||||
}
|
||||
out, err := Marshal(&Cfg{
|
||||
IP: net.IPv4(192, 0, 2, 1),
|
||||
Duration: 90 * time.Minute,
|
||||
Tag: "x",
|
||||
Pointer: textPointer{V: "abc"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
want := "ip = \"192.0.2.1\"\nduration = \"1h30m0s\"\ntag = \"tag:x\"\npointer = \"ABC\"\nboth = \"toml\"\n"
|
||||
if string(out) != want {
|
||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextValuesInContainers(t *testing.T) {
|
||||
// Slice elements are addressable, so a pointer-receiver MarshalText is used
|
||||
// there too, and an array of such values stays a value array: each element's
|
||||
// TOML form is a string, so the [[header]] form cannot carry it.
|
||||
type Cfg struct {
|
||||
Map map[string]net.IP `toml:"map"`
|
||||
Durs []time.Duration `toml:"durs"`
|
||||
Ptrs []textPointer `toml:"ptrs"`
|
||||
Empty []textPointer `toml:"empty"`
|
||||
}
|
||||
out, err := Marshal(Cfg{
|
||||
Map: map[string]net.IP{"a": net.IPv4(10, 0, 0, 1)},
|
||||
Durs: []time.Duration{0, 250 * time.Millisecond},
|
||||
Ptrs: []textPointer{{V: "a"}, {V: "b"}},
|
||||
Empty: []textPointer{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
want := "durs = [\"0s\", \"250ms\"]\nptrs = [\"A\", \"B\"]\nempty = []\n\n[map]\na = \"10.0.0.1\"\n"
|
||||
if string(out) != want {
|
||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextLeavesDateTimesAlone(t *testing.T) {
|
||||
// The four date-time types carry time.Time's text methods through an
|
||||
// embedded field; their TOML form is a bare atom, never a quoted string.
|
||||
stamp := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
|
||||
type Cfg struct {
|
||||
Stamp time.Time `toml:"stamp"`
|
||||
Ptr *time.Time `toml:"ptr"`
|
||||
Day LocalDate `toml:"day"`
|
||||
At LocalDateTime `toml:"at"`
|
||||
Clock LocalTime `toml:"clock"`
|
||||
}
|
||||
out, err := Marshal(&Cfg{
|
||||
Stamp: stamp,
|
||||
Ptr: &stamp,
|
||||
Day: LocalDate{time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)},
|
||||
At: LocalDateTime{time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)},
|
||||
Clock: LocalTime{time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
want := "stamp = 2026-06-26T10:00:00Z\nptr = 2026-06-26T10:00:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32:00\nclock = 07:32:00\n"
|
||||
if string(out) != want {
|
||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextNilPointerOmitted(t *testing.T) {
|
||||
type Cfg struct {
|
||||
P *textPointer `toml:"p"`
|
||||
K string `toml:"k"`
|
||||
}
|
||||
out, err := Marshal(&Cfg{K: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if want := "k = \"x\"\n"; string(out) != want {
|
||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextErrorCarriesPath(t *testing.T) {
|
||||
type Inner struct {
|
||||
F brokenText `toml:"f"`
|
||||
}
|
||||
type Cfg struct {
|
||||
Inner Inner `toml:"inner"`
|
||||
}
|
||||
_, err := Marshal(Cfg{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from MarshalText")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "text boom") {
|
||||
t.Errorf("err = %v, want substring \"text boom\"", err)
|
||||
}
|
||||
ee, ok := errors.AsType[*EncodeError](err)
|
||||
if !ok {
|
||||
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||
}
|
||||
if ee.Path != "inner.f" {
|
||||
t.Fatalf("Path = %q, want %q", ee.Path, "inner.f")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextRejectsInvalidUTF8(t *testing.T) {
|
||||
// A TOML string holds UTF-8 only, so text that is not gets an error rather
|
||||
// than replacement characters.
|
||||
_, err := Marshal(struct {
|
||||
V notUTF8 `toml:"v"`
|
||||
}{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for text that is not valid UTF-8")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Errorf("err = %v, want a UTF-8 message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalTextValuesRoundTrip(t *testing.T) {
|
||||
type Cfg struct {
|
||||
Duration time.Duration `toml:"duration"`
|
||||
IP net.IP `toml:"ip"`
|
||||
Tag textTagBoth `toml:"tag"`
|
||||
}
|
||||
in := Cfg{Duration: 90 * time.Minute, IP: net.IPv4(198, 51, 100, 7), Tag: "y"}
|
||||
out, err := Marshal(&in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var back Cfg
|
||||
if err := Unmarshal(out, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if back.Duration != in.Duration {
|
||||
t.Errorf("Duration = %v, want %v", back.Duration, in.Duration)
|
||||
}
|
||||
if !back.IP.Equal(in.IP) {
|
||||
t.Errorf("IP = %v, want %v", back.IP, in.IP)
|
||||
}
|
||||
if back.Tag != in.Tag {
|
||||
t.Errorf("Tag = %q, want %q", back.Tag, in.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
|
||||
// case-insensitive match on the field name when no tag is present. A tag of
|
||||
// "-" skips the field.
|
||||
//
|
||||
// A destination implementing Unmarshaler receives the parsed value as it is,
|
||||
// a TOML string fills a destination implementing encoding.TextUnmarshaler, and
|
||||
// a time.Duration destination takes a duration literal such as `1h30m` or a
|
||||
// bare integer as its nanosecond count.
|
||||
//
|
||||
// Unmarshal is equivalent to UnmarshalContext with context.Background.
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
return UnmarshalContext(context.Background(), data, v)
|
||||
@@ -177,6 +182,10 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
||||
// then encodes as if the returned value had been passed in its place, which
|
||||
// is useful for emitting a Go type as a different TOML shape (for example, a
|
||||
// struct as an inline table or a primitive alias as a richer value).
|
||||
//
|
||||
// MarshalTOML wins over encoding.TextMarshaler when a type implements both.
|
||||
// A type that implements only encoding.TextMarshaler is encoded as a TOML
|
||||
// string holding its text, and needs no method here.
|
||||
type Marshaler interface {
|
||||
MarshalTOML() (any, error)
|
||||
}
|
||||
@@ -194,6 +203,10 @@ type Marshaler interface {
|
||||
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
|
||||
// destination type implements the interface. The decoder does not need to
|
||||
// consult the concrete return value; whatever the receiver stores is kept.
|
||||
//
|
||||
// UnmarshalTOML wins over encoding.TextUnmarshaler when a type implements
|
||||
// both. A type that implements only encoding.TextUnmarshaler is filled from a
|
||||
// TOML string holding its text, and needs no method here.
|
||||
type Unmarshaler interface {
|
||||
UnmarshalTOML(data any) error
|
||||
}
|
||||
@@ -222,6 +235,9 @@ type Unmarshaler interface {
|
||||
// variants).
|
||||
// - Values implementing Marshaler are encoded by calling MarshalTOML and
|
||||
// using its result.
|
||||
// - Values implementing encoding.TextMarshaler, and not one of the
|
||||
// date-time types, encode as a TOML string holding the text the method
|
||||
// returns. time.Duration is written in its canonical Go form, `1h30m0s`.
|
||||
// - nil pointer fields are omitted.
|
||||
//
|
||||
// Marshal cannot encode cyclic data structures; passing one will loop until
|
||||
|
||||
Reference in New Issue
Block a user