2026-08-19 09:47:00 +02:00
|
|
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
|
|
package interpres
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
2026-09-19 02:41:09 +02:00
|
|
|
"encoding"
|
2026-09-17 21:20:47 +02:00
|
|
|
"errors"
|
2026-08-19 09:47:00 +02:00
|
|
|
"fmt"
|
2026-09-17 19:49:50 +02:00
|
|
|
"maps"
|
2026-08-19 09:47:00 +02:00
|
|
|
"math"
|
|
|
|
|
"reflect"
|
|
|
|
|
"slices"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
"unicode/utf8"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
2026-09-19 19:36:24 +02:00
|
|
|
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]()
|
2026-08-19 09:47:00 +02:00
|
|
|
)
|
|
|
|
|
|
2026-09-19 12:18:18 +02:00
|
|
|
// inlineLimit is the column past which an inline table is written across
|
|
|
|
|
// lines. TOML 1.1 lets an inline table carry newlines and a trailing comma, so
|
|
|
|
|
// a long one stays readable instead of running off the line.
|
|
|
|
|
const inlineLimit = 100
|
|
|
|
|
|
|
|
|
|
// noInlineBreak is the limit a measuring encoder carries, high enough that the
|
|
|
|
|
// form it renders is always the single-line one.
|
|
|
|
|
const noInlineBreak = 1 << 30
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
// encoder produces a TOML document from a Go value via a small intermediate
|
|
|
|
|
// representation that preserves the order in which fields were declared.
|
|
|
|
|
type encoder struct {
|
|
|
|
|
buf bytes.Buffer
|
|
|
|
|
ctx context.Context
|
|
|
|
|
opts Encoder
|
2026-09-19 12:18:18 +02:00
|
|
|
|
|
|
|
|
// inlineDepth is the nesting level inside inline tables, which decides
|
|
|
|
|
// their indentation.
|
|
|
|
|
inlineDepth int
|
|
|
|
|
|
|
|
|
|
// limit is the column at which an inline table is broken; only a
|
|
|
|
|
// measuring encoder raises it.
|
|
|
|
|
limit int
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 12:18:18 +02:00
|
|
|
func newEncoder() *encoder { return &encoder{limit: inlineLimit} }
|
|
|
|
|
|
|
|
|
|
// flat returns an encoder that measures a value by rendering it on one line,
|
|
|
|
|
// so a caller can decide which form to write before writing it.
|
|
|
|
|
func (e *encoder) flat() *encoder {
|
|
|
|
|
return &encoder{ctx: e.ctx, opts: e.opts, limit: noInlineBreak}
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
|
|
|
|
|
func (e *encoder) bytes() []byte { return e.buf.Bytes() }
|
|
|
|
|
|
2026-09-19 12:18:18 +02:00
|
|
|
// column reports how many bytes the current line already holds, so a form can
|
|
|
|
|
// be measured against the limit before it is written.
|
|
|
|
|
func (e *encoder) column() int {
|
|
|
|
|
if i := bytes.LastIndexByte(e.buf.Bytes(), '\n'); i >= 0 {
|
|
|
|
|
return e.buf.Len() - i - 1
|
|
|
|
|
}
|
|
|
|
|
return e.buf.Len()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
func (e *encoder) checkCtx() error {
|
|
|
|
|
if e.ctx == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return e.ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// encode converts v into a TOML document. v must be a struct or a
|
|
|
|
|
// map[string]V (or a non-nil pointer to one).
|
|
|
|
|
func (e *encoder) encode(v any) error {
|
|
|
|
|
if err := e.checkCtx(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-09-20 10:40:57 +02:00
|
|
|
switch x := v.(type) {
|
|
|
|
|
case *Document:
|
|
|
|
|
if x == nil {
|
|
|
|
|
return fmt.Errorf("interpres: cannot marshal nil value")
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("interpres: cannot marshal a Document; marshal its Map() to write the values")
|
|
|
|
|
case Document:
|
|
|
|
|
return fmt.Errorf("interpres: cannot marshal a Document; marshal its Map() to write the values")
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
rv := reflect.ValueOf(v)
|
|
|
|
|
if !rv.IsValid() {
|
|
|
|
|
return fmt.Errorf("interpres: cannot marshal nil value")
|
|
|
|
|
}
|
|
|
|
|
if rv.Kind() == reflect.Pointer {
|
|
|
|
|
if rv.IsNil() {
|
|
|
|
|
return fmt.Errorf("interpres: cannot marshal nil pointer")
|
|
|
|
|
}
|
|
|
|
|
rv = rv.Elem()
|
|
|
|
|
}
|
|
|
|
|
doc := &tomlDoc{ctx: e.ctx, opts: e.opts}
|
|
|
|
|
switch rv.Kind() {
|
|
|
|
|
case reflect.Struct:
|
|
|
|
|
if err := buildStructDoc(rv, doc, ""); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case reflect.Map:
|
|
|
|
|
if err := buildMapDoc(rv, doc, ""); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("interpres: top-level value must be a struct or map[string]V, got %s", rv.Type())
|
|
|
|
|
}
|
|
|
|
|
return e.emitDoc(doc, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- intermediate representation -----------------------------------------
|
|
|
|
|
|
|
|
|
|
// entryKind discriminates the three forms an entry in a tomlDoc may take.
|
|
|
|
|
type entryKind int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
entryScalar entryKind = iota
|
|
|
|
|
entryTable
|
|
|
|
|
entryArray
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// entry is one binding in a tomlDoc. entries live in a single slice in the
|
|
|
|
|
// order they were added; emission either walks that order directly
|
|
|
|
|
// (Encoder with GroupByKind(false)) or partitions by kind first
|
|
|
|
|
// (Encoder with GroupByKind(true), the default).
|
|
|
|
|
type entry struct {
|
|
|
|
|
kind entryKind
|
|
|
|
|
key string
|
|
|
|
|
val any // entryScalar
|
|
|
|
|
doc *tomlDoc // entryTable
|
|
|
|
|
docs []*tomlDoc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tomlDoc holds the entries of one TOML table in declaration order.
|
|
|
|
|
type tomlDoc struct {
|
|
|
|
|
entries []entry
|
|
|
|
|
ctx context.Context // inherited from encoder; nil-safe
|
|
|
|
|
opts Encoder // inherited from encoder; options drive emit-time behaviour
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *tomlDoc) checkCtx() error {
|
|
|
|
|
if d.ctx == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return d.ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *tomlDoc) addScalar(key string, val any) {
|
|
|
|
|
d.entries = append(d.entries, entry{kind: entryScalar, key: key, val: val})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *tomlDoc) addTable(key string, sub *tomlDoc) {
|
|
|
|
|
d.entries = append(d.entries, entry{kind: entryTable, key: key, doc: sub})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *tomlDoc) addArray(key string, subs []*tomlDoc) {
|
|
|
|
|
d.entries = append(d.entries, entry{kind: entryArray, key: key, docs: subs})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// partitionedEntries returns the entries grouped by kind, preserving each
|
|
|
|
|
// group's relative order. The only allocation is the three slice headers.
|
|
|
|
|
func (d *tomlDoc) partitionedEntries() (scalars []entry, tables []entry, arrays []entry) {
|
|
|
|
|
for _, e := range d.entries {
|
|
|
|
|
switch e.kind {
|
|
|
|
|
case entryScalar:
|
|
|
|
|
scalars = append(scalars, e)
|
|
|
|
|
case entryTable:
|
|
|
|
|
tables = append(tables, e)
|
|
|
|
|
case entryArray:
|
|
|
|
|
arrays = append(arrays, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- reflection walk: struct ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
2026-09-17 23:08:42 +02:00
|
|
|
return walkStructDoc(v, doc, ctx, nil, cachedStructSchema(v.Type()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// walkStructDoc emits the fields of v into doc. prefix is v's index path from
|
|
|
|
|
// the struct whose schema resolves key conflicts; an embedded struct is walked
|
|
|
|
|
// with the outer schema and a longer prefix, so every leaf competes under the
|
|
|
|
|
// decoder's rule: the shallower field wins, the later declaration at equal
|
|
|
|
|
// depth. A field another field shadows is skipped, because emitting both
|
|
|
|
|
// would duplicate the key and the output would not re-parse.
|
|
|
|
|
func walkStructDoc(v reflect.Value, doc *tomlDoc, ctx string, prefix []int, schema structSchema) error {
|
2026-08-19 09:47:00 +02:00
|
|
|
t := v.Type()
|
|
|
|
|
for i := range t.NumField() {
|
|
|
|
|
if i%ctxCheckInterval == 0 {
|
|
|
|
|
if err := doc.checkCtx(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
f := t.Field(i)
|
|
|
|
|
if f.PkgPath != "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-09-17 23:08:42 +02:00
|
|
|
path := append(append([]int{}, prefix...), i)
|
2026-08-19 09:47:00 +02:00
|
|
|
if f.Anonymous {
|
|
|
|
|
tag, _ := f.Tag.Lookup("toml")
|
|
|
|
|
if tag == "-" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if tag == "" {
|
|
|
|
|
fv := followPtr(v.Field(i))
|
|
|
|
|
if !fv.IsValid() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
switch fv.Kind() {
|
|
|
|
|
case reflect.Struct:
|
|
|
|
|
if isScalarStruct(fv.Type()) {
|
|
|
|
|
name := strings.ToLower(f.Name)
|
2026-09-17 23:08:42 +02:00
|
|
|
if !schema.ownsKey(name, path) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-09-17 23:08:42 +02:00
|
|
|
if err := walkStructDoc(fv, doc, ctx, path, schema); err != nil {
|
2026-08-19 09:47:00 +02:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
case reflect.Map:
|
|
|
|
|
if err := buildMapDoc(fv, doc, ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
name := fieldName(f)
|
|
|
|
|
if name == "-" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-09-17 23:08:42 +02:00
|
|
|
if !schema.ownsKey(strings.ToLower(name), path) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-09-17 19:57:23 +02:00
|
|
|
if fieldOmitted(f, v.Field(i)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 19:57:23 +02:00
|
|
|
// isZeroer mirrors encoding/json's omitzero: a type that knows its own zero
|
|
|
|
|
// state decides through that method before reflection is consulted.
|
|
|
|
|
type isZeroer interface{ IsZero() bool }
|
|
|
|
|
|
|
|
|
|
// 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 collection (slice, array, or map). The decoder ignores both
|
|
|
|
|
// options; they shape emission only.
|
|
|
|
|
func fieldOmitted(f reflect.StructField, v reflect.Value) bool {
|
|
|
|
|
tag, ok := f.Tag.Lookup("toml")
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
_, opts, _ := strings.Cut(tag, ",")
|
|
|
|
|
for opts != "" {
|
|
|
|
|
var opt string
|
|
|
|
|
opt, opts, _ = strings.Cut(opts, ",")
|
|
|
|
|
switch opt {
|
|
|
|
|
case "omitzero":
|
|
|
|
|
if isZeroValue(v) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
case "omitempty":
|
|
|
|
|
switch v.Kind() {
|
|
|
|
|
case reflect.Slice, reflect.Array, reflect.Map:
|
|
|
|
|
if v.Len() == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isZeroValue(v reflect.Value) bool {
|
|
|
|
|
if v.CanInterface() {
|
|
|
|
|
if z, ok := v.Interface().(isZeroer); ok {
|
|
|
|
|
return z.IsZero()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return v.IsZero()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
|
|
|
|
// tag (name or `-`) and falling back to a lower-cased field name.
|
|
|
|
|
func fieldName(f reflect.StructField) string {
|
|
|
|
|
if tag, ok := f.Tag.Lookup("toml"); ok {
|
|
|
|
|
name, _, _ := strings.Cut(tag, ",")
|
|
|
|
|
if name == "-" {
|
|
|
|
|
return "-"
|
|
|
|
|
}
|
|
|
|
|
if name != "" {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.ToLower(f.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- reflection walk: map ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|
|
|
|
if v.Type().Key().Kind() != reflect.String {
|
|
|
|
|
return fmt.Errorf("interpres: map key must be string, got %s", v.Type().Key())
|
|
|
|
|
}
|
|
|
|
|
keys := v.MapKeys()
|
|
|
|
|
slices.SortFunc(keys, func(a, b reflect.Value) int {
|
|
|
|
|
return strings.Compare(a.String(), b.String())
|
|
|
|
|
})
|
|
|
|
|
for i, k := range keys {
|
|
|
|
|
if i%ctxCheckInterval == 0 {
|
|
|
|
|
if err := doc.checkCtx(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := addField(doc, k.String(), v.MapIndex(k), ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- reflection walk: field dispatch -------------------------------------
|
|
|
|
|
|
2026-09-17 23:11:10 +02:00
|
|
|
// errNilMarshalTOML reports a Marshaler whose method returned a nil value
|
|
|
|
|
// with no error. nil has no TOML representation, so dropping the field
|
|
|
|
|
// silently or panicking on the invalid reflect.Value would both hide the
|
|
|
|
|
// contract violation.
|
|
|
|
|
var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value")
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
2026-09-19 19:36:24 +02:00
|
|
|
if m, ok := marshalerOf(v); ok {
|
|
|
|
|
mv, err := m.MarshalTOML()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-19 19:36:24 +02:00
|
|
|
if mv == nil {
|
|
|
|
|
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
|
|
|
|
|
}
|
|
|
|
|
v = reflect.ValueOf(mv)
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-19 02:41:09 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
v = followPtr(v)
|
|
|
|
|
if !v.IsValid() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if v.Kind() == reflect.Interface {
|
|
|
|
|
if v.IsNil() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
v = v.Elem()
|
|
|
|
|
}
|
|
|
|
|
switch v.Kind() {
|
|
|
|
|
case reflect.Struct:
|
|
|
|
|
if isScalarStruct(v.Type()) {
|
|
|
|
|
return doc.appendScalar(name, v.Interface(), ctx)
|
|
|
|
|
}
|
|
|
|
|
return addSubTable(doc, name, v, ctx)
|
|
|
|
|
case reflect.Map:
|
|
|
|
|
return addSubTable(doc, name, v, ctx)
|
|
|
|
|
case reflect.Slice, reflect.Array:
|
|
|
|
|
return addArrayValue(doc, name, v, ctx)
|
|
|
|
|
default:
|
|
|
|
|
val, err := normaliseValue(v)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
|
|
|
|
}
|
|
|
|
|
return doc.appendScalar(name, val, ctx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// appendScalar wraps addScalar with a uniform error path.
|
|
|
|
|
func (d *tomlDoc) appendScalar(name string, val any, ctx string) error {
|
|
|
|
|
d.addScalar(name, val)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func addSubTable(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
|
|
|
|
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
|
|
|
|
switch v.Kind() {
|
|
|
|
|
case reflect.Struct:
|
|
|
|
|
if err := buildStructDoc(v, sub, joinKey(ctx, name)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case reflect.Map:
|
|
|
|
|
if err := buildMapDoc(v, sub, joinKey(ctx, name)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
doc.addTable(name, sub)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
|
|
|
|
if v.Kind() == reflect.Slice && v.IsNil() {
|
|
|
|
|
// A nil slice has no explicit representation in TOML, so it is skipped.
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
n := v.Len()
|
|
|
|
|
if n == 0 {
|
|
|
|
|
if isTableElementType(v.Type().Elem()) {
|
|
|
|
|
// Empty array of tables has no valid TOML form, so it is skipped.
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if doc.opts.omitEmptyArrays {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return doc.appendScalar(name, []any{}, ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:36:24 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 19:49:50 +02:00
|
|
|
// 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
|
2026-09-19 19:36:24 +02:00
|
|
|
for _, ev := range elems {
|
|
|
|
|
if !ev.IsValid() || !isTableElementValue(ev) {
|
2026-09-17 19:49:50 +02:00
|
|
|
allTables = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-17 23:10:35 +02:00
|
|
|
// A []any of tables is what Parse produces for a value array of inline
|
|
|
|
|
// tables; the [[header]] form would re-parse as []map[string]any and so
|
|
|
|
|
// change the value's Go type across a round-trip. The header form is
|
|
|
|
|
// reserved for typed table slices.
|
|
|
|
|
if v.Type().Elem().Kind() == reflect.Interface {
|
|
|
|
|
allTables = false
|
|
|
|
|
}
|
2026-09-17 19:49:50 +02:00
|
|
|
if allTables {
|
2026-08-19 09:47:00 +02:00
|
|
|
subs := make([]*tomlDoc, n)
|
2026-09-19 19:36:24 +02:00
|
|
|
for i, ev := range elems {
|
2026-08-19 09:47:00 +02:00
|
|
|
if i%ctxCheckInterval == 0 {
|
|
|
|
|
if err := doc.checkCtx(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
|
|
|
|
switch ev.Kind() {
|
|
|
|
|
case reflect.Struct:
|
|
|
|
|
if isScalarStruct(ev.Type()) {
|
2026-09-17 21:20:47 +02:00
|
|
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")}
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case reflect.Map:
|
|
|
|
|
if err := buildMapDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
default:
|
2026-09-17 21:20:47 +02:00
|
|
|
return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")}
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
subs[i] = sub
|
|
|
|
|
}
|
|
|
|
|
doc.addArray(name, subs)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 19:49:50 +02:00
|
|
|
// Value array. Table elements normalise to map[string]any and the emitter
|
|
|
|
|
// writes them as inline tables.
|
2026-08-19 09:47:00 +02:00
|
|
|
items := make([]any, n)
|
2026-09-19 19:36:24 +02:00
|
|
|
for i, ev := range elems {
|
2026-08-19 09:47:00 +02:00
|
|
|
if i%ctxCheckInterval == 0 {
|
|
|
|
|
if err := doc.checkCtx(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
val, err := normaliseValue(ev)
|
|
|
|
|
if err != nil {
|
2026-09-17 21:20:47 +02:00
|
|
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
items[i] = val
|
|
|
|
|
}
|
|
|
|
|
return doc.appendScalar(name, items, ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:36:24 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
// 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.
|
|
|
|
|
func normaliseValue(v reflect.Value) (any, error) {
|
2026-09-17 19:49:50 +02:00
|
|
|
// Map and slice elements arrive wrapped in interface{}; look through them.
|
|
|
|
|
for v.Kind() == reflect.Interface && !v.IsNil() {
|
|
|
|
|
v = v.Elem()
|
|
|
|
|
}
|
|
|
|
|
if v.Kind() == reflect.Interface {
|
|
|
|
|
return nil, fmt.Errorf("cannot encode nil value")
|
|
|
|
|
}
|
2026-09-19 19:36:24 +02:00
|
|
|
if m, ok := marshalerOf(v); ok {
|
|
|
|
|
mv, err := m.MarshalTOML()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-19 19:36:24 +02:00
|
|
|
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
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-17 19:49:50 +02:00
|
|
|
// The datetime structs are TOML scalars; the emitter renders each of them.
|
2026-09-19 19:36:24 +02:00
|
|
|
if isScalarStruct(v.Type()) {
|
2026-09-17 19:49:50 +02:00
|
|
|
return v.Interface(), nil
|
|
|
|
|
}
|
2026-09-19 02:41:09 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
switch v.Kind() {
|
|
|
|
|
case reflect.String:
|
|
|
|
|
return v.String(), nil
|
|
|
|
|
case reflect.Bool:
|
|
|
|
|
return v.Bool(), nil
|
|
|
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
|
|
|
return v.Int(), nil
|
|
|
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
|
|
|
u := v.Uint()
|
|
|
|
|
if u > math.MaxInt64 {
|
|
|
|
|
return nil, fmt.Errorf("unsigned value %d overflows int64", u)
|
|
|
|
|
}
|
|
|
|
|
return int64(u), nil
|
|
|
|
|
case reflect.Float32, reflect.Float64:
|
|
|
|
|
return v.Float(), nil
|
2026-09-17 19:49:50 +02:00
|
|
|
case reflect.Map:
|
|
|
|
|
// A table nested in a value array has no header form, so it renders
|
|
|
|
|
// inline; the keys normalise to strings for the emitter.
|
|
|
|
|
if v.Type().Key().Kind() != reflect.String {
|
|
|
|
|
return nil, fmt.Errorf("map key must be string, got %s", v.Type().Key())
|
|
|
|
|
}
|
|
|
|
|
out := make(map[string]any, v.Len())
|
|
|
|
|
for _, k := range v.MapKeys() {
|
|
|
|
|
val, err := normaliseValue(v.MapIndex(k))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("[%s]: %w", k.String(), err)
|
|
|
|
|
}
|
|
|
|
|
out[k.String()] = val
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
2026-08-19 09:47:00 +02:00
|
|
|
case reflect.Slice, reflect.Array:
|
|
|
|
|
items := make([]any, v.Len())
|
|
|
|
|
for i := range v.Len() {
|
|
|
|
|
val, err := normaliseValue(v.Index(i))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
items[i] = val
|
|
|
|
|
}
|
|
|
|
|
return items, nil
|
|
|
|
|
}
|
|
|
|
|
if !v.IsValid() {
|
|
|
|
|
return nil, fmt.Errorf("invalid value")
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("cannot encode %s", v.Type())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// followPtr unwraps pointer and interface layers. Returns a zero Value if a
|
|
|
|
|
// nil pointer or nil interface is encountered.
|
|
|
|
|
func followPtr(v reflect.Value) reflect.Value {
|
|
|
|
|
for {
|
|
|
|
|
switch v.Kind() {
|
|
|
|
|
case reflect.Pointer, reflect.Interface:
|
|
|
|
|
if v.IsNil() {
|
|
|
|
|
return reflect.Value{}
|
|
|
|
|
}
|
|
|
|
|
v = v.Elem()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isScalarStruct reports whether t is a struct type that the encoder treats
|
2026-09-19 19:36:24 +02:00
|
|
|
// as a TOML scalar: time.Time and the four date-time wrappers.
|
2026-08-19 09:47:00 +02:00
|
|
|
func isScalarStruct(t reflect.Type) bool {
|
2026-09-19 19:36:24 +02:00
|
|
|
return t == timeGoType || t == offsetDateTimeType || isLocalDateType(t)
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isLocalDateType(t reflect.Type) bool {
|
|
|
|
|
return t == localDateTimeType || t == localDateType || t == localTimeType
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:36:24 +02:00
|
|
|
// 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.
|
2026-09-19 02:41:09 +02:00
|
|
|
func isDateTimeType(t reflect.Type) bool {
|
|
|
|
|
for t.Kind() == reflect.Pointer {
|
|
|
|
|
t = t.Elem()
|
|
|
|
|
}
|
2026-09-19 19:36:24 +02:00
|
|
|
return isScalarStruct(t)
|
2026-09-19 02:41:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
func isTableElementType(t reflect.Type) bool {
|
|
|
|
|
switch t.Kind() {
|
|
|
|
|
case reflect.Struct:
|
2026-09-19 02:41:09 +02:00
|
|
|
return !isScalarStruct(t) && !isTextMarshalerType(t)
|
2026-08-19 09:47:00 +02:00
|
|
|
case reflect.Map:
|
|
|
|
|
return t.Key().Kind() == reflect.String
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isTableElementValue(v reflect.Value) bool {
|
|
|
|
|
v = followPtr(v)
|
|
|
|
|
if !v.IsValid() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return isTableElementType(v.Type())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func joinKey(ctx, name string) string {
|
|
|
|
|
if ctx == "" {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
return ctx + "." + name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- emission ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// writeBlankLine writes a single newline before a table or array-of-tables
|
|
|
|
|
// header so the output has a blank line between sections, unless the buffer
|
|
|
|
|
// is empty (i.e. this is the very first header).
|
|
|
|
|
func (e *encoder) writeBlankLine() {
|
|
|
|
|
if e.buf.Len() == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|
|
|
|
if e.opts.groupByKind {
|
|
|
|
|
scalars, tables, arrays := doc.partitionedEntries()
|
|
|
|
|
for _, kv := range scalars {
|
|
|
|
|
if err := e.writeKV(kv.key, kv.val); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 12:18:30 +02:00
|
|
|
// An inlined sub-table is a value line, so it has to precede every
|
|
|
|
|
// header of this document: a line written after a [header] would be
|
|
|
|
|
// read back as part of that table.
|
|
|
|
|
headers := make([]entry, 0, len(tables))
|
2026-08-19 09:47:00 +02:00
|
|
|
for _, t := range tables {
|
2026-09-19 12:18:30 +02:00
|
|
|
inlined, err := e.writeInlineSubTableIfSmall(t.key, t.doc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !inlined {
|
|
|
|
|
headers = append(headers, t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, t := range headers {
|
2026-08-19 09:47:00 +02:00
|
|
|
path := append(append([]string{}, prefix...), t.key)
|
|
|
|
|
e.writeBlankLine()
|
|
|
|
|
e.buf.WriteByte('[')
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKeyPath(path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
e.buf.WriteString("]\n")
|
|
|
|
|
if err := e.emitDoc(t.doc, path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, a := range arrays {
|
|
|
|
|
path := append(append([]string{}, prefix...), a.key)
|
|
|
|
|
for _, sub := range a.docs {
|
|
|
|
|
e.writeBlankLine()
|
|
|
|
|
e.buf.WriteString("[[")
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKeyPath(path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
e.buf.WriteString("]]\n")
|
|
|
|
|
if err := e.emitDoc(sub, path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Preserve declaration order. Scalars and table/array headers may now
|
|
|
|
|
// interleave, which means each table/array header must include only its
|
|
|
|
|
// own section content; the emitter still writes sub-documents as separate
|
|
|
|
|
// nested blocks, so a "" sub-keyed scalar following a header for the same
|
|
|
|
|
// section is impossible in practice (struct fields are visited in order).
|
|
|
|
|
for _, ent := range doc.entries {
|
|
|
|
|
switch ent.kind {
|
|
|
|
|
case entryScalar:
|
|
|
|
|
if err := e.writeKV(ent.key, ent.val); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case entryTable:
|
2026-09-19 12:18:30 +02:00
|
|
|
inlined, err := e.writeInlineSubTableIfSmall(ent.key, ent.doc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if inlined {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
path := append(append([]string{}, prefix...), ent.key)
|
|
|
|
|
e.writeBlankLine()
|
|
|
|
|
e.buf.WriteByte('[')
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKeyPath(path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
e.buf.WriteString("]\n")
|
|
|
|
|
if err := e.emitDoc(ent.doc, path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case entryArray:
|
|
|
|
|
path := append(append([]string{}, prefix...), ent.key)
|
|
|
|
|
for _, sub := range ent.docs {
|
|
|
|
|
e.writeBlankLine()
|
|
|
|
|
e.buf.WriteString("[[")
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKeyPath(path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-19 09:47:00 +02:00
|
|
|
e.buf.WriteString("]]\n")
|
|
|
|
|
if err := e.emitDoc(sub, path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *encoder) writeKV(key string, val any) error {
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKey(key); err != nil {
|
|
|
|
|
return err
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
e.buf.WriteString(" = ")
|
|
|
|
|
if err := e.writeValue(val); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte('\n')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 23:06:59 +02:00
|
|
|
func (e *encoder) writeKeyPath(path []string) error {
|
2026-08-19 09:47:00 +02:00
|
|
|
for i, p := range path {
|
|
|
|
|
if i > 0 {
|
2026-09-17 23:06:59 +02:00
|
|
|
e.buf.WriteByte('.')
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKey(p); err != nil {
|
|
|
|
|
return err
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-17 23:06:59 +02:00
|
|
|
return nil
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 23:06:59 +02:00
|
|
|
// writeKey writes one key, bare when it qualifies and quoted otherwise. A key
|
|
|
|
|
// that is not valid UTF-8 is an error; writing it anyway would emit corrupt
|
|
|
|
|
// TOML, because the quoted form has no representation for it.
|
|
|
|
|
func (e *encoder) writeKey(key string) error {
|
2026-08-19 09:47:00 +02:00
|
|
|
if isBareKey(key) {
|
|
|
|
|
e.buf.WriteString(key)
|
2026-09-17 23:06:59 +02:00
|
|
|
return nil
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
2026-09-17 23:06:59 +02:00
|
|
|
if !utf8.ValidString(key) {
|
|
|
|
|
return fmt.Errorf("interpres: key %q is not valid UTF-8", key)
|
|
|
|
|
}
|
|
|
|
|
return writeQuotedString(&e.buf, key)
|
2026-08-19 09:47:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeQuotedString writes s as a TOML basic string (double-quoted) to buf.
|
|
|
|
|
// Returns an error only if s is not valid UTF-8; invalid byte sequences
|
|
|
|
|
// within a valid UTF-8 string are encoded as \ufffd replacement characters.
|
|
|
|
|
func writeQuotedString(buf *bytes.Buffer, s string) error {
|
|
|
|
|
if !utf8.ValidString(s) {
|
|
|
|
|
return fmt.Errorf("interpres: string is not valid UTF-8")
|
|
|
|
|
}
|
|
|
|
|
buf.WriteByte('"')
|
|
|
|
|
for i := 0; i < len(s); {
|
|
|
|
|
r, size := utf8.DecodeRuneInString(s[i:])
|
|
|
|
|
if r == utf8.RuneError && size == 1 {
|
|
|
|
|
buf.WriteString(`\ufffd`)
|
|
|
|
|
i++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
i += size
|
|
|
|
|
writeEscapedRune(buf, r)
|
|
|
|
|
}
|
|
|
|
|
buf.WriteByte('"')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeEscapedRune writes a single rune to buf, escaping it as required by
|
|
|
|
|
// TOML basic-string rules.
|
|
|
|
|
func writeEscapedRune(buf *bytes.Buffer, r rune) {
|
|
|
|
|
switch r {
|
|
|
|
|
case '\\':
|
|
|
|
|
buf.WriteString(`\\`)
|
|
|
|
|
case '"':
|
|
|
|
|
buf.WriteString(`\"`)
|
|
|
|
|
case '\b':
|
|
|
|
|
buf.WriteString(`\b`)
|
|
|
|
|
case '\t':
|
|
|
|
|
buf.WriteString(`\t`)
|
|
|
|
|
case '\n':
|
|
|
|
|
buf.WriteString(`\n`)
|
|
|
|
|
case '\f':
|
|
|
|
|
buf.WriteString(`\f`)
|
|
|
|
|
case '\r':
|
|
|
|
|
buf.WriteString(`\r`)
|
|
|
|
|
default:
|
|
|
|
|
if r < 0x20 || r == 0x7f {
|
|
|
|
|
fmt.Fprintf(buf, `\u%04X`, r)
|
|
|
|
|
} else {
|
|
|
|
|
buf.WriteRune(r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isBareKey(s string) bool {
|
|
|
|
|
if s == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for i := range len(s) {
|
|
|
|
|
c := s[i]
|
|
|
|
|
if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *encoder) writeValue(val any) error {
|
|
|
|
|
switch v := val.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
return e.writeStringVal(v)
|
|
|
|
|
case bool:
|
|
|
|
|
e.buf.WriteString(strconv.FormatBool(v))
|
|
|
|
|
return nil
|
|
|
|
|
case int64:
|
|
|
|
|
e.buf.WriteString(strconv.FormatInt(v, 10))
|
|
|
|
|
return nil
|
|
|
|
|
case float64:
|
|
|
|
|
return e.writeFloat(v)
|
|
|
|
|
case time.Time:
|
2026-09-19 12:18:18 +02:00
|
|
|
e.buf.WriteString(offsetString(v))
|
2026-08-19 09:47:00 +02:00
|
|
|
return nil
|
2026-09-19 19:36:24 +02:00
|
|
|
case OffsetDateTime:
|
|
|
|
|
e.buf.WriteString(v.String())
|
|
|
|
|
return nil
|
2026-08-19 09:47:00 +02:00
|
|
|
case LocalDateTime:
|
|
|
|
|
e.buf.WriteString(v.String())
|
|
|
|
|
return nil
|
|
|
|
|
case LocalDate:
|
|
|
|
|
e.buf.WriteString(v.String())
|
|
|
|
|
return nil
|
|
|
|
|
case LocalTime:
|
|
|
|
|
e.buf.WriteString(v.String())
|
|
|
|
|
return nil
|
|
|
|
|
case []any:
|
|
|
|
|
e.buf.WriteByte('[')
|
|
|
|
|
for i, item := range v {
|
|
|
|
|
if i > 0 {
|
|
|
|
|
e.buf.WriteString(", ")
|
|
|
|
|
}
|
|
|
|
|
if err := e.writeValue(item); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte(']')
|
|
|
|
|
return nil
|
2026-09-17 19:49:50 +02:00
|
|
|
case map[string]any:
|
2026-09-19 12:18:18 +02:00
|
|
|
return e.writeInlineMap(v)
|
2026-08-19 09:47:00 +02:00
|
|
|
case nil:
|
|
|
|
|
return fmt.Errorf("interpres: cannot encode nil value")
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("interpres: cannot encode %T", val)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 12:18:18 +02:00
|
|
|
// writeInlineMap renders m as a TOML inline table, on one line when it fits
|
|
|
|
|
// there and across lines when it does not.
|
|
|
|
|
func (e *encoder) writeInlineMap(m map[string]any) error {
|
|
|
|
|
flat := e.flat()
|
|
|
|
|
if err := flat.writeInlineMapFlat(m); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if e.column()+flat.buf.Len() <= e.limit {
|
|
|
|
|
e.buf.Write(flat.buf.Bytes())
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return e.writeInlineMapMultiline(m)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineMapFlat renders m as a single-line inline table with sorted keys,
|
|
|
|
|
// the order buildMapDoc uses for header tables. It backs the table elements of
|
|
|
|
|
// a value array, where the [[header]] form is not available.
|
|
|
|
|
func (e *encoder) writeInlineMapFlat(m map[string]any) error {
|
2026-09-17 19:49:50 +02:00
|
|
|
keys := slices.Sorted(maps.Keys(m))
|
|
|
|
|
e.buf.WriteByte('{')
|
|
|
|
|
for i, k := range keys {
|
|
|
|
|
if i > 0 {
|
|
|
|
|
e.buf.WriteString(", ")
|
|
|
|
|
}
|
2026-09-17 23:06:59 +02:00
|
|
|
if err := e.writeKey(k); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-09-17 19:49:50 +02:00
|
|
|
e.buf.WriteString(" = ")
|
|
|
|
|
if err := e.writeValue(m[k]); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte('}')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 12:18:18 +02:00
|
|
|
// writeInlineMapMultiline renders m with one entry per line and a trailing
|
|
|
|
|
// comma, the form TOML 1.1 allows for an inline table too long for one line.
|
|
|
|
|
func (e *encoder) writeInlineMapMultiline(m map[string]any) error {
|
|
|
|
|
keys := slices.Sorted(maps.Keys(m))
|
|
|
|
|
e.buf.WriteString("{\n")
|
|
|
|
|
e.inlineDepth++
|
|
|
|
|
for _, k := range keys {
|
|
|
|
|
e.writeInlineIndent()
|
|
|
|
|
if err := e.writeKey(k); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(" = ")
|
|
|
|
|
if err := e.writeValue(m[k]); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(",\n")
|
|
|
|
|
}
|
|
|
|
|
e.inlineDepth--
|
|
|
|
|
e.writeInlineIndent()
|
|
|
|
|
e.buf.WriteByte('}')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineIndent writes one tab per inline-table nesting level.
|
|
|
|
|
func (e *encoder) writeInlineIndent() {
|
|
|
|
|
for range e.inlineDepth {
|
|
|
|
|
e.buf.WriteByte('\t')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 12:18:30 +02:00
|
|
|
// errInlineArrayOfTables reports an attempt to render an array of tables
|
|
|
|
|
// inline, which has no form that keeps the value's type.
|
|
|
|
|
var errInlineArrayOfTables = errors.New("interpres: an array of tables has no inline form")
|
|
|
|
|
|
|
|
|
|
// inlinableDoc reports whether doc can be written as an inline table without
|
|
|
|
|
// changing the type of any value: scalars, value arrays and further sub-tables
|
|
|
|
|
// are fine, while an array of tables is not, because its inline form would
|
|
|
|
|
// re-parse as a value array.
|
|
|
|
|
func inlinableDoc(doc *tomlDoc) bool {
|
|
|
|
|
for _, ent := range doc.entries {
|
|
|
|
|
switch ent.kind {
|
|
|
|
|
case entryArray:
|
|
|
|
|
return false
|
|
|
|
|
case entryTable:
|
|
|
|
|
if !inlinableDoc(ent.doc) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineDocEntry writes one "key = value" binding of an inline table,
|
|
|
|
|
// without the separator that follows it.
|
|
|
|
|
func (e *encoder) writeInlineDocEntry(ent entry) error {
|
|
|
|
|
if err := e.writeKey(ent.key); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(" = ")
|
|
|
|
|
switch ent.kind {
|
|
|
|
|
case entryTable:
|
|
|
|
|
return e.writeInlineDoc(ent.doc)
|
|
|
|
|
case entryArray:
|
|
|
|
|
return errInlineArrayOfTables
|
|
|
|
|
default:
|
|
|
|
|
return e.writeValue(ent.val)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineDoc renders doc as a single-line inline table in entry order, the
|
|
|
|
|
// order the fields were declared in.
|
|
|
|
|
func (e *encoder) writeInlineDoc(doc *tomlDoc) error {
|
|
|
|
|
e.buf.WriteByte('{')
|
|
|
|
|
for i, ent := range doc.entries {
|
|
|
|
|
if i > 0 {
|
|
|
|
|
e.buf.WriteString(", ")
|
|
|
|
|
}
|
|
|
|
|
if err := e.writeInlineDocEntry(ent); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte('}')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineDocMultiline renders doc with one entry per line and a trailing
|
|
|
|
|
// comma, the form TOML 1.1 allows for an inline table too long for one line.
|
|
|
|
|
func (e *encoder) writeInlineDocMultiline(doc *tomlDoc) error {
|
|
|
|
|
e.buf.WriteString("{\n")
|
|
|
|
|
e.inlineDepth++
|
|
|
|
|
for _, ent := range doc.entries {
|
|
|
|
|
e.writeInlineIndent()
|
|
|
|
|
if err := e.writeInlineDocEntry(ent); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(",\n")
|
|
|
|
|
}
|
|
|
|
|
e.inlineDepth--
|
|
|
|
|
e.writeInlineIndent()
|
|
|
|
|
e.buf.WriteByte('}')
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeInlineSubTableIfSmall writes "key = {…}" for a sub-table whose
|
|
|
|
|
// single-line rendering fits the compact threshold, and reports whether it did
|
|
|
|
|
// so. An array of tables is never inlined, because its inline form would
|
|
|
|
|
// re-parse as a value array and change the value's Go type.
|
|
|
|
|
func (e *encoder) writeInlineSubTableIfSmall(name string, doc *tomlDoc) (bool, error) {
|
|
|
|
|
if e.opts.inlineTablesAt <= 0 || !inlinableDoc(doc) {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
flat := e.flat()
|
|
|
|
|
if err := flat.writeInlineDoc(doc); err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
if flat.buf.Len() > e.opts.inlineTablesAt {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
if err := e.writeKey(name); err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(" = ")
|
|
|
|
|
if e.column()+flat.buf.Len() <= e.limit {
|
|
|
|
|
e.buf.Write(flat.buf.Bytes())
|
|
|
|
|
} else if err := e.writeInlineDocMultiline(doc); err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteByte('\n')
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
func (e *encoder) writeStringVal(s string) error {
|
2026-09-17 23:06:18 +02:00
|
|
|
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') &&
|
|
|
|
|
len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) {
|
2026-08-19 09:47:00 +02:00
|
|
|
return writeLiteralMultilineString(&e.buf, s)
|
|
|
|
|
}
|
|
|
|
|
return writeQuotedString(&e.buf, s)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 23:06:18 +02:00
|
|
|
// canBeLiteralMultiline reports whether s can be carried verbatim by the
|
|
|
|
|
// literal ”'...”' form: the form has no escapes, so a run of three single
|
|
|
|
|
// quotes would close the delimiter early, and control characters beyond tab,
|
|
|
|
|
// and a carriage return outside a CRLF pair, have no representation at all.
|
|
|
|
|
// Anything else falls back to the escaped basic string.
|
|
|
|
|
func canBeLiteralMultiline(s string) bool {
|
|
|
|
|
if strings.Contains(s, "'''") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for i := 0; i < len(s); {
|
|
|
|
|
r, size := utf8.DecodeRuneInString(s[i:])
|
|
|
|
|
switch {
|
|
|
|
|
case r == '\t' || r == '\n':
|
|
|
|
|
case r == '\r':
|
|
|
|
|
if !strings.HasPrefix(s[i+size:], "\n") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
if r < 0x20 || r == 0x7f {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += size
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:47:00 +02:00
|
|
|
// writeLiteralMultilineString writes s as a TOML literal multi-line string,
|
|
|
|
|
// surrounded by triple single quotes. The opening delimiter is followed by a
|
|
|
|
|
// newline that the reader trims, so we always include one. The closing
|
|
|
|
|
// delimiter sits on its own line; if the value does not end in a newline, one
|
|
|
|
|
// is inserted before the closing delimiter.
|
|
|
|
|
func writeLiteralMultilineString(buf *bytes.Buffer, s string) error {
|
|
|
|
|
if !utf8.ValidString(s) {
|
|
|
|
|
return fmt.Errorf("interpres: string is not valid UTF-8")
|
|
|
|
|
}
|
|
|
|
|
buf.WriteString("'''\n")
|
|
|
|
|
buf.WriteString(s)
|
|
|
|
|
if !strings.HasSuffix(s, "\n") {
|
|
|
|
|
buf.WriteByte('\n')
|
|
|
|
|
}
|
|
|
|
|
buf.WriteString("'''")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *encoder) writeFloat(v float64) error {
|
|
|
|
|
switch {
|
|
|
|
|
case math.IsNaN(v):
|
|
|
|
|
e.buf.WriteString("nan")
|
|
|
|
|
case math.IsInf(v, 1):
|
|
|
|
|
e.buf.WriteString("inf")
|
|
|
|
|
case math.IsInf(v, -1):
|
|
|
|
|
e.buf.WriteString("-inf")
|
|
|
|
|
case v == 0:
|
2026-09-17 23:05:41 +02:00
|
|
|
// Normalise negative zero to positive zero, the contract the output
|
|
|
|
|
// rules in the documentation state.
|
2026-08-19 09:47:00 +02:00
|
|
|
e.buf.WriteString("0.0")
|
|
|
|
|
default:
|
|
|
|
|
s := strconv.FormatFloat(v, 'g', -1, 64)
|
|
|
|
|
// TOML forbids leading zeros in the exponent digits.
|
|
|
|
|
if idx := strings.LastIndexAny(s, "eE"); idx >= 0 {
|
|
|
|
|
mant := s[:idx]
|
|
|
|
|
exp := s[idx+1:] // e.g. "+06", "-05"
|
|
|
|
|
sign := ""
|
|
|
|
|
if len(exp) > 0 && (exp[0] == '+' || exp[0] == '-') {
|
|
|
|
|
sign = string(exp[0])
|
|
|
|
|
exp = exp[1:]
|
|
|
|
|
}
|
|
|
|
|
exp = strings.TrimLeft(exp, "0")
|
|
|
|
|
if exp == "" {
|
|
|
|
|
exp = "0"
|
|
|
|
|
}
|
|
|
|
|
s = mant + "e" + sign + exp
|
|
|
|
|
}
|
|
|
|
|
if !strings.ContainsAny(s, ".eE") {
|
|
|
|
|
s += ".0"
|
|
|
|
|
}
|
|
|
|
|
e.buf.WriteString(s)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|