perf(datetime): scan the token shape and render in one pass
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