// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A moment read from RFC 3339 text, in whichever of the four shapes it is written.
///
/// `2026-09-22T14:30:00Z` and `…+08:00` are `Zoned`; without an offset it is
/// `Plain`; a bare `2026-09-22` is `Day` and a bare `14:30:00` is `Clock`. TOML
/// 1.0.0 names these four and allows a space in place of the `T`, which is what a
/// person writes; RFC 3339 §5.6 allows it too, under NOTE.
///
/// A year outside 0000 to 9999 keeps the expanded form Temporal writes for it —
/// `+012026-01-02T03:04:05Z`, `-000001-01-02` — so what [`Moment::isoformat`]
/// writes reads back.
///
/// A name in brackets is read when the name is an offset — `[UTC]`, `[+09:00]`,
/// `[-0430]` — because then it says when the moment was by itself. A name that
/// is a place, `[America/New_York]`, is kept when the text carries the offset
/// beside it, and refused when it does not: the place alone does not say which
/// side of a transition the clock was on.
pub fn parse(src : StringView) -> Moment raise Refused {
let s = spaced(src.to_owned())
// A bare time has a colon where a date would have a dash.
if s.length() >= 5 && at(s, 2) == ':' {
return Clock(time=time_of(s, 0, s.length()))
}
// An expanded year is a sign and six digits, and the month and day follow the
// dash after it; `rest` is where `MM-DD` starts.
let mut rest = 5
let mut year : Int32? = None
if s.length() >= 8 && (at(s, 0) == '+' || at(s, 0) == '-') && at(s, 7) == '-' {
let y = digits(s, 1, 6)
year = Some(if at(s, 0) == '-' { -y } else { y })
rest = 8
}
if s.length() < rest + 5 {
raise Truncated("a date needs ten characters")
}
if at(s, rest + 2) != '-' {
raise Malformed("a date is YYYY-MM-DD")
}
let date = match year {
Some(y) => Date::new(y, digits(s, rest, 2), digits(s, rest + 3, 2))
None => date_of(s, 0)
}
// The annotations come after everything else — the zone the moment was read in
// and the calendar it was written in — so they are found first and set the end
// of the text the date and time have to fill.
let mut bracket = s.length()
for i = rest + 5; i < s.length(); i = i + 1 {
if at(s, i) == '[' {
bracket = i
break
}
}
let notes = if bracket < s.length() {
notes_of(s, bracket)
} else {
no_notes()
}
if bracket == rest + 5 {
if notes.zone == "" {
return Day(date~)
}
// A date on its own, with the offset given in the annotation:
// `2020-01-01[+09:00]`, which is midnight where that offset runs.
match offset_of_name(notes.zone) {
Some(minutes) =>
return Zoned(
date~,
time=Time::new(0, 0),
zone=named(notes.zone, minutes),
)
None => raise Malformed("a bracketed zone needs an offset to go with it")
}
}
let sep = at(s, rest + 5)
if sep != 'T' && sep != 't' && sep != ' ' {
raise Malformed("a date and a time are joined by T or a space")
}
let name = notes.zone
// The offset, if any, begins at the first Z or at a sign that is not part of the
// time — which is any sign at all, since the time itself carries none.
let mut cut = bracket
for i = rest + 6; i < bracket; i = i + 1 {
let c = at(s, i)
if c == 'Z' || c == 'z' || c == '+' || c == '-' {
cut = i
break
}
}
let time = time_of(s, rest + 6, cut)
if cut == bracket {
if name == "" {
return Plain(date~, time~)
}
// A name that is an offset says when it was all by itself, the way a trailing
// `Z` does. A name that is a place does not: two in the morning in Paris is
// one instant in July and another in January.
match offset_of_name(name) {
Some(minutes) => return Zoned(date~, time~, zone=named(name, minutes))
None => raise Malformed("a bracketed zone needs an offset to go with it")
}
}
Zoned(date~, time~, zone=with_name(zone_of(s, cut, bracket), name))
}
///|
/// The text with the separators ISO 8601 leaves out put back.
///
/// The basic form is the same grammar with three separators dropped —
/// `19761118T152330-0800` is `1976-11-18T15:23:30-08:00` — so it is rewritten here
/// and the reader below keeps one shape in mind. A text that is already extended
/// comes back unchanged, and so does one this does not recognise, which then fails
/// where it always did.
fn spaced(src : String) -> String {
let s = src
let n = s.length()
let mut bracket = n
for i = 0; i < n; i = i + 1 {
if at(s, i) == '[' {
bracket = i
break
}
}
let mut sep = -1
for i = 0; i < bracket; i = i + 1 {
let c = at(s, i)
if c == 'T' || c == 't' || c == ' ' {
sep = i
break
}
}
let date = spaced_date(s[:if sep < 0 { bracket } else { sep }].to_owned())
let tail = s[bracket:].to_owned()
if sep < 0 {
return date + tail
}
date + "T" + spaced_clock(s[sep + 1:bracket].to_owned()) + tail
}
///|
/// `19761118` as `1976-11-18`, and `+0019761118` as `+001976-11-18`.
fn spaced_date(date : String) -> String {
if date.contains("-") {
return date
}
let n = date.length()
let mut from = 0
if n > 0 && (at(date, 0) == '+' || at(date, 0) == '-') {
from = 1
}
let year_width = if from == 1 { 6 } else { 4 }
if n - from != year_width + 4 {
return date
}
for i = from; i < n; i = i + 1 {
let c = at(date, i)
if c < '0' || c > '9' {
return date
}
}
let cut = from + year_width
date[:cut].to_owned() +
"-" +
date[cut:cut + 2].to_owned() +
"-" +
date[cut + 2:].to_owned()
}
///|
/// `152330.1` as `15:23:30.1`, `1500` as `15:00`, and the offset beside them.
fn spaced_clock(clock : String) -> String {
let n = clock.length()
let mut cut = n
for i = 0; i < n; i = i + 1 {
let c = at(clock, i)
if c == 'Z' || c == 'z' || c == '+' || c == '-' {
cut = i
break
}
}
spaced_time(clock[:cut].to_owned()) + spaced_offset(clock[cut:].to_owned())
}
///|
/// `152330` as `15:23:30`, and `1500` as `15:00`.
fn spaced_time(time : String) -> String {
if time.contains(":") {
return time
}
let lead = leading_digits(time)
if lead == 6 {
time[:2].to_owned() + ":" + time[2:4].to_owned() + ":" + time[4:].to_owned()
} else if lead == 4 {
time[:2].to_owned() + ":" + time[2:].to_owned()
} else {
time
}
}
///|
/// `-0800` as `-08:00`, `-08` as `-08:00`, `-080000` as `-08:00:00`.
fn spaced_offset(offset : String) -> String {
let n = offset.length()
if n == 0 {
return offset
}
let c = at(offset, 0)
if c == 'Z' || c == 'z' || offset.contains(":") {
return offset
}
// The fraction, when there is one, stays as it was: only the separators between
// the fields are missing.
let mut cut = n
for i = 0; i < n; i = i + 1 {
let mark = at(offset, i)
if mark == '.' || mark == ',' {
cut = i
break
}
}
let body = offset[:cut].to_owned()
let fraction = offset[cut:].to_owned()
let spaced = if body.length() == 3 {
body + ":00"
} else if body.length() == 5 {
body[:3].to_owned() + ":" + body[3:].to_owned()
} else if body.length() == 7 {
body[:3].to_owned() + ":" + body[3:5].to_owned() + ":" + body[5:].to_owned()
} else {
body
}
spaced + fraction
}
///|
/// How many digits `text` opens with.
fn leading_digits(text : String) -> Int32 {
let n = text.length()
let mut k = 0
while k < n && at(text, k) >= '0' && at(text, k) <= '9' {
k = k + 1
}
k
}
///|
/// What the annotations say. The only calendar this library has is the ISO one, so
/// a calendar annotation is checked and then dropped; the zone is the part that
/// outlives the text.
priv struct Notes {
/// The zone's name, or "" when the text names none.
zone : String
}
///|
/// A text with no annotations at all.
fn no_notes() -> Notes {
{ zone: "", }
}
///|
/// The annotations at `from`, which run to the end of the text.
///
/// A bracket holds either a zone — a place or an offset — or a `key=value`
/// annotation. The zone comes first when both are there, a `!` before a key means
/// the reader has to understand it, and a key that is not `u-ca` is someone else's
/// business and is ignored unless it is critical. This is Temporal's grammar, and
/// the cases it turns on are the ones that say what to do with
/// `[u-ca=iso8601]`, `[foo=bar]` and `[!foo=bar]`.
fn notes_of(s : String, from : Int32) -> Notes raise Refused {
if at(s, s.length() - 1) != ']' {
raise Malformed("an annotation is written [key=value]")
}
let mut zone = ""
let mut seen_calendar = false
let mut i = from
while i < s.length() {
if at(s, i) != '[' {
raise Malformed("an annotation is written [key=value]")
}
let mut end = i + 1
while end < s.length() && at(s, end) != ']' {
end = end + 1
}
if end >= s.length() {
raise Malformed("an annotation is written [key=value]")
}
let note = s[i + 1:end].to_owned()
if note.length() == 0 {
raise Malformed("an empty annotation")
}
// A note with an `=` in it is a key and a value; anything else is a zone.
let mut eq = -1
for k = 0; k < note.length(); k = k + 1 {
if at(note, k) == '=' {
eq = k
break
}
}
if eq >= 0 {
let (key, value, critical) = split_key(note, eq)
if key == "u-ca" {
if seen_calendar {
raise Malformed("two calendars in one text")
}
// `gregory` names the same year, month and day as ISO in Temporal — it is
// proleptic, with no Julian switch — so the two are one calendar here.
if !same_text(value, "iso8601") && !same_text(value, "gregory") {
raise Malformed(
"the calendars here are iso8601 and gregory, not " + value,
)
}
seen_calendar = true
} else if critical {
raise Malformed("this reader does not understand " + note)
}
} else {
if zone != "" {
raise Malformed("a text names one zone, not two")
}
if seen_calendar {
raise Malformed("a zone comes before the annotations that follow it")
}
// `[!UTC]` is a zone the reader has to understand, and this one does.
zone = if at(note, 0) == '!' { note[1:].to_owned() } else { note }
if zone.length() == 0 {
raise Malformed("an empty annotation")
}
// A zone name keeps the minute: `[+01:35]` is a name, `[+01:35:00]` is not.
if at(zone, 0) == '+' || at(zone, 0) == '-' {
match offset_of_name(zone) {
Some(_) => ()
None =>
raise Malformed(
"a zone name has no seconds: [+01:35], not [+01:35:00]",
)
}
}
}
i = end + 1
}
{ zone, }
}
///|
/// `key=value` split three ways at `at_eq`, with the `!` that marks it critical
/// taken off the key. A key or a value that breaks Temporal's grammar is refused
/// rather than read as an unknown one, which is what `[FOO=bar]` is testing.
fn split_key(
note : String,
at_eq : Int32,
) -> (String, String, Bool) raise Refused {
let mut critical = false
let mut start = 0
if at(note, 0) == '!' {
critical = true
start = 1
}
let key = note[start:at_eq].to_owned()
let value = note[at_eq + 1:].to_owned()
// Temporal's grammar for a key is lowercase letters, digits, dashes and
// underscores — `[_foo-bar0=…]` is a key — and for a value the same plus
// capitals.
if key.length() == 0 || value.length() == 0 {
raise Malformed("an annotation is key=value")
}
for i = 0; i < key.length(); i = i + 1 {
let c = at(key, i)
if !(c >= 'a' && c <= 'z') &&
!(c >= '0' && c <= '9') &&
c != '-' &&
c != '_' {
raise Malformed(
"an annotation key is lowercase letters, digits and dashes",
)
}
}
for i = 0; i < value.length(); i = i + 1 {
let c = at(value, i)
if !(c >= 'a' && c <= 'z') &&
!(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') &&
c != '-' &&
c != '_' {
raise Malformed(
"an annotation value is letters, digits, dashes and underscores",
)
}
}
(key, value, critical)
}
///|
/// Whether two texts are the same but for the case of their letters.
fn same_text(a : String, b : String) -> Bool {
if a.length() != b.length() {
return false
}
for i = 0; i < a.length(); i = i + 1 {
let left = at(a, i)
let right = at(b, i)
let lower = fn(c : Char) -> Char {
if c >= 'A' && c <= 'Z' {
(c.to_int() + 32).unsafe_to_char()
} else {
c
}
}
if lower(left) != lower(right) {
return false
}
}
true
}
///|
/// A zone known only by the name and the offset the text gave it.
fn named(name : String, minutes : Int32) -> Zone {
Named({ name, offset: minutes * 60, abbrev: "", })
}
///|
/// The offset a bracketed name carries, when the name is an offset rather than a
/// place: `UTC`, `+01:00`, `-0430`.
///
/// `[Europe/Paris]` is a place, and a place is not an instant until something
/// says which offset was running; `[+01:00]` is that something.
fn offset_of_name(name : String) -> Int32? {
if name == "UTC" {
return Some(0)
}
let n = name.length()
if n < 3 {
return None
}
let sign = at(name, 0)
if sign != '+' && sign != '-' {
return None
}
let digit_at = fn(i : Int32) -> Int32? {
if i >= n {
return None
}
let c = at(name, i)
if c < '0' || c > '9' {
return None
}
Some(c.to_int() - '0'.to_int())
}
let hours = match (digit_at(1), digit_at(2)) {
(Some(a), Some(b)) => a * 10 + b
_ => return None
}
if hours > 23 {
return None
}
let mut minutes = 0
if n > 3 {
if n == 5 {
minutes = match (digit_at(3), digit_at(4)) {
(Some(a), Some(b)) => a * 10 + b
_ => return None
}
} else if n == 6 && at(name, 3) == ':' {
minutes = match (digit_at(4), digit_at(5)) {
(Some(a), Some(b)) => a * 10 + b
_ => return None
}
} else {
return None
}
}
if minutes > 59 {
return None
}
let total = hours * 60 + minutes
Some(if sign == '-' { -total } else { total })
}
///|
/// The zone a moment carries, with the name the text gave it.
fn with_name(zone : Zone, name : String) -> Zone {
if name == "" {
zone
} else {
Named({ name, offset: zone.seconds(), abbrev: "", })
}
}
///|
/// The ten characters `YYYY-MM-DD` at `from`.
fn date_of(s : String, from : Int32) -> Date raise Refused {
if at(s, from + 4) != '-' || at(s, from + 7) != '-' {
raise Malformed("a date is YYYY-MM-DD")
}
Date::new(digits(s, from, 4), digits(s, from + 5, 2), digits(s, from + 8, 2))
}
///|
/// `HH:MM`, `HH:MM:SS` or `HH:MM:SS.fff…` between `from` and `end`.
///
/// RFC 3339 requires the seconds; TOML's local time allows them to be left off, and
/// so does ISO 8601, so they are optional here.
fn time_of(s : String, from : Int32, end : Int32) -> Time raise Refused {
// A clock of one field is the hour and nothing else, which is what ISO 8601
// calls reduced precision and what `1970-01-01T00Z` means.
if end - from == 2 {
return Time::new(digits(s, from, 2), 0)
}
if end - from < 5 || at(s, from + 2) != ':' {
raise Malformed("a time is HH:MM[:SS[.fff]]")
}
let hour = digits(s, from, 2)
let minute = digits(s, from + 3, 2)
if end - from == 5 {
return Time::new(hour, minute)
}
if at(s, from + 5) != ':' {
raise Malformed("a time is HH:MM[:SS[.fff]]")
}
let second = digits(s, from + 6, 2)
if end - from == 8 {
return Time::new(hour, minute, second~)
}
if at(s, from + 8) != '.' {
raise Malformed("a fraction of a second begins with a dot")
}
// Nine digits is a nanosecond; more are read and dropped, which is what every
// parser does with a precision it cannot hold.
let mut nano = 0
let mut seen = 0
for i = from + 9; i < end; i = i + 1 {
let d = digit(s, i)
if seen < 9 {
nano = nano * 10 + d
seen = seen + 1
}
}
if seen == 0 {
raise Malformed("a dot with no digits after it")
}
for i = seen; i < 9; i = i + 1 {
nano = nano * 10
}
Time::new(hour, minute, second~, nano~)
}
///|
/// `Z`, `+HH:MM` or `-HH:MM` at `from`.
///
/// RFC 3339 §5.6 has no seconds field, so `+HH:MM:SS` is read for Temporal's sake:
/// its grammar allows the seconds, and a zone read before standard time needs them
/// to write an exact instant.
fn zone_of(s : String, from : Int32, end : Int32) -> Zone raise Refused {
let c = at(s, from)
if c == 'Z' || c == 'z' {
if end != from + 1 {
raise Malformed("trailing characters after Z")
}
return Utc
}
let width = end - from
if width < 6 || at(s, from + 3) != ':' {
raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
}
let hours = digits(s, from + 1, 2)
let minutes = digits(s, from + 4, 2)
if hours > 23 {
raise Range(field="offset hour", got=hours, low=0, high=23)
}
if minutes > 59 {
raise Range(field="offset minute", got=minutes, low=0, high=59)
}
let mut seconds = 0
let mut fraction = 0L
let mut k = from + 6
if k < end {
if at(s, k) != ':' {
raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
}
if end - k < 3 {
raise Truncated("an offset second is two digits")
}
seconds = digits(s, k + 1, 2)
if seconds > 59 {
raise Range(field="offset second", got=seconds, low=0, high=59)
}
k = k + 3
if k < end {
let mark = at(s, k)
if mark != '.' && mark != ',' {
raise Malformed("an offset is ±HH:MM[:SS[:fff]]")
}
k = k + 1
if k == end {
raise Malformed("a dot with no digits after it")
}
if end - k > 9 {
raise Malformed("a fraction of a second is nine digits at most")
}
// A fraction of a second is kept as far as nine digits, which is what a
// nanosecond is.
let mut seen = 0
while k < end {
if seen < 9 {
fraction = fraction * 10L + digit(s, k).to_int64()
seen = seen + 1
}
k = k + 1
}
for i = seen; i < 9; i = i + 1 {
fraction = fraction * 10L
}
}
}
let mut nanos = (hours * 3600 + minutes * 60 + seconds).to_int64() *
1_000_000_000L +
fraction
if c == '-' {
nanos = -nanos
}
// A whole number of minutes is the ordinary case and keeps the ordinary shape;
// everything else has a precision `Offset` cannot hold.
if nanos % 60_000_000_000L == 0L {
Offset((nanos / 60_000_000_000L).to_int())
} else {
Precise(nanos)
}
}
///|
/// The character at `i`, or a space past the end so a caller's comparison fails
/// rather than reading out of bounds.
fn at(s : String, i : Int32) -> Char {
if i >= 0 && i < s.length() {
s[i].unsafe_to_char()
} else {
' '
}
}
///|
/// One digit at `i`.
fn digit(s : String, i : Int32) -> Int32 raise Refused {
let c = at(s, i)
if c < '0' || c > '9' {
raise Malformed("expected a digit at \{i}")
}
c.to_int() - 48
}
///|
/// `n` digits starting at `from`, as the number they spell.
fn digits(s : String, from : Int32, n : Int32) -> Int32 raise Refused {
let mut out = 0
for i = 0; i < n; i = i + 1 {
out = out * 10 + digit(s, from + i)
}
out
}
// -- writing ------------------------------------------------------------------
///|
/// A moment as RFC 3339 text, in the shape it is.
///
/// This is the inverse of [`parse`] and the form TOML writes, so a document read
/// and written again carries the same timestamps.
pub impl Show for Moment with fn output(self, logger) {
logger.write_string(
match self {
Zoned(date~, time~, zone~) =>
text_of(date) + "T" + text_of_time(time) + text_of_zone(zone)
Plain(date~, time~) => text_of(date) + "T" + text_of_time(time)
Day(date~) => text_of(date)
Clock(time~) => text_of_time(time)
},
)
}
///|
pub extend Moment with Show::{to_string, output}
///|
pub impl Show for Date with fn output(self, logger) {
logger.write_string(text_of(self))
}
///|
pub extend Date with Show::{to_string, output}
///|
pub impl Show for Time with fn output(self, logger) {
logger.write_string(text_of_time(self))
}
///|
pub extend Time with Show::{to_string, output}
///|
/// `YYYY-MM-DD`, with a year past four digits written out in full.
fn text_of(d : Date) -> String {
let sign = if d.year < 0 { "-" } else { "" }
let y = if d.year < 0 { -d.year } else { d.year }
sign + pad(y, 4) + "-" + pad(d.month, 2) + "-" + pad(d.day, 2)
}
///|
/// `HH:MM:SS`, with `.fff…` only when there is a fraction to write.
fn text_of_time(t : Time) -> String {
let head = pad(t.hour, 2) + ":" + pad(t.minute, 2) + ":" + pad(t.second, 2)
if t.nano == 0 {
return head
}
// Trailing zeros are dropped, so a millisecond reads as one.
let mut frac = pad(t.nano, 9)
while frac.length() > 1 && frac[frac.length() - 1].to_int() == 48 {
frac = frac[0:frac.length() - 1].to_owned()
}
head + "." + frac
}
///|
/// The offset part of a moment, in the minutes RFC 3339 has room for.
///
/// A named zone whose local mean time is not a whole number of minutes writes the
/// offset rounded towards zero: the standard has no field for the remainder, and
/// the value keeps it.
fn text_of_zone(z : Zone) -> String {
match z {
Utc => "Z"
// A fixed offset finer than a minute keeps its seconds and its fraction; the
// rest are whole minutes and are written as such.
Precise(nanos) => hhmmss_nanos(nanos)
_ => hhmm(z.minutes())
}
}
///|
/// `±HH:MM`.
fn hhmm(minutes : Int32) -> String {
let sign = if minutes < 0 { "-" } else { "+" }
let a = if minutes < 0 { -minutes } else { minutes }
sign + pad(a / 60, 2) + ":" + pad(a % 60, 2)
}
///|
/// `±HH:MM`, with `:SS` only when the offset is not a whole number of minutes.
///
/// Temporal's ISO grammar allows the seconds; RFC 3339 does not, so this is the
/// one form written there.
fn hhmmss(seconds : Int32) -> String {
let a = if seconds < 0 { -seconds } else { seconds }
let head = hhmm(seconds / 60)
if a % 60 == 0 {
head
} else {
head + ":" + pad(a % 60, 2)
}
}
///|
/// `n` written with at least `width` digits.
fn pad(n : Int32, width : Int32) -> String {
let s = n.to_string()
if s.length() >= width {
return s
}
let out = StringBuilder()
for i = s.length(); i < width; i = i + 1 {
out.write_char('0')
}
out.write_string(s)
out.to_string()
}
// -- HTTP ---------------------------------------------------------------------
///|
/// The three-letter day names IMF-fixdate uses, Sunday first.
let weekdays : Array[String] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
///|
/// The three-letter month names, January first.
let months : Array[String] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
///|
/// A moment as an HTTP date (RFC 9110 §5.6.7): `Sun, 06 Nov 1994 08:49:37 GMT`.
///
/// This is the one form a `Date` header may take. The two obsolete forms are read
/// by [`http`] and never written, which is what §5.6.7 asks for.
pub fn Moment::http_text(self : Moment) -> String raise Refused {
let (d, t) = match self {
Zoned(date~, time~, zone~) => {
// The header is always GMT, so an offset moment is moved onto it first.
let shift = zone.minutes()
match
Zoned(date~, time~, zone=Utc).plus(Span::of(minute, -shift.to_int64())) {
Zoned(date~, time~, ..) => (date, time)
_ => (date, time)
}
}
Plain(date~, time~) => (date, time)
Day(date~) => (date, { hour: 0, minute: 0, second: 0, nano: 0, })
Clock(_) => raise Malformed("a time with no date is not an HTTP date")
}
weekdays[d.weekday()] +
", " +
pad(d.day, 2) +
" " +
months[d.month - 1] +
" " +
http_year(d.year) +
" " +
pad(t.hour, 2) +
":" +
pad(t.minute, 2) +
":" +
pad(t.second, 2) +
" GMT"
}
///|
/// A year as the HTTP date forms write it: at least four digits, with a minus for
/// a negative year and nothing extra for one that needs five, which is what
/// ECMAScript's `toUTCString` does.
fn http_year(year : Int32) -> String {
if year < 0 {
"-" + pad(-year, 4)
} else {
pad(year, 4)
}
}
///|
/// An HTTP date read (RFC 9110 §5.6.7).
///
/// The preferred IMF-fixdate, and the two obsolete forms a recipient must still
/// accept: RFC 850's `Sunday, 06-Nov-94 08:49:37 GMT` and asctime's
/// `Sun Nov 6 08:49:37 1994`. A two-digit year is read as RFC 6265 §5.1.1 says:
/// 69 and under are 2000s, 70 and over are 1900s.
pub fn http(src : StringView) -> Moment raise Refused {
let s = src.to_owned()
let comma = find(s, ',')
if comma < 0 {
return asctime(s)
}
let rest = trim(s[comma + 1:].to_owned())
// IMF-fixdate has spaces around the month; RFC 850 has dashes.
if rest.length() >= 11 && at(rest, 2) == '-' {
let year = digits(rest, 7, 2)
return Zoned(
date=Date::new(
if year <= 69 {
2000 + year
} else {
1900 + year
},
month_of(rest[3:6].to_owned()),
digits(rest, 0, 2),
),
time=time_of(rest, 10, 18),
zone=Utc,
)
}
if rest.length() < 20 {
raise Truncated("an HTTP date")
}
Zoned(
date=Date::new(
digits(rest, 7, 4),
month_of(rest[3:6].to_owned()),
digits(rest, 0, 2),
),
time=time_of(rest, 12, 20),
zone=Utc,
)
}
///|
/// The asctime form, whose day is space-padded rather than zero-padded.
fn asctime(s : String) -> Moment raise Refused {
let t = trim(s)
if t.length() < 24 {
raise Truncated("an asctime date")
}
let day = if at(t, 8) == ' ' { digits(t, 9, 1) } else { digits(t, 8, 2) }
Zoned(
date=Date::new(digits(t, 20, 4), month_of(t[4:7].to_owned()), day),
time=time_of(t, 11, 19),
zone=Utc,
)
}
///|
/// A three-letter month name as its number.
fn month_of(name : String) -> Int32 raise Refused {
for i = 0; i < 12; i = i + 1 {
if months[i] == name {
return i + 1
}
}
raise Malformed("not a month name: " + name)
}
///|
fn find(s : String, c : Char) -> Int32 {
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == c.to_int() {
return i
}
}
-1
}
///|
fn trim(s : String) -> String {
let mut a = 0
let mut b = s.length()
while a < b && (s[a].to_int() == 0x20 || s[a].to_int() == 0x09) {
a = a + 1
}
while b > a && (s[b - 1].to_int() == 0x20 || s[b - 1].to_int() == 0x09) {
b = b - 1
}
s[a:b].to_owned()
}
// -- ASN.1 --------------------------------------------------------------------
///|
/// An ASN.1 UTCTime or GeneralizedTime (ITU-T X.680 §47), as DER writes them.
///
/// This is how a certificate spells its validity. DER requires the `Z` and whole
/// seconds; UTCTime's two-digit year is read as RFC 5280 §4.1.2.5.1 says — 49 and
/// under are 2000s, 50 and over are 1900s, which is not the same rule HTTP uses.
pub fn asn1(src : StringView) -> Moment raise Refused {
let s = src.to_owned()
let (year, from) = if s.length() == 13 {
let y = digits(s, 0, 2)
(if y < 50 { 2000 + y } else { 1900 + y }, 2)
} else if s.length() == 15 {
(digits(s, 0, 4), 4)
} else {
raise Malformed("an ASN.1 time is 13 or 15 characters")
}
if at(s, s.length() - 1) != 'Z' {
raise Malformed("DER requires the Z")
}
Zoned(
date=Date::new(year, digits(s, from, 2), digits(s, from + 2, 2)),
time=Time::new(
digits(s, from + 4, 2),
digits(s, from + 6, 2),
second=digits(s, from + 8, 2),
),
zone=Utc,
)
}
///|
/// A moment as an ASN.1 GeneralizedTime, which is the form DER uses from 2050 on
/// and the one that needs no windowing rule to read back.
pub fn Moment::asn1_text(self : Moment) -> String raise Refused {
match self {
Zoned(date~, time~, ..) | Plain(date~, time~) =>
pad(date.year, 4) +
pad(date.month, 2) +
pad(date.day, 2) +
pad(time.hour, 2) +
pad(time.minute, 2) +
pad(time.second, 2) +
"Z"
_ => raise Malformed("an ASN.1 time needs both a date and a time")
}
}