feat: decode arrays into [N]T and add MarshalAppend
Test / test (push) Successful in 2m31s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 23:58:23 +02:00
parent 10d49fbe60
commit 2e5dfc54c9
6 changed files with 140 additions and 18 deletions
+5
View File
@@ -46,6 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(10000 levels, which no hand-written document approaches): a document that
nests arrays or inline tables deeper used to run the stack out and is now
rejected with a `SyntaxError` naming the limit.
- A TOML array decodes into a Go fixed-size array, `[N]T`, where only a slice
was accepted before; the encoder could already encode one. A length mismatch
is an error wrapped with the key path.
- `MarshalAppend(buf, v)` appends the TOML encoding of v to buf and returns
the extended buffer, the shape `json.MarshalAppend` has.
- `ParseFile(path)` reads the file and parses it into a `Document`, with the
file name at the front of every error it returns, read failure and parse
failure alike. `Valid(data)` reports whether a document parses, nil on
+33 -6
View File
@@ -309,9 +309,8 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
}
func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
if dst.Kind() != reflect.Slice {
return fmt.Errorf("interpres: cannot assign array to %s", dst.Type())
}
switch dst.Kind() {
case reflect.Slice:
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
for i, item := range items {
if err := d.assign(item, out.Index(i)); err != nil {
@@ -320,12 +319,27 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
}
dst.Set(out)
return nil
case reflect.Array:
// A fixed-size array takes the elements in place; a length mismatch is
// the error, because a TOML array carries no way to name a default for
// the elements it is short of, and the surplus has nowhere to go.
if dst.Len() != len(items) {
return fmt.Errorf("interpres: cannot assign %d elements to %s", len(items), dst.Type())
}
for i, item := range items {
if err := d.assign(item, dst.Index(i)); err != nil {
return newDecodeError(fmt.Sprintf("[%d]", i), err)
}
}
return nil
default:
return fmt.Errorf("interpres: cannot assign array to %s", dst.Type())
}
}
func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) error {
if dst.Kind() != reflect.Slice {
return fmt.Errorf("interpres: cannot assign array of tables to %s", dst.Type())
}
switch dst.Kind() {
case reflect.Slice:
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
for i, item := range items {
if err := d.assign(item, out.Index(i)); err != nil {
@@ -334,6 +348,19 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
}
dst.Set(out)
return nil
case reflect.Array:
if dst.Len() != len(items) {
return fmt.Errorf("interpres: cannot assign %d elements to %s", len(items), dst.Type())
}
for i, item := range items {
if err := d.assign(item, dst.Index(i)); err != nil {
return newDecodeError(fmt.Sprintf("[%d]", i), err)
}
}
return nil
default:
return fmt.Errorf("interpres: cannot assign array of tables to %s", dst.Type())
}
}
// --- low-level setters -----------------------------------------------------
+43
View File
@@ -1273,3 +1273,46 @@ func TestSourceLineEdgePositions(t *testing.T) {
t.Errorf("SourceLine(offset past end) =\n%q\nwant:\n%q", got, want)
}
}
func TestDecodeFixedArray(t *testing.T) {
t.Run("value array", func(t *testing.T) {
var cfg struct {
Ports [2]int `toml:"ports"`
Label [2]string `toml:"label"`
Grid [2][2]int64 `toml:"grid"`
}
in := []byte("ports = [8080, 9090]\nlabel = [\"a\", \"b\"]\ngrid = [[1, 2], [3, 4]]\n")
if err := Unmarshal(in, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Ports != [2]int{8080, 9090} || cfg.Label != [2]string{"a", "b"} || cfg.Grid != [2][2]int64{{1, 2}, {3, 4}} {
t.Errorf("decoded %+v", cfg)
}
})
t.Run("array of tables", func(t *testing.T) {
type Item struct {
Name string `toml:"name"`
Qty int `toml:"qty"`
}
var cfg struct {
Items [2]Item `toml:"items"`
}
in := []byte("[[items]]\nname = \"a\"\nqty = 1\n[[items]]\nname = \"b\"\nqty = 2\n")
if err := Unmarshal(in, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Items != [2]Item{{"a", 1}, {"b", 2}} {
t.Errorf("decoded %+v", cfg)
}
})
t.Run("a length mismatch is an error", func(t *testing.T) {
var cfg struct {
Ports [3]int `toml:"ports"`
}
err := Unmarshal([]byte("ports = [8080, 9090]\n"), &cfg)
want := "ports: interpres: cannot assign 2 elements to [3]int"
if err == nil || err.Error() != want {
t.Errorf("err = %v, want %q", err, want)
}
})
}
+10
View File
@@ -158,6 +158,11 @@ out, err := interpres.Marshal(cfg)
The cancellable variant of `Marshal`. The context is checked before any work
and every 64 fields during the reflection walk.
### `func MarshalAppend(buf []byte, v any) ([]byte, error)`
Appends the TOML encoding of `v` to `buf` and returns the extended buffer, the
shape `json.MarshalAppend` has. A failed encoding leaves `buf` untouched.
## Decoding
### Value mapping
@@ -282,6 +287,11 @@ destination is a slice, each element decodes into the slice's element type
(`[]struct` or `[]map[string]V`); a mismatch on one element surfaces as an
error wrapped with `[i]:` and the element index.
A value array also decodes into a fixed-size array, `[N]T`, the mirror of the
encoder's ability to encode one. The element count has to match: an array
whose length differs from `N` is an error, `interpres: cannot assign 2
elements to [3]int`, wrapped with the key path.
### Custom decoding: `Unmarshaler`
A type that wants full control of its decode implements:
+26
View File
@@ -1998,3 +1998,29 @@ func TestMarshalNumber(t *testing.T) {
}
})
}
func TestMarshalAppend(t *testing.T) {
buf := []byte("preamble\n")
out, err := MarshalAppend(buf, map[string]any{"a": int64(1)})
if err != nil {
t.Fatal(err)
}
want := "preamble\na = 1\n"
if string(out) != want {
t.Errorf("output %q, want %q", out, want)
}
if &out[0] != &buf[0] {
t.Log("append reallocated; capacity differed")
}
out2, err := MarshalAppend(out, map[string]any{"b": true})
if err != nil {
t.Fatal(err)
}
if string(out2) != want+"b = true\n" {
t.Errorf("second append %q", out2)
}
buf = []byte("keep\n")
if out3, err := MarshalAppend(buf, Document{}); err == nil {
t.Errorf("MarshalAppend with an unencodable value = %q, want an error", out3)
}
}
+11
View File
@@ -399,6 +399,17 @@ func Marshal(v any) ([]byte, error) {
return MarshalContext(context.Background(), v)
}
// MarshalAppend appends the TOML encoding of v to buf and returns the extended
// buffer, the shape json.MarshalAppend has. A failed encoding leaves buf
// untouched and comes back with a nil slice.
func MarshalAppend(buf []byte, v any) ([]byte, error) {
out, err := Marshal(v)
if err != nil {
return nil, err
}
return append(buf, out...), nil
}
// MarshalContext is the cancellable variant of Marshal.
func MarshalContext(ctx context.Context, v any) ([]byte, error) {
if err := ctx.Err(); err != nil {