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
- 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
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
+3 -3
View File
@@ -307,13 +307,13 @@ func TestRunEncoderScalars(t *testing.T) {
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
}
want := "b = false\n" +
"dt = 1979-05-27T07:32:00-07:00\n" +
"dt = 1979-05-27T07:32-07:00\n" +
"f = inf\n" +
"g = 1.5\n" +
"i = -9223372036854775808\n" +
"ld = 1979-05-27\n" +
"ldt = 1979-05-27T07:32:00\n" +
"lt = 07:32:00.999000000\n" +
"ldt = 1979-05-27T07:32\n" +
"lt = 07:32:00.999\n" +
"nl = \"line1\\nline2\"\n" +
"s = \"quote \\\" and backslash \\\\\"\n"
if stdout.String() != want {
+28 -15
View File
@@ -28,29 +28,42 @@ type LocalDate struct{ time.Time }
type LocalTime struct{ time.Time }
// 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
// second. The fractional component is zero-padded to nanosecond precision.
// "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a
// 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 {
base := ldt.Format("2006-01-02T15:04:05")
if ns := ldt.Nanosecond(); ns > 0 {
return base + "." + fmt.Sprintf("%09d", ns)
}
return base
return ldt.Format("2006-01-02T") + clockString(ldt.Time)
}
// String returns the TOML-canonical rendering of the local date, e.g.
// "1979-05-27".
func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
// String returns the TOML-canonical rendering of the local time, e.g.
// "07:32:00" or "...:00.000000123" when the time has a fractional second.
// The fractional component is zero-padded to nanosecond precision.
func (lt LocalTime) String() string {
base := lt.Format("15:04:05")
if ns := lt.Nanosecond(); ns > 0 {
return base + "." + fmt.Sprintf("%09d", ns)
// String returns the TOML-canonical rendering of the local time, e.g. "07:32"
// or "07:32:00.5" when the time carries a fractional second.
func (lt LocalTime) String() string { return clockString(lt.Time) }
// clockString renders a time of day the way TOML writes it: the seconds appear
// only when the value carries them, and a fractional second drops its trailing
// 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 (
+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
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
canonical rendering writes full seconds. There is no implicit conversion
between the offset and local kinds; assigning one to the other is an error.
The four types take a bare timestamp and never a quoted string, so a document
that writes a date-time with quotes does not decode into them, and neither
`encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
encoder writes the seconds only when the value carries them, so a document
written without seconds comes back without them. There is no implicit
conversion between the offset and local kinds; assigning one to the other is an
error. The four types take a bare timestamp and never a quoted string, so a
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
@@ -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
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
`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
- the choice between `[table]` headers and inline tables is not preserved
- 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`
and stays distinguishable from the integer `1` across a round-trip; negative
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
```
Each carries a `String()` method returning the TOML-canonical rendering, with
the fractional second zero-padded to nanosecond precision when present. The
types are produced by `Parse` and accepted by `Marshal`.
Each carries a `String()` method returning the TOML-canonical rendering: the
seconds appear only when the value carries them, and a fractional second drops
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
+83 -7
View File
@@ -28,18 +28,50 @@ var (
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
// representation that preserves the order in which fields were declared.
type encoder struct {
buf bytes.Buffer
ctx context.Context
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() }
// 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 {
if e.ctx == nil {
return nil
@@ -891,7 +923,7 @@ func (e *encoder) writeValue(val any) error {
case float64:
return e.writeFloat(v)
case time.Time:
e.buf.WriteString(v.Format(time.RFC3339Nano))
e.buf.WriteString(offsetString(v))
return nil
case LocalDateTime:
e.buf.WriteString(v.String())
@@ -915,7 +947,7 @@ func (e *encoder) writeValue(val any) error {
e.buf.WriteByte(']')
return nil
case map[string]any:
return e.writeInlineTable(v)
return e.writeInlineMap(v)
case nil:
return fmt.Errorf("interpres: cannot encode nil value")
default:
@@ -923,10 +955,24 @@ func (e *encoder) writeValue(val any) error {
}
}
// writeInlineTable renders m as a TOML 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) writeInlineTable(m map[string]any) error {
// writeInlineMap renders m as a TOML inline table, on one line when it fits
// there and across lines when it does not.
func (e *encoder) writeInlineMap(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))
e.buf.WriteByte('{')
for i, k := range keys {
@@ -945,6 +991,36 @@ func (e *encoder) writeInlineTable(m map[string]any) error {
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 {
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') &&
len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) {
+113 -12
View File
@@ -326,7 +326,7 @@ func TestMarshalerReturningTime(t *testing.T) {
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "m = 2026-06-26T10:00:00Z\n"
want := "m = 2026-06-26T10:00Z\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
@@ -466,7 +466,7 @@ func TestMarshalEmbeddedScalarStruct(t *testing.T) {
if err != nil {
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 {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
@@ -541,7 +541,7 @@ func TestMarshalDateTime(t *testing.T) {
if err != nil {
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 {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
@@ -688,7 +688,7 @@ func TestMarshalInlineTableWithDatetime(t *testing.T) {
if err != nil {
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 {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
@@ -907,7 +907,7 @@ func TestMarshalTagOptionOmitZero(t *testing.T) {
if err != nil {
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 {
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
@@ -1196,6 +1196,8 @@ qty = 2
[meta]
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)
if err != nil {
@@ -1258,24 +1260,35 @@ func TestLocalDateString(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)}
if got := ldt.String(); got != "1979-05-27T07:32:00" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got)
if got := ldt.String(); got != "1979-05-27T07:32" {
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)}
if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" {
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)}
if got := ldt3.String(); got != "1979-05-27T07:32:00.000000500" {
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got)
if got := ldt3.String(); got != "1979-05-27T07:32:00.0000005" {
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) {
lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}
if got := lt.String(); got != "07:32:00" {
t.Errorf("LocalTime.String() = %q, want 07:32:00", got)
if got := lt.String(); got != "07:32" {
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 {
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 {
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)
}
}
// --- 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.
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
// (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
// using its result.
// - 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.
//
// All options default to the behaviour earlier releases used, and the defaults
// pass the toml-test compliance suite in both directions:
// All options default to the behaviour that passes the toml-test compliance
// suite in both directions:
//
// GroupByKind: true (scalars first, then tables, then arrays of tables)
// 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 {
t.Fatalf("parse: %v", err)
}
if got := tree["t"].(LocalTime).String(); got != "13:37:00" {
t.Errorf("t = %q, want %q", got, "13:37:00")
// A value written without seconds comes back without them: the seconds are
// 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" {
t.Errorf("dt = %q, want %q", 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")
}
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
t.Errorf("odt1 = %q", got)