///|
/// Parsed UTC timestamp and the original offset in seconds.
pub(all) struct ParsedTimestamp {
unix_seconds : Int64
offset_seconds : Int
} derive(Debug, Eq)
///|
/// Errors produced by the restricted ISO-8601 parser.
pub(all) enum TimestampError {
EmptyTimestamp
InvalidLength
InvalidSeparator
InvalidDigit(Int)
InvalidYear(Int)
InvalidMonth(Int)
InvalidDay(Int)
InvalidHour(Int)
InvalidMinute(Int)
InvalidSecond(Int)
InvalidOffset
TrailingContent
} derive(Debug, Eq)
///|
/// Materialize characters for predictable indexed parsing.
fn timestamp_chars(text : String) -> Array[Char] {
let chars : Array[Char] = []
for char in text {
chars.push(char)
}
chars
}
///|
/// Decimal value of an ASCII digit.
fn ascii_digit(char : Char, index : Int) -> Result[Int, TimestampError] {
match char {
'0' => Ok(0)
'1' => Ok(1)
'2' => Ok(2)
'3' => Ok(3)
'4' => Ok(4)
'5' => Ok(5)
'6' => Ok(6)
'7' => Ok(7)
'8' => Ok(8)
'9' => Ok(9)
_ => Err(InvalidDigit(index))
}
}
///|
/// Parse two ASCII digits from a character array.
fn parse_two_digits(
chars : Array[Char],
start : Int,
) -> Result[Int, TimestampError] {
if start < 0 || start + 1 >= chars.length() {
return Err(InvalidLength)
}
match
(ascii_digit(chars[start], start), ascii_digit(chars[start + 1], start + 1)) {
(Ok(a), Ok(b)) => Ok(a * 10 + b)
(Err(error), _) => Err(error)
(_, Err(error)) => Err(error)
}
}
///|
/// Parse four ASCII digits from a character array.
fn parse_four_digits(
chars : Array[Char],
start : Int,
) -> Result[Int, TimestampError] {
if start < 0 || start + 3 >= chars.length() {
return Err(InvalidLength)
}
match
(
ascii_digit(chars[start], start),
ascii_digit(chars[start + 1], start + 1),
ascii_digit(chars[start + 2], start + 2),
ascii_digit(chars[start + 3], start + 3),
) {
(Ok(a), Ok(b), Ok(c), Ok(d)) => Ok(a * 1000 + b * 100 + c * 10 + d)
(Err(error), _, _, _) => Err(error)
(_, Err(error), _, _) => Err(error)
(_, _, Err(error), _) => Err(error)
(_, _, _, Err(error)) => Err(error)
}
}
///|
/// Gregorian leap-year rule.
pub fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
/// Number of days in a Gregorian month, or zero for an invalid month.
pub fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
/// Convert a civil date to days since 1970-01-01.
/// Algorithm adapted from the public-domain civil calendar arithmetic by
/// Howard Hinnant; only the mathematical formula is used.
pub fn days_from_civil(year : Int, month : Int, day : Int) -> Int64 {
let adjusted_year = if month <= 2 { year - 1 } else { year }
let era = if adjusted_year >= 0 {
adjusted_year / 400
} else {
(adjusted_year - 399) / 400
}
let year_of_era = adjusted_year - era * 400
let shifted_month = if month > 2 { month - 3 } else { month + 9 }
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1
let day_of_era = year_of_era * 365 +
year_of_era / 4 -
year_of_era / 100 +
day_of_year
(era * 146097 + day_of_era - 719468).to_int64()
}
///|
/// Convert validated date-time fields and an explicit offset to Unix seconds.
pub fn unix_seconds_from_parts(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
offset_seconds : Int,
) -> Int64 {
let local_seconds = days_from_civil(year, month, day) * 86400L +
hour.to_int64() * 3600L +
minute.to_int64() * 60L +
second.to_int64()
local_seconds - offset_seconds.to_int64()
}
///|
/// Parse `YYYY-MM-DDTHH:MM:SSZ` or the same timestamp with `±HH:MM` offset.
pub fn parse_iso8601(text : String) -> Result[ParsedTimestamp, TimestampError] {
if text.is_empty() {
return Err(EmptyTimestamp)
}
let chars = timestamp_chars(text)
if chars.length() < 20 {
return Err(InvalidLength)
}
if chars[4] != '-' ||
chars[7] != '-' ||
(chars[10] != 'T' && chars[10] != 't' && chars[10] != ' ') ||
chars[13] != ':' ||
chars[16] != ':' {
return Err(InvalidSeparator)
}
let year = match parse_four_digits(chars, 0) {
Ok(value) => value
Err(error) => return Err(error)
}
let month = match parse_two_digits(chars, 5) {
Ok(value) => value
Err(error) => return Err(error)
}
let day = match parse_two_digits(chars, 8) {
Ok(value) => value
Err(error) => return Err(error)
}
let hour = match parse_two_digits(chars, 11) {
Ok(value) => value
Err(error) => return Err(error)
}
let minute = match parse_two_digits(chars, 14) {
Ok(value) => value
Err(error) => return Err(error)
}
let second = match parse_two_digits(chars, 17) {
Ok(value) => value
Err(error) => return Err(error)
}
if year < 1 || year > 9999 {
return Err(InvalidYear(year))
}
if month < 1 || month > 12 {
return Err(InvalidMonth(month))
}
let maximum_day = days_in_month(year, month)
if day < 1 || day > maximum_day {
return Err(InvalidDay(day))
}
if hour < 0 || hour > 23 {
return Err(InvalidHour(hour))
}
if minute < 0 || minute > 59 {
return Err(InvalidMinute(minute))
}
if second < 0 || second > 59 {
return Err(InvalidSecond(second))
}
let mut offset_seconds = 0
if chars.length() == 20 && (chars[19] == 'Z' || chars[19] == 'z') {
offset_seconds = 0
} else if chars.length() == 25 && (chars[19] == '+' || chars[19] == '-') {
if chars[22] != ':' {
return Err(InvalidOffset)
}
let offset_hour = match parse_two_digits(chars, 20) {
Ok(value) => value
Err(_) => return Err(InvalidOffset)
}
let offset_minute = match parse_two_digits(chars, 23) {
Ok(value) => value
Err(_) => return Err(InvalidOffset)
}
if offset_hour > 23 || offset_minute > 59 {
return Err(InvalidOffset)
}
let magnitude = offset_hour * 3600 + offset_minute * 60
offset_seconds = if chars[19] == '-' { -magnitude } else { magnitude }
} else if chars.length() > 20 {
return Err(TrailingContent)
} else {
return Err(InvalidOffset)
}
Ok({
unix_seconds: unix_seconds_from_parts(
year, month, day, hour, minute, second, offset_seconds,
),
offset_seconds,
})
}
///|
/// Floor division for positive divisor, used when formatting pre-epoch values.
fn floor_div_i64(value : Int64, divisor : Int64) -> Int64 {
let quotient = value / divisor
let remainder = value % divisor
if remainder != 0L && value < 0L {
quotient - 1L
} else {
quotient
}
}
///|
/// Convert days since Unix epoch back to Gregorian year, month and day.
pub fn civil_from_days(days : Int64) -> (Int, Int, Int) {
let shifted = days + 719468L
let era = if shifted >= 0L {
shifted / 146097L
} else {
(shifted - 146096L) / 146097L
}
let day_of_era = shifted - era * 146097L
let year_of_era = (
day_of_era -
day_of_era / 1460L +
day_of_era / 36524L -
day_of_era / 146096L
) /
365L
let mut year = (year_of_era + era * 400L).to_int()
let day_of_year = day_of_era -
(365L * year_of_era + year_of_era / 4L - year_of_era / 100L)
let month_prime = (5L * day_of_year + 2L) / 153L
let day = (day_of_year - (153L * month_prime + 2L) / 5L + 1L).to_int()
let month = if month_prime < 10L {
(month_prime + 3L).to_int()
} else {
(month_prime - 9L).to_int()
}
if month <= 2 {
year = year + 1
}
(year, month, day)
}
///|
/// Append a two-digit decimal component.
fn write_two_digits(builder : StringBuilder, value : Int) -> Unit {
if value < 10 {
builder.write_char('0')
}
builder.write_object(value)
}
///|
/// Append a four-digit year.
fn write_four_digits(builder : StringBuilder, value : Int) -> Unit {
if value < 10 {
builder.write_string("000")
} else if value < 100 {
builder.write_string("00")
} else if value < 1000 {
builder.write_char('0')
}
builder.write_object(value)
}
///|
/// Format UTC Unix seconds as canonical `YYYY-MM-DDTHH:MM:SSZ`.
pub fn format_iso8601(unix_seconds : Int64) -> String {
let days = floor_div_i64(unix_seconds, 86400L)
let seconds_of_day = unix_seconds - days * 86400L
let hour = (seconds_of_day / 3600L).to_int()
let minute = (seconds_of_day % 3600L / 60L).to_int()
let second = (seconds_of_day % 60L).to_int()
let (year, month, day) = civil_from_days(days)
let builder = StringBuilder::new()
write_four_digits(builder, year)
builder.write_char('-')
write_two_digits(builder, month)
builder.write_char('-')
write_two_digits(builder, day)
builder.write_char('T')
write_two_digits(builder, hour)
builder.write_char(':')
write_two_digits(builder, minute)
builder.write_char(':')
write_two_digits(builder, second)
builder.write_char('Z')
builder.to_string()
}
///|
/// Human readable description for timestamp parse errors.
pub fn timestamp_error_message(error : TimestampError) -> String {
match error {
EmptyTimestamp => "timestamp is empty"
InvalidLength => "timestamp has an invalid length"
InvalidSeparator => "timestamp separators do not match ISO-8601"
InvalidDigit(index) => "timestamp contains a non-digit at index \{index}"
InvalidYear(year) => "timestamp year is invalid: \{year}"
InvalidMonth(month) => "timestamp month is invalid: \{month}"
InvalidDay(day) => "timestamp day is invalid: \{day}"
InvalidHour(hour) => "timestamp hour is invalid: \{hour}"
InvalidMinute(minute) => "timestamp minute is invalid: \{minute}"
InvalidSecond(second) => "timestamp second is invalid: \{second}"
InvalidOffset => "timestamp UTC offset is invalid"
TrailingContent => "timestamp has unsupported trailing content"
}
}
///|
/// Convert seconds into a compact audit-friendly duration.
pub fn format_duration(seconds : Int64) -> String {
let sign = if seconds < 0L { "-" } else { "" }
let absolute = if seconds < 0L { -seconds } else { seconds }
let days = absolute / 86400L
let hours = absolute % 86400L / 3600L
let minutes = absolute % 3600L / 60L
let remaining_seconds = absolute % 60L
let builder = StringBuilder::new()
builder.write_string(sign)
if days > 0L {
builder.write_object(days)
builder.write_char('d')
}
if hours > 0L || days > 0L {
builder.write_object(hours)
builder.write_char('h')
}
if minutes > 0L || hours > 0L || days > 0L {
builder.write_object(minutes)
builder.write_char('m')
}
builder.write_object(remaining_seconds)
builder.write_char('s')
builder.to_string()
}