// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package interpres import ( "fmt" "regexp" "strconv" "strings" "time" ) // TOML distinguishes four date-time kinds. interpres decodes an offset // date-time to a plain time.Time (it carries a zone), and uses the wrapper // types below for the local variants so callers can tell them apart. // 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 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. func (ldt LocalDateTime) String() string { return ldt.Format("2006-01-02T") + clockString(ldt.Time) } // 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") } 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") } 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", } ) // 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+)?)?$`, ) // 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})$`) // 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 } } // 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 t, true } } 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 "datetime". 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 }