// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package interpres import ( "fmt" "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 } // 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) } // 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 { 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. // "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) } // 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 { 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 { buf := t.AppendFormat(make([]byte, 0, 32), "2006-01-02T") buf = appendClock(buf, t) buf = t.AppendFormat(buf, "Z07:00") return string(buf) } // 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 ) // 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" ) // 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 (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' { return nil, false } if !strings.ContainsAny(tok, "-:") { return nil, false } kind, seconds := scanDateTimeShape(tok) if kind == dateTimeNone { return nil, false } 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 } // A zero offset carries its own anonymous location from time.Parse, // while the written form is "Z" either way; normalising to UTC keeps // the tree identical across the round trip. if _, off := t.Zone(); off == 0 { t = t.In(time.UTC) } return OffsetDateTime{t}, true case dateTimeLocal: layout := localClockLayout if seconds { layout = localDateTimeLayout } 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 } return LocalDate{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 } // 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 }