// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Read an RFC 5322 date-time, `Tue, 1 Jul 2003 10:52:37 +0200`, with the
/// obsolete forms of section 4.3: two- and three-digit years, a missing
/// second, the named zones of RFC 822 and comments anywhere. A zone of
/// `-0000` or an unknown name reads as UTC, as section 3.3 says it means.
pub fn parse_date(text : StringView) -> @moondate.Moment raise Refused {
let words = date_words(text)
let mut i = 0
if i < words.length() && words[i].has_suffix(",") {
i += 1
} else if i < words.length() &&
month_of(words[i]) is None &&
number(words[i]) is None {
// A weekday without its comma.
i += 1
}
guard i + 4 <= words.length() else { raise Date }
guard number(words[i]) is Some(day) else { raise Date }
guard month_of(words[i + 1]) is Some(month) else { raise Date }
guard number(words[i + 2]) is Some(written) else { raise Date }
let year = match words[i + 2].length() {
2 => if written < 50 { 2000 + written } else { 1900 + written }
3 => 1900 + written
_ => written
}
let clock = words[i + 3].split(":").to_array()
let (hour, minute, second) = match clock.map(c => number(c.to_owned())) {
[Some(h), Some(m)] => (h, m, 0)
[Some(h), Some(m), Some(s)] => (h, m, s)
_ => raise Date
}
let zone = if i + 4 < words.length() {
zone_of(words[i + 4])
} else {
@moondate.Utc
}
let date = @moondate.Date::new(year, month, day) catch { _ => raise Date }
// A leap second is read as the last second of its minute.
let time = @moondate.Time::new(
hour,
minute,
second=if second == 60 { 59 } else { second },
) catch {
_ => raise Date
}
@moondate.Moment::combine(date, time, zone~)
}
///|
/// A date-time as RFC 5322 writes it, `Tue, 01 Jul 2003 10:52:37 +0200`.
pub fn format_date(moment : @moondate.Moment) -> String {
moment.strftime("%a, %d %b %Y %H:%M:%S %z")
}
///|
/// The words of a date, comments dropped.
fn date_words(text : StringView) -> Array[String] {
let out = StringBuilder()
let mut depth = 0
for c in text {
if c == '(' {
depth += 1
} else if c == ')' && depth > 0 {
depth -= 1
} else if depth == 0 {
out.write_char(if c == '\t' || c == '\r' || c == '\n' { ' ' } else { c })
}
}
out
.to_string()
.split(" ")
.filter(w => !w.is_empty())
.map(w => w.to_owned())
.to_array()
}
///|
fn number(word : String) -> Int32? {
if word.is_empty() || word.length() > 9 {
return None
}
let mut n = 0
for c in word {
guard c >= '0' && c <= '9' else { return None }
n = n * 10 + (c.to_int() - '0'.to_int())
}
Some(n)
}
///|
fn month_of(word : String) -> Int32? {
let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov",
"dec",
]
let key = word.to_lower()
match months.search_by(m => key.has_prefix(m)) {
Some(i) => Some(i + 1)
None => None
}
}
///|
/// `+hhmm` or `-hhmm`, or a zone named by RFC 822.
fn zone_of(word : String) -> @moondate.Zone {
let view = word[:]
if view.length() == 5 && (view[0] == '+' || view[0] == '-') {
match number(view[1:].to_owned()) {
Some(n) => {
let minutes = n / 100 * 60 + n % 100
if minutes == 0 && view[0] == '-' {
return @moondate.Utc
}
return @moondate.Offset(if view[0] == '-' { -minutes } else { minutes })
}
None => ()
}
}
match word.to_upper() {
"EDT" => @moondate.Offset(-4 * 60)
"EST" | "CDT" => @moondate.Offset(-5 * 60)
"CST" | "MDT" => @moondate.Offset(-6 * 60)
"MST" | "PDT" => @moondate.Offset(-7 * 60)
"PST" => @moondate.Offset(-8 * 60)
"UT" | "GMT" => @moondate.Offset(0)
_ => @moondate.Utc
}
}