Date-time tokens were validated by two regular expressions and then tried against up to sixteen time.Parse layouts; the profile named the regexp backtracker among the hottest nodes, and the failed attempts allocated ParseErrors by the million. scanDateTimeShape walks the strict TOML grammar as bytes and dispatches one layout per shape, which time.Parse accepts because parsing takes a fractional second whether the layout signs it or not. The String methods build their output in a single buffer instead of concatenating Format results.
309 lines
9.2 KiB
Go
309 lines
9.2 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
|
|
}
|
|
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
|
|
}
|