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
|
||||
|
||||
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
|
||||
|
||||
A nil slice is always omitted. An empty (length 0) array of tables is always
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -313,7 +314,17 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
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)
|
||||
for i := range n {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
@@ -347,7 +358,8 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
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)
|
||||
for i := range n {
|
||||
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
|
||||
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
||||
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 m, ok := v.Interface().(Marshaler); ok {
|
||||
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() {
|
||||
case reflect.String:
|
||||
return v.String(), nil
|
||||
@@ -402,6 +425,21 @@ func normaliseValue(v reflect.Value) (any, error) {
|
||||
return int64(u), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
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:
|
||||
items := make([]any, v.Len())
|
||||
for i := range v.Len() {
|
||||
@@ -683,6 +721,8 @@ func (e *encoder) writeValue(val any) error {
|
||||
}
|
||||
e.buf.WriteByte(']')
|
||||
return nil
|
||||
case map[string]any:
|
||||
return e.writeInlineTable(v)
|
||||
case nil:
|
||||
return fmt.Errorf("interpres: cannot encode nil value")
|
||||
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 {
|
||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
||||
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) {
|
||||
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
||||
// 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
|
||||
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
||||
// 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
|
||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||
// variants).
|
||||
|
||||
Reference in New Issue
Block a user