perf(datetime): scan the token shape and render in one pass

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.
This commit is contained in:
2026-09-20 22:15:10 +02:00
parent 9a3d8dd87a
commit f37c05f9a2
+198 -69
View File
@@ -5,8 +5,6 @@ package interpres
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
@@ -44,7 +42,8 @@ func (odt OffsetDateTime) String() string { return offsetString(odt.Time) }
// 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 {
return ldt.Format("2006-01-02T") + clockString(ldt.Time)
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.
@@ -55,70 +54,187 @@ func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
// 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.
// 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 {
out := t.Format("15:04")
ns := t.Nanosecond()
if t.Second() != 0 || ns != 0 {
out += t.Format(":05")
}
if ns > 0 {
out += "." + strings.TrimRight(fmt.Sprintf("%09d", ns), "0")
}
return out
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 {
return t.Format("2006-01-02T") + clockString(t) + t.Format("Z07:00")
buf := t.AppendFormat(make([]byte, 0, 32), "2006-01-02T")
buf = appendClock(buf, t)
buf = t.AppendFormat(buf, "Z07:00")
return string(buf)
}
var (
offsetDateTimeLayouts = []string{
"2006-01-02T15:04:05.999999999Z07:00",
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05Z07:00",
// TOML 1.1 makes the seconds optional.
"2006-01-02T15:04Z07:00",
"2006-01-02 15:04Z07:00",
}
localDateTimeLayouts = []string{
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
}
localTimeLayouts = []string{
"15:04:05.999999999",
"15:04:05",
"15:04",
}
// 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
)
// dateTimeShape enforces the strict TOML grammar (two-digit components,
// seconds optional since 1.1, a fraction only after seconds) that time.Parse
// would otherwise accept loosely (e.g. a single-digit hour).
var dateTimeShape = regexp.MustCompile(
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
`|^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$`,
// 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"
)
// offsetBounds extracts the numeric offset of a date-time. The ABNF bounds it
// to 00:00 through 23:59, but time.Parse accepts values outside that range
// and rolls them over (for example "+00:60" becomes "+01:00"), so the bounds
// are enforced here.
var offsetBounds = regexp.MustCompile(`([+-])(\d{2}):(\d{2})$`)
// 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 (time.Time, LocalDateTime, LocalDate, or
// 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' {
@@ -127,36 +243,49 @@ func parseDateTime(tok string) (any, bool) {
if !strings.ContainsAny(tok, "-:") {
return nil, false
}
if !dateTimeShape.MatchString(tok) {
kind, seconds := scanDateTimeShape(tok)
if kind == dateTimeNone {
return nil, false
}
if m := offsetBounds.FindStringSubmatch(tok); m != nil {
hour, _ := strconv.Atoi(m[2])
minute, _ := strconv.Atoi(m[3])
if hour > 23 || minute > 59 {
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
}
}
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
norm := strings.ToUpper(tok)
for _, layout := range offsetDateTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil {
return OffsetDateTime{t}, true
case dateTimeLocal:
layout := localClockLayout
if seconds {
layout = localDateTimeLayout
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
}
for _, layout := range localDateTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil {
return LocalDateTime{t}, true
case dateTimeDate:
t, err := time.Parse(localDateOnlyLayout, norm)
if err != nil {
return nil, false
}
}
if t, err := time.Parse("2006-01-02", norm); err == nil {
return LocalDate{t}, true
case dateTimeClock:
layout := localTimeClockLayout
if seconds {
layout = localTimeLayout
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
}
for _, layout := range localTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil {
return LocalTime{t}, true
}
}
return nil, false
}