feat(encode): write the TOML 1.1 output form
Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user