diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f747fb..0acc549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index dfab6c0..c0ea349 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/encode.go b/encode.go index 2b2bd33..681caa5 100644 --- a/encode.go +++ b/encode.go @@ -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) diff --git a/encode_test.go b/encode_test.go index 9c87143..3699f7e 100644 --- a/encode_test.go +++ b/encode_test.go @@ -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". diff --git a/interpres.go b/interpres.go index 3b44376..f3939ad 100644 --- a/interpres.go +++ b/interpres.go @@ -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).