fix: encode mixed arrays with inline table elements
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
-
|
-
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `Marshal` re-emits arrays that mix tables with scalars: the table elements
|
||||||
|
render as inline tables inside the value array. A tree that `Parse` accepts
|
||||||
|
from such a document previously failed with
|
||||||
|
`cannot encode map[string]interface {}`.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-20
|
## [1.0.0] - 2026-08-20
|
||||||
|
|
||||||
First stable release: a dependency-free TOML 1.0 parser and encoder for Go that
|
First stable release: a dependency-free TOML 1.0 parser and encoder for Go that
|
||||||
|
|||||||
+12
@@ -289,6 +289,18 @@ func (p Port) MarshalTOML() (any, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Arrays
|
||||||
|
|
||||||
|
An array whose every element is a table (`[]struct`, `[]map[string]V`, after
|
||||||
|
pointer dereference) emits as an array of tables. TOML also lets one array mix
|
||||||
|
tables with scalars; such an array emits as a plain value array, with the
|
||||||
|
table elements rendered as inline tables:
|
||||||
|
|
||||||
|
```go
|
||||||
|
tree, _ := interpres.Parse([]byte(`arr = [1, {a = 2}, "x"]`))
|
||||||
|
out, _ := interpres.Marshal(tree) // arr = [1, {a = 2}, "x"]
|
||||||
|
```
|
||||||
|
|
||||||
### Empty arrays
|
### Empty arrays
|
||||||
|
|
||||||
A nil slice is always omitted. An empty (length 0) array of tables is always
|
A nil slice is always omitted. An empty (length 0) array of tables is always
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -313,7 +314,17 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return doc.appendScalar(name, []any{}, ctx)
|
return doc.appendScalar(name, []any{}, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isTableElementValue(v.Index(0)) {
|
// 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)) {
|
||||||
|
allTables = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allTables {
|
||||||
subs := make([]*tomlDoc, n)
|
subs := make([]*tomlDoc, n)
|
||||||
for i := range n {
|
for i := range n {
|
||||||
if i%ctxCheckInterval == 0 {
|
if i%ctxCheckInterval == 0 {
|
||||||
@@ -347,7 +358,8 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular array of scalars.
|
// Value array. Table elements normalise to map[string]any and the emitter
|
||||||
|
// writes them as inline tables.
|
||||||
items := make([]any, n)
|
items := make([]any, n)
|
||||||
for i := range n {
|
for i := range n {
|
||||||
if i%ctxCheckInterval == 0 {
|
if i%ctxCheckInterval == 0 {
|
||||||
@@ -382,11 +394,22 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
// nested-array representations the emitter understands. Slices and arrays are
|
// nested-array representations the emitter understands. Slices and arrays are
|
||||||
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
||||||
func normaliseValue(v reflect.Value) (any, error) {
|
func normaliseValue(v reflect.Value) (any, error) {
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
if v.CanInterface() {
|
if v.CanInterface() {
|
||||||
if m, ok := v.Interface().(Marshaler); ok {
|
if m, ok := v.Interface().(Marshaler); ok {
|
||||||
return m.MarshalTOML()
|
return m.MarshalTOML()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The datetime structs are TOML scalars; the emitter renders each of them.
|
||||||
|
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
|
||||||
|
return v.Interface(), nil
|
||||||
|
}
|
||||||
switch v.Kind() {
|
switch v.Kind() {
|
||||||
case reflect.String:
|
case reflect.String:
|
||||||
return v.String(), nil
|
return v.String(), nil
|
||||||
@@ -402,6 +425,21 @@ func normaliseValue(v reflect.Value) (any, error) {
|
|||||||
return int64(u), nil
|
return int64(u), nil
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
return v.Float(), nil
|
return v.Float(), nil
|
||||||
|
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
|
||||||
case reflect.Slice, reflect.Array:
|
case reflect.Slice, reflect.Array:
|
||||||
items := make([]any, v.Len())
|
items := make([]any, v.Len())
|
||||||
for i := range v.Len() {
|
for i := range v.Len() {
|
||||||
@@ -683,6 +721,8 @@ func (e *encoder) writeValue(val any) error {
|
|||||||
}
|
}
|
||||||
e.buf.WriteByte(']')
|
e.buf.WriteByte(']')
|
||||||
return nil
|
return nil
|
||||||
|
case map[string]any:
|
||||||
|
return e.writeInlineTable(v)
|
||||||
case nil:
|
case nil:
|
||||||
return fmt.Errorf("interpres: cannot encode nil value")
|
return fmt.Errorf("interpres: cannot encode nil value")
|
||||||
default:
|
default:
|
||||||
@@ -690,6 +730,26 @@ func (e *encoder) writeValue(val any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeInlineTable renders m as a TOML 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) writeInlineTable(m map[string]any) error {
|
||||||
|
keys := slices.Sorted(maps.Keys(m))
|
||||||
|
e.buf.WriteByte('{')
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
e.buf.WriteString(", ")
|
||||||
|
}
|
||||||
|
e.writeKey(k)
|
||||||
|
e.buf.WriteString(" = ")
|
||||||
|
if err := e.writeValue(m[k]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('}')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *encoder) writeStringVal(s string) error {
|
func (e *encoder) writeStringVal(s string) error {
|
||||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
||||||
return writeLiteralMultilineString(&e.buf, s)
|
return writeLiteralMultilineString(&e.buf, s)
|
||||||
|
|||||||
@@ -504,6 +504,79 @@ func TestMarshalNestedArrays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalMixedArrayWithInlineTable(t *testing.T) {
|
||||||
|
// Parse accepts a mixed array (TOML allows any value kinds in one array),
|
||||||
|
// so Marshal of the parsed tree must re-emit it. The table element has no
|
||||||
|
// header form inside a value array and renders inline.
|
||||||
|
tree, err := Parse([]byte("arr = [1, {a = 2}, \"x\"]\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "arr = [1, {a = 2}, \"x\"]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
re, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-parse: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tree, re) {
|
||||||
|
t.Fatalf("round-trip changed the tree:\nwas: %#v\nnow: %#v", tree, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNestedInlineTables(t *testing.T) {
|
||||||
|
tree := map[string]any{
|
||||||
|
"mix": []any{
|
||||||
|
int64(1),
|
||||||
|
map[string]any{"deep": map[string]any{"n": int64(0)}, "list": []any{"a", true}},
|
||||||
|
map[string]any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "mix = [1, {deep = {n = 0}, list = [\"a\", true]}, {}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalInlineTableWithDatetime(t *testing.T) {
|
||||||
|
when := time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)
|
||||||
|
tree := map[string]any{
|
||||||
|
"mix": []any{when, map[string]any{"t": LocalDateTime{when}}},
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "mix = [1979-05-27T07:32:00Z, {t = 1979-05-27T07:32:00}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalArrayOfTablesStaysHeaderForm(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
|
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
|
||||||
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
||||||
// exponent). The encoder must strip it so the output is "1e+6".
|
// exponent). The encoder must strip it so the output is "1e+6".
|
||||||
|
|||||||
+3
-1
@@ -156,7 +156,9 @@ type Unmarshaler interface {
|
|||||||
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
||||||
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
||||||
// while other empty arrays emit as `key = []`.
|
// while other empty arrays emit as `key = []`.
|
||||||
// - Other slices and arrays become TOML arrays.
|
// - Other slices and arrays become TOML arrays; a table element inside a
|
||||||
|
// value array (for example an inline table in a mixed array) emits as an
|
||||||
|
// inline table.
|
||||||
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
||||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||||
// variants).
|
// variants).
|
||||||
|
|||||||
Reference in New Issue
Block a user