// Datetime -- an RFC 3339 timestamp, the `createdAt` on every record and the
// `indexedAt` on every view.
//
// https://atproto.com/specs/lexicon#datetime
// Ported from @atproto/syntax packages/syntax/src/datetime.ts.
//
// The value keeps the ORIGINAL STRING and re-serializes it unchanged. That is
// not laziness, it is a correctness requirement: a record's CID is the hash of
// its DAG-CBOR encoding, so a client that reads a record, changes one field and
// writes it back must not also rewrite `createdAt` into its own preferred
// precision. rsky stores `chrono::DateTime` and re-serializes at chrono's
// precision, which silently changes the CID of every record it round-trips.
//
// Four things atproto requires that RFC 3339 alone does not:
//
// - The timezone is mandatory. A bare `1985-04-12T23:20:50` is not a moment.
// - `-00:00` is forbidden. RFC 3339 gives it the meaning "UTC, but the local
// offset is unknown", which is not a thing a record should claim.
// - Uppercase `T` and `Z` only. Lowercase is legal ISO 8601 and is rejected.
// - Four-digit years, zero-padded throughout.
///|
/// Generous: the longest sensible value is about 35 characters. The cap exists
/// to bound work on hostile input.
const DATETIME_MAX_LENGTH : Int = 64
///|
/// A syntactically and semantically valid RFC 3339 datetime, holding the exact
/// bytes it was parsed from.
pub struct Datetime(String) derive(Eq, Debug)
///|
pub impl Show for Datetime with fn output(self, logger) {
logger.write_string(self.0)
}
///|
pub fn Datetime::to_string(self : Self) -> String {
self.0
}
///|
pub fn Datetime::unchecked(value : String) -> Datetime {
Datetime(value)
}
///|
pub fn Datetime::is_valid(value : String) -> Bool {
try {
Datetime::parse(value) |> ignore
true
} catch {
_ => false
}
}
///|
/// The parsed pieces. Kept alongside the string rather than replacing it, so
/// callers can do arithmetic without the value losing its original spelling.
pub struct DatetimeParts {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
/// The fractional digits, without the dot, exactly as written -- so
/// `.120` and `.12` stay distinguishable.
fraction : String
/// Minutes east of UTC. `Z` is zero.
offset_minutes : Int
} derive(Eq, Debug)
///|
pub fn Datetime::parts(self : Self) -> DatetimeParts {
// Unreachable failure: `parse` accepted this string already.
match scan(self.0) {
Some(parts) => parts
None =>
{
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
fraction: "",
offset_minutes: 0,
}
}
}
///|
/// Seconds since the Unix epoch, with the offset applied. Sub-second precision
/// is dropped; `parts().fraction` still has it.
pub fn Datetime::to_epoch_seconds(self : Self) -> Int64 {
epoch_seconds_of(self.parts())
}
///|
/// Split out from the method so `parse` can use it before it has a `Datetime`
/// to call it on -- and because `Datetime(...)` as a constructor is ambiguous
/// with `SyntaxKind::Datetime`.
fn epoch_seconds_of(p : DatetimeParts) -> Int64 {
days_from_civil(p.year, p.month, p.day) * 86400L +
(p.hour * 3600 + p.minute * 60 + p.second).to_int64() -
(p.offset_minutes * 60).to_int64()
}
///|
/// Builds a UTC datetime with millisecond precision -- `1985-04-12T23:20:50.123Z`,
/// the spelling the spec calls "preferred" and the one to write when creating a
/// record.
///
/// The clock is an argument, as everywhere else in this package.
pub fn Datetime::from_epoch_millis(millis : Int64) -> Datetime {
let mut secs = millis / 1000L
let mut ms = millis % 1000L
// Truncation rounds toward zero, so a negative remainder must be carried.
if ms < 0L {
ms = ms + 1000L
secs = secs - 1L
}
let mut day = secs / 86400L
let mut rem = secs % 86400L
if rem < 0L {
rem = rem + 86400L
day = day - 1L
}
let (year, month, mday) = civil_from_days(day)
let hour = (rem / 3600L).to_int()
let minute = (rem % 3600L / 60L).to_int()
let second = (rem % 60L).to_int()
let s = pad(year, 4) +
"-" +
pad(month, 2) +
"-" +
pad(mday, 2) +
"T" +
pad(hour, 2) +
":" +
pad(minute, 2) +
":" +
pad(second, 2) +
"." +
pad(ms.to_int(), 3) +
"Z"
Datetime(s)
}
///|
pub fn Datetime::parse(value : String) -> Datetime raise SyntaxError {
fn bad(reason : String) -> SyntaxError {
SyntaxError(kind=Datetime, input=value, reason~)
}
guard value.length() <= DATETIME_MAX_LENGTH else {
raise bad("datetime is too long (\{DATETIME_MAX_LENGTH} chars max)")
}
// Checked before the pattern, and separately from it, because `-00:00` is
// well-formed RFC 3339 -- it is refused for what it means, not how it looks.
guard !value.has_suffix("-00:00") else {
raise bad("datetime can not use \"-00:00\" for UTC timezone")
}
guard scan(value) is Some(parts) else {
raise bad(
"datetime is not in a valid format (must match RFC 3339 & ISO 8601 with 'Z' or ±hh:mm timezone)",
)
}
// The pattern admits day 00 and 31 February; the calendar does not. Upstream
// gets this by handing the string to `new Date` and seeing what comes back.
guard parts.day >= 1 && parts.day <= days_in_month(parts.year, parts.month) else {
raise bad("datetime did not parse as ISO 8601")
}
// `0000-01-01T00:00:00+01:00` is a valid-looking string for an instant before
// year zero. The offset is what makes it so, which is why this cannot be a
// check on the year alone.
guard epoch_seconds_of(parts) >= EPOCH_SECONDS_AT_YEAR_ZERO else {
raise bad("datetime normalized to a negative time")
}
Datetime::Datetime(value)
}
///|
/// Seconds from the Unix epoch back to 0000-01-01T00:00:00Z. Negative, and the
/// floor below which a datetime is considered to have gone negative.
const EPOCH_SECONDS_AT_YEAR_ZERO : Int64 = -62167219200L
///|
/// Recognises the grammar and returns the pieces, or `None`.
///
/// Written as a scan rather than a regex so that each field's permitted range
/// is stated where it is read -- `[0-1][0-9]|2[0-3]` for the hour, `0[1-9]|1[012]`
/// for the month -- which is also what makes the ranges reviewable against the
/// spec.
fn scan(s : String) -> DatetimeParts? {
// YYYY-MM-DDTHH:MM:SS is 19 characters, and the timezone adds at least one.
guard s.length() >= 20 else { return None }
guard digits(s, 0, 4) is Some(year) else { return None }
guard code_unit_is(s[4], '-') else { return None }
guard digits(s, 5, 2) is Some(month) else { return None }
guard month >= 1 && month <= 12 else { return None }
guard code_unit_is(s[7], '-') else { return None }
guard digits(s, 8, 2) is Some(day) else { return None }
// `[0-2][0-9]|3[01]`: the pattern allows day 00, and the calendar check in
// `parse` is what rejects it.
guard day <= 31 else { return None }
guard code_unit_is(s[10], 'T') else { return None }
guard digits(s, 11, 2) is Some(hour) else { return None }
guard hour <= 23 else { return None }
guard code_unit_is(s[13], ':') else { return None }
guard digits(s, 14, 2) is Some(minute) else { return None }
guard minute <= 59 else { return None }
guard code_unit_is(s[16], ':') else { return None }
guard digits(s, 17, 2) is Some(second) else { return None }
// 60 is a leap second, which RFC 3339 permits.
guard second <= 60 else { return None }
let mut i = 19
let mut fraction = ""
if i < s.length() && code_unit_is(s[i], '.') {
let start = i + 1
let mut end = start
while end < s.length() && code_unit_is_ascii_digit(s[end]) {
end = end + 1
}
// A dot with no digits after it is not a fraction.
guard end > start else { return None }
fraction = s[start:end].to_owned()
i = end
}
// The timezone is mandatory, and is either `Z` or `±HH:MM`.
guard i < s.length() else { return None }
let offset_minutes = if code_unit_is(s[i], 'Z') {
guard i + 1 == s.length() else { return None }
0
} else {
let sign = if code_unit_is(s[i], '+') {
1
} else if code_unit_is(s[i], '-') {
-1
} else {
return None
}
guard i + 6 == s.length() else { return None }
guard digits(s, i + 1, 2) is Some(oh) else { return None }
guard oh <= 23 else { return None }
guard code_unit_is(s[i + 3], ':') else { return None }
guard digits(s, i + 4, 2) is Some(om) else { return None }
guard om <= 59 else { return None }
sign * (oh * 60 + om)
}
Some({ year, month, day, hour, minute, second, fraction, offset_minutes })
}
///|
/// Exactly `count` ASCII digits starting at `start`, as a number. `None` if any
/// of them is not a digit or the string is too short -- which is what enforces
/// the zero-padding the spec requires.
fn digits(s : String, start : Int, count : Int) -> Int? {
guard start + count <= s.length() else { return None }
let mut acc = 0
for i = start; i < start + count; i = i + 1 {
guard code_unit_is_ascii_digit(s[i]) else { return None }
acc = acc * 10 + (s[i].to_int() - '0'.to_int())
}
Some(acc)
}
///|
fn is_leap_year(year : Int) -> Bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
///|
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
}
}
///|
/// Days from 1970-01-01 to the given proleptic Gregorian date. Howard
/// Hinnant's `days_from_civil`, which is exact for every year this type can
/// hold and needs no table.
fn days_from_civil(year : Int, month : Int, day : Int) -> Int64 {
let y = if month <= 2 { year - 1 } else { year }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let mp = (month + 9) % 12
let doy = (153 * mp + 2) / 5 + day - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era.to_int64() * 146097L + doe.to_int64() - 719468L
}
///|
/// The inverse of `days_from_civil`.
fn civil_from_days(days : Int64) -> (Int, Int, Int) {
let z = days + 719468L
let era = (if z >= 0L { z } else { z - 146096L }) / 146097L
let doe = (z - era * 146097L).to_int()
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let y = yoe.to_int64() + era * 400L
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let d = doy - (153 * mp + 2) / 5 + 1
let m = if mp < 10 { mp + 3 } else { mp - 9 }
((if m <= 2 { y + 1L } else { y }).to_int(), m, d)
}
///|
fn pad(value : Int, width : Int) -> String {
let digits = value.to_string()
if digits.length() >= width {
return digits
}
let b = StringBuilder::new(size_hint=width)
for _ in 0..<(width - digits.length()) {
b.write_char('0')
}
b.write_string(digits)
b.to_string()
}