///|
/// A fixed offset from UTC, stored in nanoseconds.
///
/// Offsets used as time zone identifiers are restricted to minute precision;
/// sub-minute offsets only arise when parsing an RFC 9557 string that carries
/// one, and are rejected where the specification requires minute precision.
pub struct UtcOffset(Int64) derive(Eq, Compare, Debug)
///|
pub impl Show for UtcOffset with fn output(self, logger) {
logger.write_string(self.to_string())
}
///|
/// UTC itself, an offset of zero.
pub let utc_offset_zero : UtcOffset = UtcOffset(0L)
///|
/// Creates an offset from whole minutes.
///
/// ```mbt check
/// test {
/// inspect(@temporal.UtcOffset::from_minutes(-330), content="-05:30")
/// }
/// ```
pub fn UtcOffset::from_minutes(minutes : Int) -> UtcOffset {
UtcOffset(minutes.to_int64() * NS_PER_MINUTE)
}
///|
/// Creates an offset from whole seconds.
pub fn UtcOffset::from_seconds(seconds : Int64) -> UtcOffset {
UtcOffset(seconds * NS_PER_SECOND)
}
///|
/// Creates an offset from nanoseconds.
pub fn UtcOffset::from_nanoseconds(nanoseconds : Int64) -> UtcOffset {
UtcOffset(nanoseconds)
}
///|
/// Returns the offset in nanoseconds.
pub fn UtcOffset::nanoseconds(self : UtcOffset) -> Int64 {
self.0
}
///|
/// Returns the offset in whole seconds, truncated toward zero.
pub fn UtcOffset::seconds(self : UtcOffset) -> Int64 {
self.0 / NS_PER_SECOND
}
///|
/// Returns the offset in whole minutes, truncated toward zero.
pub fn UtcOffset::minutes(self : UtcOffset) -> Int {
(self.0 / NS_PER_MINUTE).to_int()
}
///|
/// Returns whether the offset carries finer detail than whole minutes.
pub fn UtcOffset::is_sub_minute(self : UtcOffset) -> Bool {
self.0 % NS_PER_MINUTE != 0L
}
///|
/// Renders the offset as `±HH:MM`, extending to seconds and fractions only
/// when the offset needs them.
///
/// ```mbt check
/// test {
/// inspect(@temporal.UtcOffset::from_minutes(0), content="+00:00")
/// inspect(@temporal.UtcOffset::from_seconds(3661), content="+01:01:01")
/// }
/// ```
pub fn UtcOffset::to_string(self : UtcOffset) -> String {
let buf = StringBuilder::new()
buf.write_string(if self.0 < 0L { "-" } else { "+" })
let total = self.0.abs()
let nanosecond = (total % NS_PER_SECOND).to_int()
let seconds_left = total / NS_PER_SECOND
let second = (seconds_left % 60L).to_int()
let minutes_left = seconds_left / 60L
let minute = (minutes_left % 60L).to_int()
let hour = (minutes_left / 60L).to_int()
let precision : Precision = if nanosecond == 0 && second == 0 {
Minute
} else {
Auto
}
write_time(buf, hour, minute, second, nanosecond, precision, true)
buf.to_string()
}
///|
/// A time zone: either a fixed offset from UTC or a named IANA zone.
///
/// Named zones are resolved through a [`TimeZoneProvider`], because the
/// transition data they need is far larger than a date library should embed.
/// The built-in [`utc_only_provider`] handles `UTC` and nothing else.
pub(all) enum TimeZone {
/// A fixed offset from UTC, such as `+05:30`.
OffsetZone(UtcOffset)
/// A named IANA zone, such as `America/Chicago`.
IanaZone(String)
} derive(Eq, Debug)
///|
pub impl Show for TimeZone with fn output(self, logger) {
logger.write_string(self.identifier())
}
///|
/// The UTC time zone.
pub let time_zone_utc : TimeZone = IanaZone("UTC")
///|
/// Returns the time zone identifier, as `toString` would render it.
pub fn TimeZone::identifier(self : TimeZone) -> String {
match self {
OffsetZone(offset) => offset.to_string()
IanaZone(name) => name
}
}
///|
/// The possible instants a wall-clock time maps to in a time zone.
///
/// A time in a spring-forward gap has none, a time in a fall-back overlap has
/// two, and every other time has exactly one.
pub(all) enum CandidateEpochNanoseconds {
/// The time falls in a gap. The two offsets are those in force immediately
/// before and after the transition.
Zero(UtcOffset, UtcOffset)
/// The usual case: exactly one instant.
One(@int128.Int128, UtcOffset)
/// The time is ambiguous, given earliest first.
Two(@int128.Int128, UtcOffset, @int128.Int128, UtcOffset)
} derive(Debug)
///|
/// An instant paired with the offset in force at that instant.
pub struct EpochNanosecondsAndOffset {
/// Nanoseconds since the epoch.
nanoseconds : @int128.Int128
/// The UTC offset in force.
offset : UtcOffset
} derive(Eq, Debug)
///|
/// Supplies the transition data for named IANA time zones.
///
/// Implement this to plug in a TZif database or any other source of zone
/// rules; the library itself embeds none.
pub trait TimeZoneProvider {
/// Returns the offset in force in `identifier` at the given instant.
fn offset_nanoseconds_for(Self, String, @int128.Int128) -> Int64 raise TemporalError
/// Returns the instants that the local date-time maps to in `identifier`.
fn candidates_for_local_datetime(Self, String, IsoDateTime) -> CandidateEpochNanoseconds raise TemporalError
}
///|
/// A provider that knows only `UTC`.
///
/// It is enough for fixed-offset work and for the `UTC` zone itself; any other
/// named zone raises a `RangeError`.
pub struct UtcOnlyProvider {
/// Unused; the provider carries no state.
priv marker : Unit
} derive(Debug)
///|
/// The shared [`UtcOnlyProvider`] instance.
pub let utc_only_provider : UtcOnlyProvider = { marker: () }
///|
pub impl TimeZoneProvider for UtcOnlyProvider with fn offset_nanoseconds_for(
_self,
identifier,
_epoch,
) {
if is_utc_identifier(identifier) {
0L
} else {
raise RangeError(
"time zone '\{identifier}' needs a TimeZoneProvider with IANA data",
)
}
}
///|
pub impl TimeZoneProvider for UtcOnlyProvider with fn candidates_for_local_datetime(
_self,
identifier,
local_iso,
) {
if is_utc_identifier(identifier) {
One(local_iso.as_nanoseconds(), utc_offset_zero)
} else {
raise RangeError(
"time zone '\{identifier}' needs a TimeZoneProvider with IANA data",
)
}
}
///|
/// Returns whether the identifier names UTC under one of its accepted
/// spellings.
fn is_utc_identifier(identifier : String) -> Bool {
ascii_lowercase(identifier) is ("utc" | "etc/utc" | "etc/gmt" | "gmt")
}
///|
/// `GetOffsetNanosecondsFor`: the offset in force at the given instant.
pub fn[P : TimeZoneProvider] TimeZone::offset_nanoseconds_for(
self : TimeZone,
epoch_nanoseconds : @int128.Int128,
provider : P,
) -> Int64 raise TemporalError {
match self {
OffsetZone(offset) => offset.nanoseconds()
IanaZone(name) => provider.offset_nanoseconds_for(name, epoch_nanoseconds)
}
}
///|
/// Returns the offset in force at the given instant.
pub fn[P : TimeZoneProvider] TimeZone::utc_offset_for(
self : TimeZone,
epoch_nanoseconds : @int128.Int128,
provider : P,
) -> UtcOffset raise TemporalError {
UtcOffset(self.offset_nanoseconds_for(epoch_nanoseconds, provider))
}
///|
/// `GetPossibleEpochNanoseconds`: the instants a local time maps to.
fn[P : TimeZoneProvider] TimeZone::possible_epoch_nanoseconds_for(
self : TimeZone,
local_iso : IsoDateTime,
provider : P,
) -> CandidateEpochNanoseconds raise TemporalError {
match self {
OffsetZone(offset) => {
// A fixed offset never has gaps or overlaps, so subtracting it gives the
// one and only instant.
let balanced = IsoDateTime::balance(
local_iso.date.year,
local_iso.date.month,
local_iso.date.day.to_int64(),
local_iso.time.hour.to_int64(),
local_iso.time.minute.to_int64() - offset.minutes().to_int64(),
local_iso.time.second.to_int64(),
local_iso.time.millisecond.to_int64(),
@int128.of_int(local_iso.time.microsecond),
@int128.of_int(local_iso.time.nanosecond),
)
check_iso_days_range(balanced.date)
One(balanced.as_nanoseconds(), offset)
}
IanaZone(name) => provider.candidates_for_local_datetime(name, local_iso)
}
}
///|
/// `CheckISODaysRange`.
fn check_iso_days_range(date : IsoDate) -> Unit raise TemporalError {
if date.to_epoch_days().abs() > 100_000_000L {
raise RangeError("date is not within a valid ISO day range")
}
}
///|
/// `GetEpochNanosecondsFor`: resolves a local time to a single instant.
pub fn[P : TimeZoneProvider] TimeZone::epoch_nanoseconds_for(
self : TimeZone,
local_iso : IsoDateTime,
disambiguation : Disambiguation,
provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
let candidates = self.possible_epoch_nanoseconds_for(local_iso, provider)
self.disambiguate(candidates, local_iso, disambiguation, provider)
}
///|
/// `DisambiguatePossibleEpochNanoseconds`: picks one instant from the
/// candidates a local time maps to.
fn[P : TimeZoneProvider] TimeZone::disambiguate(
self : TimeZone,
candidates : CandidateEpochNanoseconds,
local_iso : IsoDateTime,
disambiguation : Disambiguation,
provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
match candidates {
One(ns, offset) => { nanoseconds: ns, offset }
Two(first, first_offset, second, second_offset) =>
match disambiguation {
Compatible | Earlier => { nanoseconds: first, offset: first_offset }
Later => { nanoseconds: second, offset: second_offset }
Reject => raise RangeError("the local time is ambiguous")
}
Zero(before, after) => {
if disambiguation is Reject {
raise RangeError("the local time does not exist in this time zone")
}
// The local time falls in a gap. Shift it by the width of the gap and
// resolve again: `earlier` steps back to just before the transition and
// `later` steps forward to just after it.
let gap_nanoseconds = after.nanoseconds() - before.nanoseconds()
let shift = match disambiguation {
Earlier => -gap_nanoseconds
Compatible | Later => gap_nanoseconds
Reject => raise AssertError("reject was handled above")
}
let shifted = IsoDateTime::balance(
local_iso.date.year,
local_iso.date.month,
local_iso.date.day.to_int64(),
local_iso.time.hour.to_int64(),
local_iso.time.minute.to_int64(),
local_iso.time.second.to_int64(),
local_iso.time.millisecond.to_int64(),
@int128.of_int(local_iso.time.microsecond),
@int128.of_int(local_iso.time.nanosecond + shift.to_int()),
)
let candidates = self.possible_epoch_nanoseconds_for(shifted, provider)
match candidates {
One(ns, offset) => { nanoseconds: ns, offset }
Two(first, first_offset, second, second_offset) =>
match disambiguation {
Earlier => { nanoseconds: first, offset: first_offset }
Compatible | Later => { nanoseconds: second, offset: second_offset }
Reject => raise AssertError("reject was handled above")
}
Zero(_, _) =>
raise AssertError(
"shifting out of a gap must not land in another gap",
)
}
}
}
}