feat(encode): add the InlineTables option
Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
@@ -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.
|
||||
The compliance suite now runs the encoder as well as the decoder, 214
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
`context.Context`.
|
||||
- **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
|
||||
|
||||
|
||||
+30
-3
@@ -429,9 +429,10 @@ the basic form, so the output always re-parses to the same value.
|
||||
|
||||
### Inline tables
|
||||
|
||||
A table element of a value array is written as one `{a = 1, b = 2}` line while
|
||||
it fits. An inline table that would pass the hundredth column carries newlines
|
||||
and a trailing comma instead, which TOML 1.1 allows:
|
||||
A table element of a value array, and a sub-table inlined by
|
||||
[`InlineTables`](#compact-documents), is written as one `{a = 1, b = 2}` line
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
`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 |
|
||||
| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays |
|
||||
| `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
|
||||
out, err := interpres.NewEncoder().
|
||||
GroupByKind(false).
|
||||
OmitEmptyArrays().
|
||||
UseLiteralMultiline(80).
|
||||
InlineTables(60).
|
||||
MarshalContext(ctx, cfg)
|
||||
```
|
||||
|
||||
|
||||
@@ -741,7 +741,20 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
||||
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 {
|
||||
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)
|
||||
e.writeBlankLine()
|
||||
e.buf.WriteByte('[')
|
||||
@@ -782,6 +795,13 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
e.writeBlankLine()
|
||||
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 {
|
||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') &&
|
||||
len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) {
|
||||
|
||||
+127
@@ -1673,3 +1673,130 @@ func TestMarshalNestedInlineTableBreaksIndependently(t *testing.T) {
|
||||
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
@@ -234,8 +234,9 @@ type Unmarshaler interface {
|
||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||
// variants). A date-time writes its seconds only when the value carries
|
||||
// them, and drops the trailing zeros of a fractional second.
|
||||
// - A table element of a value array is written as an inline table, across
|
||||
// lines when it does not fit one.
|
||||
// - A table element of a value array, and a sub-table inlined by
|
||||
// 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
|
||||
// using its result.
|
||||
// - 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)
|
||||
// LiteralMultilineAt: 0 (always emit the escaped basic form, never a
|
||||
// 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;
|
||||
// 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
|
||||
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
|
||||
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
|
||||
inlineTablesAt int // default 0; set via (*Encoder).InlineTables
|
||||
}
|
||||
|
||||
// NewEncoder returns an Encoder with default options.
|
||||
@@ -312,6 +316,24 @@ func (e *Encoder) UseLiteralMultiline(threshold int) *Encoder {
|
||||
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 is equivalent to MarshalContext with context.Background.
|
||||
|
||||
Reference in New Issue
Block a user