3 Commits
Author SHA1 Message Date
petrbalvin b695b69768 docs(api): use an inline table sample that really breaks
Test / test (push) Successful in 1m33s
Assisted-by: DeepSeek V4.1 Flash
2026-09-19 12:18:57 +02:00
petrbalvin 959eaba4b0 feat(encode): add the InlineTables option
Assisted-by: DeepSeek V4.1 Flash
2026-09-19 12:18:30 +02:00
petrbalvin 8f85bb68fa feat(encode): write the TOML 1.1 output form
Assisted-by: DeepSeek V4.1 Flash
2026-09-19 12:18:18 +02:00
9 changed files with 581 additions and 53 deletions
+13
View File
@@ -24,9 +24,22 @@ 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
- The output takes the TOML 1.1 form. A date-time writes its seconds only when
the value carries them and drops the trailing zeros of a fractional second,
so `07:32:00` is written `07:32` and half a second as `00.5`. Both are the
same value, and a document written without seconds now comes back without
them. `LocalDateTime.String()`, `LocalTime.String()` and the offset date-time
rendering follow the same rule.
- An inline table that would pass the hundredth column is written across lines
with a trailing comma and one tab of indentation per nesting level, the shape
TOML 1.1 allows an inline table to take.
- TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance - TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance
suite runs the 1.1 corpus alone, and the promise that every 1.0 document suite runs the 1.1 corpus alone, and the promise that every 1.0 document
parses exactly as before is withdrawn. Nothing that parses today stops parses exactly as before is withdrawn. Nothing that parses today stops
+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
+3 -3
View File
@@ -307,13 +307,13 @@ func TestRunEncoderScalars(t *testing.T) {
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
} }
want := "b = false\n" + want := "b = false\n" +
"dt = 1979-05-27T07:32:00-07:00\n" + "dt = 1979-05-27T07:32-07:00\n" +
"f = inf\n" + "f = inf\n" +
"g = 1.5\n" + "g = 1.5\n" +
"i = -9223372036854775808\n" + "i = -9223372036854775808\n" +
"ld = 1979-05-27\n" + "ld = 1979-05-27\n" +
"ldt = 1979-05-27T07:32:00\n" + "ldt = 1979-05-27T07:32\n" +
"lt = 07:32:00.999000000\n" + "lt = 07:32:00.999\n" +
"nl = \"line1\\nline2\"\n" + "nl = \"line1\\nline2\"\n" +
"s = \"quote \\\" and backslash \\\\\"\n" "s = \"quote \\\" and backslash \\\\\"\n"
if stdout.String() != want { if stdout.String() != want {
+28 -15
View File
@@ -28,29 +28,42 @@ type LocalDate struct{ time.Time }
type LocalTime struct{ time.Time } type LocalTime struct{ time.Time }
// String returns the TOML-canonical rendering of the local date-time, e.g. // String returns the TOML-canonical rendering of the local date-time, e.g.
// "1979-05-27T07:32:00" or "...:00.000000123" when the time has a fractional // "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a
// second. The fractional component is zero-padded to nanosecond precision. // fractional second. TOML 1.1 makes the seconds optional, so they appear only
// when they are non-zero, and a fraction drops its trailing zeros.
func (ldt LocalDateTime) String() string { func (ldt LocalDateTime) String() string {
base := ldt.Format("2006-01-02T15:04:05") return ldt.Format("2006-01-02T") + clockString(ldt.Time)
if ns := ldt.Nanosecond(); ns > 0 {
return base + "." + fmt.Sprintf("%09d", ns)
}
return base
} }
// String returns the TOML-canonical rendering of the local date, e.g. // String returns the TOML-canonical rendering of the local date, e.g.
// "1979-05-27". // "1979-05-27".
func (ld LocalDate) String() string { return ld.Format("2006-01-02") } func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
// String returns the TOML-canonical rendering of the local time, e.g. // String returns the TOML-canonical rendering of the local time, e.g. "07:32"
// "07:32:00" or "...:00.000000123" when the time has a fractional second. // or "07:32:00.5" when the time carries a fractional second.
// The fractional component is zero-padded to nanosecond precision. func (lt LocalTime) String() string { return clockString(lt.Time) }
func (lt LocalTime) String() string {
base := lt.Format("15:04:05") // clockString renders a time of day the way TOML writes it: the seconds appear
if ns := lt.Nanosecond(); ns > 0 { // only when the value carries them, and a fractional second drops its trailing
return base + "." + fmt.Sprintf("%09d", ns) // zeros, so half a second is "00.5" and not "00.500000000". Both are the same
// value either way; the shorter form is the one TOML 1.1 allows.
func clockString(t time.Time) string {
out := t.Format("15:04")
ns := t.Nanosecond()
if t.Second() != 0 || ns != 0 {
out += t.Format(":05")
} }
return base if ns > 0 {
out += "." + strings.TrimRight(fmt.Sprintf("%09d", ns), "0")
}
return out
}
// offsetString renders an offset date-time, the fourth TOML kind, in the same
// shape: no zero seconds, no trailing zeros in the fraction, and the offset
// written as "Z" when it is zero.
func offsetString(t time.Time) string {
return t.Format("2006-01-02T") + clockString(t) + t.Format("Z07:00")
} }
var ( var (
+57 -8
View File
@@ -145,11 +145,12 @@ variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the
zero date for a local time). Every kind may omit the seconds as of TOML 1.1 zero date for a local time). Every kind may omit the seconds as of TOML 1.1
(`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the (`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the
canonical rendering writes full seconds. There is no implicit conversion encoder writes the seconds only when the value carries them, so a document
between the offset and local kinds; assigning one to the other is an error. written without seconds comes back without them. There is no implicit
The four types take a bare timestamp and never a quoted string, so a document conversion between the offset and local kinds; assigning one to the other is an
that writes a date-time with quotes does not decode into them, and neither error. The four types take a bare timestamp and never a quoted string, so a
`encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. document that writes a date-time with quotes does not decode into them, and
neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
### Arrays of tables ### Arrays of tables
@@ -426,6 +427,48 @@ carry verbatim (an embedded run of three single quotes, a control character
other than tab or newline, or a carriage return outside a CRLF pair) also keeps other than tab or newline, or a carriage return outside a CRLF pair) also keeps
the basic form, so the output always re-parses to the same value. the basic form, so the output always re-parses to the same value.
### Inline tables
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, {
n = 1,
name = "a value long enough to push this line well past the one hundred column limit",
}]
```
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 ### Cancellation
`MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The `MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The
@@ -440,6 +483,8 @@ The output is not byte-identical to any document that produced the value:
- map keys are emitted in sorted order - map keys are emitted in sorted order
- the choice between `[table]` headers and inline tables is not preserved - the choice between `[table]` headers and inline tables is not preserved
- strings use the basic quoted form unless the literal option above applies - strings use the basic quoted form unless the literal option above applies
- a date-time drops its zero seconds and the trailing zeros of its fraction, so
`07:32:00` is written `07:32`; both are the same value
- floats always carry a `.` or an exponent, so a float `1` is emitted as `1.0` - floats always carry a `.` or an exponent, so a float `1` is emitted as `1.0`
and stays distinguishable from the integer `1` across a round-trip; negative and stays distinguishable from the integer `1` across a round-trip; negative
zero is normalised to `0.0` zero is normalised to `0.0`
@@ -516,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)
``` ```
@@ -545,9 +592,11 @@ type LocalDate struct{ time.Time } // 1979-05-27
type LocalTime struct{ time.Time } // 07:32:00.999999 type LocalTime struct{ time.Time } // 07:32:00.999999
``` ```
Each carries a `String()` method returning the TOML-canonical rendering, with Each carries a `String()` method returning the TOML-canonical rendering: the
the fractional second zero-padded to nanosecond precision when present. The seconds appear only when the value carries them, and a fractional second drops
types are produced by `Parse` and accepted by `Marshal`. its trailing zeros, so `07:32:00` renders as `07:32` and a half second as
`00.5`. The types are produced by `Parse` and accepted by `Marshal`, which
writes them through `String()`.
## Errors ## Errors
+204 -7
View File
@@ -28,18 +28,50 @@ var (
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
) )
// inlineLimit is the column past which an inline table is written across
// lines. TOML 1.1 lets an inline table carry newlines and a trailing comma, so
// a long one stays readable instead of running off the line.
const inlineLimit = 100
// noInlineBreak is the limit a measuring encoder carries, high enough that the
// form it renders is always the single-line one.
const noInlineBreak = 1 << 30
// encoder produces a TOML document from a Go value via a small intermediate // encoder produces a TOML document from a Go value via a small intermediate
// representation that preserves the order in which fields were declared. // representation that preserves the order in which fields were declared.
type encoder struct { type encoder struct {
buf bytes.Buffer buf bytes.Buffer
ctx context.Context ctx context.Context
opts Encoder opts Encoder
// inlineDepth is the nesting level inside inline tables, which decides
// their indentation.
inlineDepth int
// limit is the column at which an inline table is broken; only a
// measuring encoder raises it.
limit int
} }
func newEncoder() *encoder { return &encoder{} } func newEncoder() *encoder { return &encoder{limit: inlineLimit} }
// flat returns an encoder that measures a value by rendering it on one line,
// so a caller can decide which form to write before writing it.
func (e *encoder) flat() *encoder {
return &encoder{ctx: e.ctx, opts: e.opts, limit: noInlineBreak}
}
func (e *encoder) bytes() []byte { return e.buf.Bytes() } func (e *encoder) bytes() []byte { return e.buf.Bytes() }
// column reports how many bytes the current line already holds, so a form can
// be measured against the limit before it is written.
func (e *encoder) column() int {
if i := bytes.LastIndexByte(e.buf.Bytes(), '\n'); i >= 0 {
return e.buf.Len() - i - 1
}
return e.buf.Len()
}
func (e *encoder) checkCtx() error { func (e *encoder) checkCtx() error {
if e.ctx == nil { if e.ctx == nil {
return nil return nil
@@ -709,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('[')
@@ -750,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('[')
@@ -891,7 +943,7 @@ func (e *encoder) writeValue(val any) error {
case float64: case float64:
return e.writeFloat(v) return e.writeFloat(v)
case time.Time: case time.Time:
e.buf.WriteString(v.Format(time.RFC3339Nano)) e.buf.WriteString(offsetString(v))
return nil return nil
case LocalDateTime: case LocalDateTime:
e.buf.WriteString(v.String()) e.buf.WriteString(v.String())
@@ -915,7 +967,7 @@ func (e *encoder) writeValue(val any) error {
e.buf.WriteByte(']') e.buf.WriteByte(']')
return nil return nil
case map[string]any: case map[string]any:
return e.writeInlineTable(v) return e.writeInlineMap(v)
case nil: case nil:
return fmt.Errorf("interpres: cannot encode nil value") return fmt.Errorf("interpres: cannot encode nil value")
default: default:
@@ -923,10 +975,24 @@ func (e *encoder) writeValue(val any) error {
} }
} }
// writeInlineTable renders m as a TOML inline table with sorted keys, the // writeInlineMap renders m as a TOML inline table, on one line when it fits
// order buildMapDoc uses for header tables. It backs the table elements of a // there and across lines when it does not.
// value array, where the [[header]] form is not available. func (e *encoder) writeInlineMap(m map[string]any) error {
func (e *encoder) writeInlineTable(m map[string]any) error { flat := e.flat()
if err := flat.writeInlineMapFlat(m); err != nil {
return err
}
if e.column()+flat.buf.Len() <= e.limit {
e.buf.Write(flat.buf.Bytes())
return nil
}
return e.writeInlineMapMultiline(m)
}
// writeInlineMapFlat renders m as a single-line 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) writeInlineMapFlat(m map[string]any) error {
keys := slices.Sorted(maps.Keys(m)) keys := slices.Sorted(maps.Keys(m))
e.buf.WriteByte('{') e.buf.WriteByte('{')
for i, k := range keys { for i, k := range keys {
@@ -945,6 +1011,137 @@ func (e *encoder) writeInlineTable(m map[string]any) error {
return nil return nil
} }
// writeInlineMapMultiline renders m 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) writeInlineMapMultiline(m map[string]any) error {
keys := slices.Sorted(maps.Keys(m))
e.buf.WriteString("{\n")
e.inlineDepth++
for _, k := range keys {
e.writeInlineIndent()
if err := e.writeKey(k); err != nil {
return err
}
e.buf.WriteString(" = ")
if err := e.writeValue(m[k]); err != nil {
return err
}
e.buf.WriteString(",\n")
}
e.inlineDepth--
e.writeInlineIndent()
e.buf.WriteByte('}')
return nil
}
// writeInlineIndent writes one tab per inline-table nesting level.
func (e *encoder) writeInlineIndent() {
for range e.inlineDepth {
e.buf.WriteByte('\t')
}
}
// 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) {
+240 -12
View File
@@ -326,7 +326,7 @@ func TestMarshalerReturningTime(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want := "m = 2026-06-26T10:00:00Z\n" want := "m = 2026-06-26T10:00Z\n"
if string(out) != want { if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -466,7 +466,7 @@ func TestMarshalEmbeddedScalarStruct(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want := "name = \"x\"\ns = 2026-06-26T00:00:00\n" want := "name = \"x\"\ns = 2026-06-26T00:00\n"
if string(out) != want { if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -541,7 +541,7 @@ func TestMarshalDateTime(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want := "offset = 2026-06-26T10:00:00Z\nlocal = 2026-06-26T07:32:00\nday = 2026-06-26\nclock = 07:32:00\n" want := "offset = 2026-06-26T10:00Z\nlocal = 2026-06-26T07:32\nday = 2026-06-26\nclock = 07:32\n"
if string(out) != want { if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -688,7 +688,7 @@ func TestMarshalInlineTableWithDatetime(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want := "mix = [1979-05-27T07:32:00Z, {t = 1979-05-27T07:32:00}]\n" want := "mix = [1979-05-27T07:32Z, {t = 1979-05-27T07:32}]\n"
if string(out) != want { if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -907,7 +907,7 @@ func TestMarshalTagOptionOmitZero(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n" want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n"
if string(out) != want { if string(out) != want {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -1196,6 +1196,8 @@ qty = 2
[meta] [meta]
created = 2026-06-26T10:00:00Z created = 2026-06-26T10:00:00Z
mixed = [1, {n = 1, name = "a value long enough to push this line well past the one hundred column limit"}]
`) `)
tree1, err := Parse(src) tree1, err := Parse(src)
if err != nil { if err != nil {
@@ -1258,24 +1260,35 @@ func TestLocalDateString(t *testing.T) {
} }
func TestLocalDateTimeString(t *testing.T) { func TestLocalDateTimeString(t *testing.T) {
// The rendering drops zero seconds and the trailing zeros of a fraction,
// which TOML 1.1 allows and which keeps a value written without seconds
// written without them.
ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)} ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)}
if got := ldt.String(); got != "1979-05-27T07:32:00" { if got := ldt.String(); got != "1979-05-27T07:32" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got) t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32", got)
} }
ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)} ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)}
if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" { if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got) t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got)
} }
ldt3 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 500, time.UTC)} ldt3 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 500, time.UTC)}
if got := ldt3.String(); got != "1979-05-27T07:32:00.000000500" { if got := ldt3.String(); got != "1979-05-27T07:32:00.0000005" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got) t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.0000005", got)
}
ldt4 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 30, 500000000, time.UTC)}
if got := ldt4.String(); got != "1979-05-27T07:32:30.5" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:30.5", got)
} }
} }
func TestLocalTimeString(t *testing.T) { func TestLocalTimeString(t *testing.T) {
lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)} lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}
if got := lt.String(); got != "07:32:00" { if got := lt.String(); got != "07:32" {
t.Errorf("LocalTime.String() = %q, want 07:32:00", got) t.Errorf("LocalTime.String() = %q, want 07:32", got)
}
lt2 := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 15, 250000000, time.UTC)}
if got := lt2.String(); got != "07:32:15.25" {
t.Errorf("LocalTime.String() = %q, want 07:32:15.25", got)
} }
} }
@@ -1490,7 +1503,7 @@ func TestMarshalTextLeavesDateTimesAlone(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
want := "stamp = 2026-06-26T10:00:00Z\nptr = 2026-06-26T10:00:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32:00\nclock = 07:32:00\n" want := "stamp = 2026-06-26T10:00Z\nptr = 2026-06-26T10:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32\nclock = 07:32\n"
if string(out) != want { if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
} }
@@ -1572,3 +1585,218 @@ func TestMarshalTextValuesRoundTrip(t *testing.T) {
t.Errorf("Tag = %q, want %q", back.Tag, in.Tag) t.Errorf("Tag = %q, want %q", back.Tag, in.Tag)
} }
} }
// --- TOML 1.1 output forms -------------------------------------------------
func TestMarshalDateTimeRendering(t *testing.T) {
// The seconds are written only when the value carries them, and a fraction
// drops its trailing zeros. Both are the same value either way; the shorter
// form is the one TOML 1.1 allows.
base := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
cases := []struct {
name string
val any
want string
}{
{"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"},
{"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"},
{"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"},
{"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"},
{"local-zero-seconds", LocalDateTime{Time: base}, "v = 2026-06-26T10:00\n"},
{"local-fraction", LocalDateTime{Time: base.Add(2500 * time.Millisecond)}, "v = 2026-06-26T10:00:02.5\n"},
{"date", LocalDate{Time: base}, "v = 2026-06-26\n"},
{"time-zero-seconds", LocalTime{Time: base}, "v = 10:00\n"},
{"time-seconds", LocalTime{Time: base.Add(15 * time.Second)}, "v = 10:00:15\n"},
{"time-nanoseconds", LocalTime{Time: base.Add(123456789 * time.Nanosecond)}, "v = 10:00:00.123456789\n"},
}
for _, c := range cases {
out, err := Marshal(map[string]any{"v": c.val})
if err != nil {
t.Errorf("%s: marshal: %v", c.name, err)
continue
}
if string(out) != c.want {
t.Errorf("%s: output mismatch:\ngot: %q\nwant: %q", c.name, out, c.want)
}
}
}
func TestMarshalInlineTableBreaksWhenLong(t *testing.T) {
// A table element of a value array is written inline; a long one carries
// newlines and a trailing comma instead of running past the line limit,
// which TOML 1.1 allows an inline table to do.
const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit"
type Cfg struct {
Arr []any `toml:"arr"`
}
out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": long}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "arr = [1, {\n\tn = 1,\n\tname = \"" + long + "\",\n}]\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
// The same values without the long string stay on one line.
out, err = Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": "short"}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := "arr = [1, {n = 1, name = \"short\"}]\n"; string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
// The broken form parses back to the same tree.
tree, err := Parse(out)
if err != nil {
t.Fatalf("parse of the encoder output: %v", err)
}
if got := len(tree["arr"].([]any)); got != 2 {
t.Fatalf("arr has %d elements, want 2", got)
}
}
func TestMarshalNestedInlineTableBreaksIndependently(t *testing.T) {
// A nested table breaks on its own measure, so a table whose entries stay
// short keeps the one-line form inside a parent that broke.
const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit"
type Cfg struct {
Arr []any `toml:"arr"`
}
out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "sub": map[string]any{"name": long}}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "arr = [1, {\n\tn = 1,\n\tsub = {name = \"" + long + "\"},\n}]\n"
if string(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"])
}
}
+28 -3
View File
@@ -232,7 +232,11 @@ type Unmarshaler interface {
// inline table. // inline table.
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time // - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local // (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
// variants). // 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, 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 // - 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
@@ -261,14 +265,16 @@ func MarshalContext(ctx context.Context, v any) ([]byte, error) {
// An Encoder encodes Go values into TOML. // An Encoder encodes Go values into TOML.
// //
// All options default to the behaviour earlier releases used, and the defaults // All options default to the behaviour that passes the toml-test compliance
// pass the toml-test compliance suite in both directions: // suite in both directions:
// //
// GroupByKind: true (scalars first, then tables, then arrays of tables) // GroupByKind: true (scalars first, then tables, then arrays of tables)
// OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value; // OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value;
// 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
@@ -277,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.
@@ -309,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.
+6 -4
View File
@@ -611,11 +611,13 @@ odt2 = 1979-05-27 07:32-07:00
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
if got := tree["t"].(LocalTime).String(); got != "13:37:00" { // A value written without seconds comes back without them: the seconds are
t.Errorf("t = %q, want %q", got, "13:37:00") // only written when the value carries them.
if got := tree["t"].(LocalTime).String(); got != "13:37" {
t.Errorf("t = %q, want %q", got, "13:37")
} }
if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32:00" { if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" {
t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32:00") t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32")
} }
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" { if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
t.Errorf("odt1 = %q", got) t.Errorf("odt1 = %q", got)