diff --git a/datetime.go b/datetime.go index 481fd81..54b6e4f 100644 --- a/datetime.go +++ b/datetime.go @@ -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,35 +243,48 @@ 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 + return OffsetDateTime{t}, true + case dateTimeLocal: + layout := localClockLayout + if seconds { + layout = localDateTimeLayout } - } - for _, layout := range localDateTimeLayouts { - if t, err := time.Parse(layout, norm); err == nil { - return LocalDateTime{t}, true + 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 } - } - if t, err := time.Parse("2006-01-02", norm); err == nil { return LocalDate{t}, true - } - for _, layout := range localTimeLayouts { - if t, err := time.Parse(layout, norm); err == nil { - return LocalTime{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 }