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:
+198
-69
@@ -5,8 +5,6 @@ package interpres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// 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.
|
// when they are non-zero, and a fraction drops its trailing zeros.
|
||||||
func (ldt LocalDateTime) String() string {
|
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.
|
// 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.
|
// or "07:32:00.5" when the time carries a fractional second.
|
||||||
func (lt LocalTime) String() string { return clockString(lt.Time) }
|
func (lt LocalTime) String() string { return clockString(lt.Time) }
|
||||||
|
|
||||||
// clockString renders a time of day the way TOML writes it: the seconds appear
|
// appendClock appends the clock part of a TOML time to buf: HH:MM, seconds
|
||||||
// only when the value carries them, and a fractional second drops its trailing
|
// only when the value carries them, and a fraction with its trailing zeros
|
||||||
// zeros, so half a second is "00.5" and not "00.500000000". Both are the same
|
// 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.
|
// 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 {
|
func clockString(t time.Time) string {
|
||||||
out := t.Format("15:04")
|
return string(appendClock(make([]byte, 0, 16), t))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// offsetString renders an offset date-time, the fourth TOML kind, in the same
|
// 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
|
// shape: no zero seconds, no trailing zeros in the fraction, and the offset
|
||||||
// written as "Z" when it is zero.
|
// written as "Z" when it is zero.
|
||||||
func offsetString(t time.Time) string {
|
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 (
|
// dateTimeKind names the date-time shape a bare token has, as the scanner
|
||||||
offsetDateTimeLayouts = []string{
|
// below classifies it.
|
||||||
"2006-01-02T15:04:05.999999999Z07:00",
|
type dateTimeKind int
|
||||||
"2006-01-02T15:04:05Z07:00",
|
|
||||||
"2006-01-02 15:04:05.999999999Z07:00",
|
const (
|
||||||
"2006-01-02 15:04:05Z07:00",
|
dateTimeNone dateTimeKind = iota
|
||||||
// TOML 1.1 makes the seconds optional.
|
dateTimeOffset
|
||||||
"2006-01-02T15:04Z07:00",
|
dateTimeLocal
|
||||||
"2006-01-02 15:04Z07:00",
|
dateTimeDate
|
||||||
}
|
dateTimeClock
|
||||||
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",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// dateTimeShape enforces the strict TOML grammar (two-digit components,
|
// The layouts the time package parses each shape with. Parsing accepts a
|
||||||
// seconds optional since 1.1, a fraction only after seconds) that time.Parse
|
// fractional second even when the layout does not carry one, so each shape
|
||||||
// would otherwise accept loosely (e.g. a single-digit hour).
|
// needs a single layout, chosen by whether the token has seconds.
|
||||||
var dateTimeShape = regexp.MustCompile(
|
const (
|
||||||
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
|
offsetDateTimeLayout = "2006-01-02T15:04:05Z07:00"
|
||||||
`|^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$`,
|
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
|
// scanDateTimeShape validates a bare token against the strict TOML date-time
|
||||||
// to 00:00 through 23:59, but time.Parse accepts values outside that range
|
// grammar and reports which kind it is: two-digit components, seconds
|
||||||
// and rolls them over (for example "+00:60" becomes "+01:00"), so the bounds
|
// optional since TOML 1.1, a fraction only after seconds, an offset only
|
||||||
// are enforced here.
|
// after a time, and an offset bounded to 00:00 through 23:59. The grammar is
|
||||||
var offsetBounds = regexp.MustCompile(`([+-])(\d{2}):(\d{2})$`)
|
// 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.
|
// 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.
|
// LocalTime) and whether the token was a date-time at all.
|
||||||
func parseDateTime(tok string) (any, bool) {
|
func parseDateTime(tok string) (any, bool) {
|
||||||
if tok == "" || tok[0] < '0' || tok[0] > '9' {
|
if tok == "" || tok[0] < '0' || tok[0] > '9' {
|
||||||
@@ -127,36 +243,49 @@ func parseDateTime(tok string) (any, bool) {
|
|||||||
if !strings.ContainsAny(tok, "-:") {
|
if !strings.ContainsAny(tok, "-:") {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if !dateTimeShape.MatchString(tok) {
|
kind, seconds := scanDateTimeShape(tok)
|
||||||
|
if kind == dateTimeNone {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if m := offsetBounds.FindStringSubmatch(tok); m != nil {
|
norm := normaliseDateTimeToken(tok, kind)
|
||||||
hour, _ := strconv.Atoi(m[2])
|
switch kind {
|
||||||
minute, _ := strconv.Atoi(m[3])
|
case dateTimeOffset:
|
||||||
if hour > 23 || minute > 59 {
|
layout := offsetClockLayout
|
||||||
|
if seconds {
|
||||||
|
layout = offsetDateTimeLayout
|
||||||
|
}
|
||||||
|
t, err := time.Parse(layout, norm)
|
||||||
|
if err != nil {
|
||||||
return nil, false
|
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
|
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
|
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
|
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 LocalTime{t}, true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user