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

Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
2026-09-19 19:36:24 +02:00
parent b695b69768
commit a6e3e3fe31
12 changed files with 631 additions and 117 deletions
+106 -59
View File
@@ -20,12 +20,13 @@ import (
)
var (
localDateTimeType = reflect.TypeFor[LocalDateTime]()
localDateType = reflect.TypeFor[LocalDate]()
localTimeType = reflect.TypeFor[LocalTime]()
timeGoType = reflect.TypeFor[time.Time]()
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
localDateTimeType = reflect.TypeFor[LocalDateTime]()
localDateType = reflect.TypeFor[LocalDate]()
localTimeType = reflect.TypeFor[LocalTime]()
offsetDateTimeType = reflect.TypeFor[OffsetDateTime]()
timeGoType = reflect.TypeFor[time.Time]()
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
)
// inlineLimit is the column past which an inline table is written across
@@ -342,17 +343,15 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value")
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
if v.CanInterface() {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
if m, ok := marshalerOf(v); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
}
// A type that renders itself as text becomes a TOML string, whether it is
// a scalar kind or a struct.
@@ -431,12 +430,29 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, []any{}, ctx)
}
// Every element is resolved through MarshalTOML first, so an element that
// renders itself as a scalar, a table or a value array is classified by
// what it produces rather than by its Go kind, and its method runs once.
elems := make([]reflect.Value, n)
for i := range n {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev, err := resolveElement(v.Index(i), fmt.Sprintf("%s[%d]", joinKey(ctx, name), i))
if err != nil {
return err
}
elems[i] = ev
}
// An array keeps the [[header]] form only when every element is a table.
// TOML lets one array mix tables with scalars, and that mix renders as a
// value array with the table elements written inline.
allTables := true
for i := range n {
if !isTableElementValue(v.Index(i)) {
for _, ev := range elems {
if !ev.IsValid() || !isTableElementValue(ev) {
allTables = false
break
}
@@ -450,16 +466,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
if allTables {
subs := make([]*tomlDoc, n)
for i := range n {
for i, ev := range elems {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
switch ev.Kind() {
case reflect.Struct:
@@ -485,29 +497,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
// Value array. Table elements normalise to map[string]any and the emitter
// writes them as inline tables.
items := make([]any, n)
for i := range n {
for i, ev := range elems {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
if ev.CanInterface() {
if m, ok := ev.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
}
if mv == nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errNilMarshalTOML}
}
ev = reflect.ValueOf(mv)
ev = followPtr(ev)
}
}
val, err := normaliseValue(ev)
if err != nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
@@ -517,6 +512,50 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, items, ctx)
}
// marshalerOf finds the Marshaler a value carries: on the value itself, or on
// its address, so a pointer-receiver MarshalTOML is found on an addressable
// struct field or slice element, exactly as textMarshalerOf finds MarshalText.
func marshalerOf(v reflect.Value) (Marshaler, bool) {
if !v.CanInterface() {
return nil, false
}
if m, ok := v.Interface().(Marshaler); ok {
return m, true
}
if v.CanAddr() {
if m, ok := v.Addr().Interface().(Marshaler); ok {
return m, true
}
}
return nil, false
}
// resolveElement looks through pointers and runs MarshalTOML, so an array
// element is classified by what its method produces. path names the element,
// for the errors the method can raise.
func resolveElement(v reflect.Value, path string) (reflect.Value, error) {
ev := followPtr(v)
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
m, ok := marshalerOf(ev)
if !ok {
return ev, nil
}
mv, err := m.MarshalTOML()
if err != nil {
return reflect.Value{}, &EncodeError{Path: path, Err: err}
}
if mv == nil {
return reflect.Value{}, &EncodeError{Path: path, Err: errNilMarshalTOML}
}
ev = followPtr(reflect.ValueOf(mv))
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
return ev, nil
}
// normaliseValue converts a reflect.Value into one of the canonical scalar or
// nested-array representations the emitter understands. Slices and arrays are
// recursively normalised so that nested arrays (e.g. [][]int) work.
@@ -528,20 +567,25 @@ func normaliseValue(v reflect.Value) (any, error) {
if v.Kind() == reflect.Interface {
return nil, fmt.Errorf("cannot encode nil value")
}
if v.CanInterface() {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return nil, err
}
if mv == nil {
return nil, errNilMarshalTOML
}
return mv, nil
if m, ok := marshalerOf(v); ok {
mv, err := m.MarshalTOML()
if err != nil {
return nil, err
}
if mv == nil {
return nil, errNilMarshalTOML
}
// The result is normalised like any other value, so a method may return
// a duration, a defined type or another Marshaler. A result of the
// receiver's own type is written as it is, because recursing into it
// would never end.
if rv := reflect.ValueOf(mv); rv.Type() != v.Type() {
return normaliseValue(rv)
}
return mv, nil
}
// The datetime structs are TOML scalars; the emitter renders each of them.
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
if isScalarStruct(v.Type()) {
return v.Interface(), nil
}
// TOML has no duration type, so a duration goes out in its canonical Go
@@ -622,24 +666,24 @@ func followPtr(v reflect.Value) reflect.Value {
}
// isScalarStruct reports whether t is a struct type that the encoder treats
// as a TOML scalar (time.Time, LocalDateTime, LocalDate, LocalTime).
// as a TOML scalar: time.Time and the four date-time wrappers.
func isScalarStruct(t reflect.Type) bool {
return t == timeGoType || isLocalDateType(t)
return t == timeGoType || t == offsetDateTimeType || isLocalDateType(t)
}
func isLocalDateType(t reflect.Type) bool {
return t == localDateTimeType || t == localDateType || t == localTimeType
}
// isDateTimeType reports whether t is one of the four TOML date-time types,
// which the encoder emits as bare atoms. Pointers are looked through. The types
// carry time.Time's text methods through an embedded field, and the atom form
// takes precedence over them.
// isDateTimeType reports whether t is one of the date-time types, which the
// encoder emits as bare atoms. Pointers are looked through. The types carry
// time.Time's text methods through an embedded field, and the atom form takes
// precedence over them.
func isDateTimeType(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == timeGoType || isLocalDateType(t)
return isScalarStruct(t)
}
// isTextMarshalerType reports whether t or *t implements
@@ -945,6 +989,9 @@ func (e *encoder) writeValue(val any) error {
case time.Time:
e.buf.WriteString(offsetString(v))
return nil
case OffsetDateTime:
e.buf.WriteString(v.String())
return nil
case LocalDateTime:
e.buf.WriteString(v.String())
return nil