feat(encode): write the TOML 1.1 output form

Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
2026-09-19 12:18:18 +02:00
parent bccaf087c8
commit 8f85bb68fa
8 changed files with 278 additions and 52 deletions
+9
View File
@@ -27,6 +27,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
+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 (
+30 -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,23 @@ 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 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 the line past the 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.
### Cancellation ### Cancellation
`MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The `MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The
@@ -440,6 +458,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`
@@ -545,9 +565,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
+83 -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
@@ -891,7 +923,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 +947,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 +955,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 +991,36 @@ 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')
}
}
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) {
+113 -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,91 @@ 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)
}
}
+6 -3
View File
@@ -232,7 +232,10 @@ 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 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,8 +264,8 @@ 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;
+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)