Files
interpres/datetime.go
T
petrbalvin bef1d3fbd9
Test / test (push) Successful in 1m31s
test: add FuzzMarshal, golden messages, synctest cancellation and cross smoke
Assisted-by: GLM 5.3 Flash
2026-09-22 01:03:27 +02:00

315 lines
9.5 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"fmt"
"strings"
"time"
)
// TOML distinguishes four date-time kinds, and each has its own Go type:
// OffsetDateTime for the offset kind, and the local wrappers below for the
// three that carry no offset. A plain time.Time is accepted wherever an
// offset date-time is, on both the encoding and the decoding side, so a
// timestamp field does not have to name the wrapper.
// OffsetDateTime is a TOML offset date-time, e.g. 1979-05-27T07:32:00-07:00.
// The embedded time.Time is the instant, with the offset the document wrote.
type OffsetDateTime struct{ time.Time }
// LocalDateTime is a TOML local date-time with no offset, e.g.
// 1979-05-27T07:32:00. The embedded time.Time is in UTC.
type LocalDateTime struct{ time.Time }
// LocalDate is a TOML local date with no time or offset, e.g. 1979-05-27.
// The embedded time.Time is at midnight UTC.
type LocalDate struct{ time.Time }
// LocalTime is a TOML local time with no date or offset, e.g. 07:32:00.999999.
// The embedded time.Time uses the zero date.
type LocalTime struct{ time.Time }
// String returns the TOML-canonical rendering of the offset date-time, e.g.
// "1979-05-27T07:32Z" or "1979-05-27T07:32:00-07:00". The seconds appear only
// when the value carries them, a fractional second drops its trailing zeros,
// and an offset of zero is written "Z".
func (odt OffsetDateTime) String() string { return offsetString(odt.Time) }
// String returns the TOML-canonical rendering of the local date-time, e.g.
// "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 {
buf := ldt.Time.AppendFormat(make([]byte, 0, 32), "2006-01-02T")
return string(appendClock(buf, 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"
// or "07:32:00.5" when the time carries a fractional second.
func (lt LocalTime) String() string { return clockString(lt.Time) }
// appendClock appends the clock part of a TOML time to buf: HH:MM, seconds
// only when the value carries them, and a fraction with its trailing zeros
// dropped, so half a second is ".5" and not ".500000000". Both are the same
// value either way; the shorter form is the one TOML 1.1 allows. The whole
// rendering is built in one buffer, because the encoder writes a date-time
// per entry of a large document.
func appendClock(buf []byte, t time.Time) []byte {
buf = t.AppendFormat(buf, "15:04")
if t.Second() != 0 || t.Nanosecond() != 0 {
buf = t.AppendFormat(buf, ":05")
}
if ns := t.Nanosecond(); ns > 0 {
buf = append(buf, '.')
buf = append(buf, strings.TrimRight(fmt.Sprintf("%09d", ns), "0")...)
}
return buf
}
// clockString renders a time of day the way TOML writes it.
func clockString(t time.Time) string {
return string(appendClock(make([]byte, 0, 16), t))
}
// 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 {
buf := t.AppendFormat(make([]byte, 0, 32), "2006-01-02T")
buf = appendClock(buf, t)
buf = t.AppendFormat(buf, "Z07:00")
return string(buf)
}
// dateTimeKind names the date-time shape a bare token has, as the scanner
// below classifies it.
type dateTimeKind int
const (
dateTimeNone dateTimeKind = iota
dateTimeOffset
dateTimeLocal
dateTimeDate
dateTimeClock
)
// The layouts the time package parses each shape with. Parsing accepts a
// fractional second even when the layout does not carry one, so each shape
// needs a single layout, chosen by whether the token has seconds.
const (
offsetDateTimeLayout = "2006-01-02T15:04:05Z07:00"
offsetClockLayout = "2006-01-02T15:04Z07:00"
localDateTimeLayout = "2006-01-02T15:04:05"
localClockLayout = "2006-01-02T15:04"
localTimeLayout = "15:04:05"
localTimeClockLayout = "15:04"
localDateOnlyLayout = "2006-01-02"
)
// scanDateTimeShape validates a bare token against the strict TOML date-time
// grammar and reports which kind it is: two-digit components, seconds
// optional since TOML 1.1, a fraction only after seconds, an offset only
// after a time, and an offset bounded to 00:00 through 23:59. The grammar is
// a fixed byte shape, so the scan is a byte walk; the regular expressions
// this replaced cost the parser measurably per token, and a shape that fails
// the scan is simply not a date-time.
func scanDateTimeShape(tok string) (kind dateTimeKind, seconds bool) {
// A local clock on its own: HH:MM[:SS[.fraction]].
if len(tok) >= 5 && tok[2] == ':' {
n, secs, ok := scanClock(tok, 0)
if !ok || n != len(tok) {
return dateTimeNone, false
}
return dateTimeClock, secs
}
// A date, optionally followed by a time and an offset.
if len(tok) < 10 || tok[4] != '-' || tok[7] != '-' {
return dateTimeNone, false
}
for _, i := range [8]int{0, 1, 2, 3, 5, 6, 8, 9} {
if !isDecDigit(tok[i]) {
return dateTimeNone, false
}
}
if len(tok) == 10 {
return dateTimeDate, false
}
if sep := tok[10]; sep != 'T' && sep != 't' && sep != ' ' {
return dateTimeNone, false
}
n, secs, ok := scanClock(tok, 11)
if !ok {
return dateTimeNone, false
}
if n == len(tok) {
return dateTimeLocal, secs
}
// The offset: Z/z, or a signed HH:MM bounded as the ABNF requires.
switch c := tok[n]; {
case c == 'Z' || c == 'z':
if n+1 != len(tok) {
return dateTimeNone, false
}
case c == '+' || c == '-':
if n+6 != len(tok) || tok[n+3] != ':' ||
!isDecDigit(tok[n+1]) || !isDecDigit(tok[n+2]) ||
!isDecDigit(tok[n+4]) || !isDecDigit(tok[n+5]) ||
tok[n+1] > '2' || (tok[n+1] == '2' && tok[n+2] > '3') ||
tok[n+4] > '5' {
return dateTimeNone, false
}
default:
return dateTimeNone, false
}
return dateTimeOffset, secs
}
// scanClock validates HH:MM[:SS[.fraction]] starting at i and returns the
// position after the clock, whether seconds were present, and whether the
// shape is valid.
func scanClock(tok string, i int) (pos int, seconds bool, ok bool) {
if i+5 > len(tok) || tok[i+2] != ':' ||
!isDecDigit(tok[i]) || !isDecDigit(tok[i+1]) ||
!isDecDigit(tok[i+3]) || !isDecDigit(tok[i+4]) {
return 0, false, false
}
i += 5
if i == len(tok) || tok[i] != ':' {
return i, false, true
}
if i+3 > len(tok) || !isDecDigit(tok[i+1]) || !isDecDigit(tok[i+2]) {
return 0, false, false
}
i += 3
if i == len(tok) || tok[i] != '.' {
return i, true, true
}
i++
digits := i
for i < len(tok) && isDecDigit(tok[i]) {
i++
}
if i == digits {
return 0, false, false
}
return i, true, true
}
// normaliseDateTimeToken rewrites the date/time separator to 'T' and the
// offset marker to 'Z', the characters the layouts above carry. A token that
// already has them is returned as it is, without a copy.
func normaliseDateTimeToken(tok string, kind dateTimeKind) string {
if kind == dateTimeDate || kind == dateTimeClock {
return tok
}
needs := false
for i := range len(tok) {
c := tok[i]
if c == 't' || c == 'z' || (c == ' ' && i == 10) {
needs = true
break
}
}
if !needs {
return tok
}
b := []byte(tok)
for i, c := range b {
switch {
case c == 't':
b[i] = 'T'
case c == 'z':
b[i] = 'Z'
case c == ' ' && i == 10:
b[i] = 'T'
}
}
return string(b)
}
// parseDateTime classifies and parses a bare token as a TOML date-time value.
// It returns the decoded value (OffsetDateTime, LocalDateTime, LocalDate or
// LocalTime) and whether the token was a date-time at all.
func parseDateTime(tok string) (any, bool) {
if tok == "" || tok[0] < '0' || tok[0] > '9' {
return nil, false
}
if !strings.ContainsAny(tok, "-:") {
return nil, false
}
kind, seconds := scanDateTimeShape(tok)
if kind == dateTimeNone {
return nil, false
}
norm := normaliseDateTimeToken(tok, kind)
switch kind {
case dateTimeOffset:
layout := offsetClockLayout
if seconds {
layout = offsetDateTimeLayout
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
}
// A zero offset carries its own anonymous location from time.Parse,
// while the written form is "Z" either way; normalising to UTC keeps
// the tree identical across the round trip.
if _, off := t.Zone(); off == 0 {
t = t.In(time.UTC)
}
return OffsetDateTime{t}, true
case dateTimeLocal:
layout := localClockLayout
if seconds {
layout = localDateTimeLayout
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
}
return LocalDateTime{t}, true
case dateTimeDate:
t, err := time.Parse(localDateOnlyLayout, norm)
if err != nil {
return nil, false
}
return LocalDate{t}, true
case dateTimeClock:
layout := localTimeClockLayout
if seconds {
layout = localTimeLayout
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
}
return LocalTime{t}, true
}
return nil, false
}
// isDateToken reports whether s is exactly a YYYY-MM-DD date, used to detect a
// space-separated date-time written as "date<space>time".
func isDateToken(s string) bool {
if len(s) != 10 {
return false
}
for i := range len(s) {
if i == 4 || i == 7 {
if s[i] != '-' {
return false
}
} else if !isDecDigit(s[i]) {
return false
}
}
return true
}