feat(encode): add the InlineTables option

Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
2026-09-19 12:18:30 +02:00
parent 8f85bb68fa
commit 959eaba4b0
6 changed files with 308 additions and 6 deletions
+4
View File
@@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
toml-test tagged JSON from stdin and writes the TOML document it describes. toml-test tagged JSON from stdin and writes the TOML document it describes.
The compliance suite now runs the encoder as well as the decoder, 214 The compliance suite now runs the encoder as well as the decoder, 214
encoder cases against the tagged JSON of the valid corpus. encoder cases against the tagged JSON of the valid corpus.
- `Encoder.InlineTables(threshold)`: a sub-table whose single-line rendering is
at most `threshold` bytes is written as an inline table instead of a header
section, which shortens a document of small tables. An array of tables keeps
its header form, because its inline form would re-parse as a value array.
### Changed ### Changed
+2 -1
View File
@@ -24,7 +24,8 @@ the entire official [toml-test](https://github.com/toml-lang/toml-test) suite:
- **Cancellation**: every entry point has a `*Context` sibling that honours a - **Cancellation**: every entry point has a `*Context` sibling that honours a
`context.Context`. `context.Context`.
- **Configurable emission**: `Encoder` options for declaration-order output, - **Configurable emission**: `Encoder` options for declaration-order output,
omitting empty arrays, and literal multiline strings. omitting empty arrays, literal multiline strings, and inlining small
sub-tables.
## Install ## Install
+30 -3
View File
@@ -429,9 +429,10 @@ the basic form, so the output always re-parses to the same value.
### Inline tables ### Inline tables
A table element of a value array is written as one `{a = 1, b = 2}` line while A table element of a value array, and a sub-table inlined by
it fits. An inline table that would pass the hundredth column carries newlines [`InlineTables`](#compact-documents), is written as one `{a = 1, b = 2}` line
and a trailing comma instead, which TOML 1.1 allows: while it fits. An inline table that would pass the hundredth column carries
newlines and a trailing comma instead, which TOML 1.1 allows:
```toml ```toml
arr = [1, { arr = [1, {
@@ -444,6 +445,30 @@ The closing brace and the entries are indented one tab per nesting level, a
nested table is measured on its own line, and the output re-parses to the same nested table is measured on its own line, and the output re-parses to the same
value either way. value either way.
### Compact documents
`InlineTables(threshold)` writes a sub-table as an inline table when its
single-line rendering is at most `threshold` bytes, and as a table header
section when it is longer. A document of small tables therefore grows shorter:
```go
out, err := interpres.NewEncoder().InlineTables(60).Marshal(cfg)
```
With `60` and a table of three short entries, the same value is written
```toml
server = {host = "127.0.0.1", port = 9090, tls = {on = false}}
```
instead of three lines under a `[server]` header and a `[server.tls]` section.
A nested sub-table takes part in the same way, and the whole option is off at
`0` or less. Two limits are deliberate. An array of tables keeps the `[[a]]`
header form, because its inline form re-parses as a value array and would change
the value's Go type. And because an inlined table is a value line, every one of
them precedes the first header of its document, so a table inlined next to a
header is not read back as part of that header's section.
### Cancellation ### Cancellation
`MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The `MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The
@@ -536,12 +561,14 @@ encoder:
| `GroupByKind(v bool)` | `true` | group entries as scalars, then sub-tables, then arrays of tables; `false` preserves declaration order | | `GroupByKind(v bool)` | `true` | group entries as scalars, then sub-tables, then arrays of tables; `false` preserves declaration order |
| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays | | `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays |
| `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` | | `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` |
| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes |
```go ```go
out, err := interpres.NewEncoder(). out, err := interpres.NewEncoder().
GroupByKind(false). GroupByKind(false).
OmitEmptyArrays(). OmitEmptyArrays().
UseLiteralMultiline(80). UseLiteralMultiline(80).
InlineTables(60).
MarshalContext(ctx, cfg) MarshalContext(ctx, cfg)
``` ```
+121
View File
@@ -741,7 +741,20 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
return err return err
} }
} }
// 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))
for _, t := range tables { for _, t := range tables {
inlined, err := e.writeInlineSubTableIfSmall(t.key, t.doc)
if err != nil {
return err
}
if !inlined {
headers = append(headers, t)
}
}
for _, t := range headers {
path := append(append([]string{}, prefix...), t.key) path := append(append([]string{}, prefix...), t.key)
e.writeBlankLine() e.writeBlankLine()
e.buf.WriteByte('[') e.buf.WriteByte('[')
@@ -782,6 +795,13 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
return err return err
} }
case entryTable: case entryTable:
inlined, err := e.writeInlineSubTableIfSmall(ent.key, ent.doc)
if err != nil {
return err
}
if inlined {
continue
}
path := append(append([]string{}, prefix...), ent.key) path := append(append([]string{}, prefix...), ent.key)
e.writeBlankLine() e.writeBlankLine()
e.buf.WriteByte('[') e.buf.WriteByte('[')
@@ -1021,6 +1041,107 @@ func (e *encoder) writeInlineIndent() {
} }
} }
// 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
}
func (e *encoder) writeStringVal(s string) error { func (e *encoder) writeStringVal(s string) error {
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') &&
len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) { len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) {
+127
View File
@@ -1673,3 +1673,130 @@ func TestMarshalNestedInlineTableBreaksIndependently(t *testing.T) {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
} }
type inlineTLS struct {
On bool `toml:"on"`
}
type inlineServer struct {
Host string `toml:"host"`
Port int `toml:"port"`
TLS inlineTLS `toml:"tls"`
}
type inlineBig struct {
A int `toml:"a"`
B int `toml:"b"`
C int `toml:"c"`
}
func TestEncoderInlineTables(t *testing.T) {
type Cfg struct {
Server inlineServer `toml:"server"`
Big inlineBig `toml:"big"`
}
cfg := Cfg{Server: inlineServer{Host: "127.0.0.1", Port: 9090}, Big: inlineBig{A: 1, B: 2, C: 3}}
// The default keeps every sub-table a header section.
headerForm, err := Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[server]\nhost = \"127.0.0.1\"\nport = 9090\n\n[server.tls]\non = false\n\n[big]\na = 1\nb = 2\nc = 3\n"
if string(headerForm) != want {
t.Errorf("default output mismatch:\ngot: %q\nwant: %q", headerForm, want)
}
// With the option both fit the threshold and become inline tables, nested
// ones included.
out, err := NewEncoder().InlineTables(60).Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want = "server = {host = \"127.0.0.1\", port = 9090, tls = {on = false}}\nbig = {a = 1, b = 2, c = 3}\n"
if string(out) != want {
t.Errorf("compact output mismatch:\ngot: %q\nwant: %q", out, want)
}
// A threshold below the rendering keeps the header form.
out, err = NewEncoder().InlineTables(10).Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(out) != string(headerForm) {
t.Errorf("small threshold output mismatch:\ngot: %q\nwant: %q", out, headerForm)
}
}
func TestEncoderInlineTablesOrderAndRoundTrip(t *testing.T) {
// An inlined sub-table is a value line, so it precedes every header of the
// document; written after a header it would be read back as part of that
// table. The compact form and the header form parse to the same tree.
type Four struct {
A int `toml:"a"`
B int `toml:"b"`
C int `toml:"c"`
D int `toml:"d"`
}
type Cfg struct {
Small inlineTLS `toml:"small"`
Big Four `toml:"big"`
}
cfg := Cfg{Small: inlineTLS{On: true}, Big: Four{A: 1, B: 2, C: 3, D: 4}}
headerForm, err := Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
compact, err := NewEncoder().InlineTables(20).Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "small = {on = true}\n\n[big]\na = 1\nb = 2\nc = 3\nd = 4\n"
if string(compact) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", compact, want)
}
got, err := Parse(compact)
if err != nil {
t.Fatalf("parse of the compact output: %v", err)
}
ref, err := Parse(headerForm)
if err != nil {
t.Fatalf("parse of the header output: %v", err)
}
if !reflect.DeepEqual(got, ref) {
t.Errorf("the compact form changed the tree:\ncompact: %#v\nheaders: %#v", got, ref)
}
if _, ok := got["big"].(map[string]any); !ok {
t.Errorf("big = %#v, want a table", got["big"])
}
}
func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) {
// An array of tables has no inline form that keeps the value's type, so the
// option leaves it alone and the tree keeps its []map[string]any shape.
type Item struct {
N int `toml:"n"`
}
type Cfg struct {
Items []Item `toml:"items"`
Small inlineTLS `toml:"small"`
}
cfg := Cfg{Items: []Item{{N: 1}}, Small: inlineTLS{On: true}}
out, err := NewEncoder().InlineTables(60).Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "small = {on = true}\n\n[[items]]\nn = 1\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
tree, err := Parse(out)
if err != nil {
t.Fatalf("parse: %v", err)
}
if _, ok := tree["items"].([]map[string]any); !ok {
t.Errorf("items = %#v, want []map[string]any", tree["items"])
}
}
+24 -2
View File
@@ -234,8 +234,9 @@ type Unmarshaler interface {
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local // (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
// variants). A date-time writes its seconds only when the value carries // variants). A date-time writes its seconds only when the value carries
// them, and drops the trailing zeros of a fractional second. // them, and drops the trailing zeros of a fractional second.
// - A table element of a value array is written as an inline table, across // - A table element of a value array, and a sub-table inlined by
// lines when it does not fit one. // Encoder.InlineTables, is written as an inline table, across lines when it
// does not fit one.
// - Values implementing Marshaler are encoded by calling MarshalTOML and // - Values implementing Marshaler are encoded by calling MarshalTOML and
// using its result. // using its result.
// - Values implementing encoding.TextMarshaler, and not one of the // - Values implementing encoding.TextMarshaler, and not one of the
@@ -272,6 +273,8 @@ func MarshalContext(ctx context.Context, v any) ([]byte, error) {
// a nil/empty []Item struct slice is still skipped) // a nil/empty []Item struct slice is still skipped)
// LiteralMultilineAt: 0 (always emit the escaped basic form, never a // LiteralMultilineAt: 0 (always emit the escaped basic form, never a
// literal one) // literal one)
// InlineTablesAt: 0 (always emit a table header, never an inline
// table)
// //
// Use the chainable option methods to opt out. The option state is private; // Use the chainable option methods to opt out. The option state is private;
// callers that need the underlying knobs reach for the methods rather than // callers that need the underlying knobs reach for the methods rather than
@@ -280,6 +283,7 @@ type Encoder struct {
groupByKind bool // default true; set via (*Encoder).GroupByKind groupByKind bool // default true; set via (*Encoder).GroupByKind
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
inlineTablesAt int // default 0; set via (*Encoder).InlineTables
} }
// NewEncoder returns an Encoder with default options. // NewEncoder returns an Encoder with default options.
@@ -312,6 +316,24 @@ func (e *Encoder) UseLiteralMultiline(threshold int) *Encoder {
return e return e
} }
// InlineTables sets the size limit, in bytes of the single-line rendering, at
// which a sub-table is written as an inline table instead of a table header,
// which makes a document of small tables shorter. Use 0 or any negative value
// to disable (always emit a header).
//
// A sub-table is inlined only when doing so keeps every value's type: an array
// of tables keeps its header form, because its inline form would re-parse as a
// value array. An inlined table that does not fit the line is written across
// lines, which TOML 1.1 allows.
//
// With GroupByKind(false) the layout is already for presentation only, and an
// inlined table follows the same rule as any other value line: it lands in the
// section of the header that precedes it.
func (e *Encoder) InlineTables(threshold int) *Encoder {
e.inlineTablesAt = threshold
return e
}
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v. // Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
// //
// Marshal is equivalent to MarshalContext with context.Background. // Marshal is equivalent to MarshalContext with context.Background.