Files
interpres/datetime.go
T

180 lines
6.0 KiB
Go
Raw Normal View History

2026-08-19 09:47:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package interpres
import (
"fmt"
"regexp"
"strconv"
2026-08-19 09:47:00 +02:00
"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 }
2026-08-19 09:47:00 +02:00
// 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) }
2026-08-19 09:47:00 +02:00
// 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.
2026-08-19 09:47:00 +02:00
func (ldt LocalDateTime) String() string {
return ldt.Format("2006-01-02T") + clockString(ldt.Time)
2026-08-19 09:47:00 +02:00
}
// 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) }
// 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.
func clockString(t time.Time) string {
out := t.Format("15:04")
ns := t.Nanosecond()
if t.Second() != 0 || ns != 0 {
out += t.Format(":05")
2026-08-19 09:47:00 +02:00
}
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
// 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")
2026-08-19 09:47:00 +02:00
}
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",
2026-09-17 22:06:26 +02:00
// TOML 1.1 makes the seconds optional.
"2006-01-02T15:04Z07:00",
"2006-01-02 15:04Z07:00",
2026-08-19 09:47:00 +02: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",
2026-09-17 22:06:26 +02:00
"2006-01-02T15:04",
"2006-01-02 15:04",
2026-08-19 09:47:00 +02:00
}
localTimeLayouts = []string{
"15:04:05.999999999",
"15:04:05",
2026-09-17 22:06:26 +02:00
"15:04",
2026-08-19 09:47:00 +02:00
}
)
2026-09-17 22:06:26 +02:00
// 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).
2026-08-19 09:47:00 +02:00
var dateTimeShape = regexp.MustCompile(
2026-09-17 22:06:26 +02:00
`^\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+)?)?$`,
2026-08-19 09:47:00 +02:00
)
// 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})$`)
2026-08-19 09:47:00 +02:00
// parseDateTime classifies and parses a bare token as a TOML date-time value.
// It returns the decoded value (time.Time, 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
}
if !dateTimeShape.MatchString(tok) {
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 {
return nil, false
}
}
2026-08-19 09:47:00 +02:00
// 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
2026-08-19 09:47:00 +02:00
}
}
for _, layout := range localDateTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil {
return LocalDateTime{t}, true
}
}
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
}
}
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
}