diff --git a/CHANGELOG.md b/CHANGELOG.md index b15374f..a29f5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c70a5b1..67cce0c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 8246a21..0ef825b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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) ``` diff --git a/encode.go b/encode.go index 071d397..9468e10 100644 --- a/encode.go +++ b/encode.go @@ -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) { diff --git a/encode_test.go b/encode_test.go index 76f80ac..c0c144e 100644 --- a/encode_test.go +++ b/encode_test.go @@ -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"]) + } +} diff --git a/interpres.go b/interpres.go index 0859975..2e5d37c 100644 --- a/interpres.go +++ b/interpres.go @@ -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.