Skip to content

Implement correct resolving of getStartOfDay #159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 55 additions & 12 deletions src/components/timezone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ pub static TZ_PROVIDER: LazyLock<Mutex<FsTzdbProvider>> =

const NS_IN_HOUR: i128 = 60 * 60 * 1000 * 1000 * 1000;

/// `TimeZoneOffset` represents the number of seconds to be added to UT in order to determine local time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeZoneOffset {
/// The transition time epoch at which the offset needs to be applied.
pub transition_epoch: Option<i64>,
/// The time zone offset in seconds.
pub offset: i64,
}

// NOTE: It may be a good idea to eventually move this into it's
// own individual crate rather than having it tied directly into `temporal_rs`
pub trait TzProvider {
Expand All @@ -41,8 +50,8 @@ pub trait TzProvider {
fn get_named_tz_offset_nanoseconds(
&self,
identifier: &str,
epoch_nanoseconds: i128,
) -> TemporalResult<i128>;
utc_epoch: i128,
) -> TemporalResult<TimeZoneOffset>;
}

pub struct NeverProvider;
Expand All @@ -60,7 +69,7 @@ impl TzProvider for NeverProvider {
unimplemented!()
}

fn get_named_tz_offset_nanoseconds(&self, _: &str, _: i128) -> TemporalResult<i128> {
fn get_named_tz_offset_nanoseconds(&self, _: &str, _: i128) -> TemporalResult<TimeZoneOffset> {
unimplemented!()
}
}
Expand Down Expand Up @@ -125,17 +134,17 @@ impl TimeZone {
/// Get the offset for this current `TimeZoneSlot`.
pub fn get_offset_nanos_for(
&self,
epoch_ns: i128,
utc_epoch: i128,
provider: &impl TzProvider,
) -> TemporalResult<i128> {
// 1. Let parseResult be ! ParseTimeZoneIdentifier(timeZone).
match self {
// 2. If parseResult.[[OffsetMinutes]] is not empty, return parseResult.[[OffsetMinutes]] × (60 × 10**9).
Self::OffsetMinutes(minutes) => Ok(i128::from(*minutes) * 60_000_000_000i128),
// 3. Return GetNamedTimeZoneOffsetNanoseconds(parseResult.[[Name]], epochNs).
Self::IanaIdentifier(identifier) => {
provider.get_named_tz_offset_nanoseconds(identifier, epoch_ns)
}
Self::IanaIdentifier(identifier) => provider
.get_named_tz_offset_nanoseconds(identifier, utc_epoch)
.map(|offset| i128::from(offset.offset) * 1_000_000_000),
}
}

Expand Down Expand Up @@ -358,18 +367,52 @@ impl TimeZone {
if !possible_nanos.is_empty() {
return Ok(possible_nanos[0]);
}
// 4. Assert: IsOffsetTimeZoneIdentifier(timeZone) is false.
let TimeZone::IanaIdentifier(identifier) = self else {
debug_assert!(
false,
"4. Assert: IsOffsetTimeZoneIdentifier(timeZone) is false."
);
return Err(
TemporalError::assert().with_message("Timezone was not an Iana identifier.")
);
};
// 5. Let possibleEpochNsAfter be GetNamedTimeZoneEpochNanoseconds(timeZone, isoDateTimeAfter), where
// isoDateTimeAfter is the ISO Date-Time Record for which ! DifferenceISODateTime(isoDateTime,
// isoDateTimeAfter, "iso8601", hour).[[Time]] is the smallest possible value > 0 for which
// possibleEpochNsAfter is not empty (i.e., isoDateTimeAfter represents the first local time
// after the transition).
// NOTE (nekevss): The polyfill subtracts time from the epoch nanoseconds, but the specification
// appears to be talking about the possible nanoseconds after ... tbd.
let epoch_ns = iso.as_nanoseconds()?.0 + 7_200_000_000_000;

// Similar to disambiguation, we need to first get the possible epoch for the current start of day +
// 3 hours, then get the timestamp for the transition epoch.
let after = IsoDateTime::new_unchecked(
*iso_date,
IsoTime {
hour: 3,
..Default::default()
},
);
let Some(after_epoch) = self
.get_possible_epoch_ns_for(after, provider)?
.into_iter()
.next()
else {
return Err(TemporalError::r#type()
.with_message("Could not determine the start of day for the provided date."));
};

let TimeZoneOffset {
transition_epoch: Some(transition_epoch),
..
} = provider.get_named_tz_offset_nanoseconds(identifier, after_epoch.0)?
else {
return Err(TemporalError::r#type()
.with_message("Could not determine the start of day for the provided date."));
};

// let provider.
// 6. Assert: possibleEpochNsAfter's length = 1.
// 7. Return possibleEpochNsAfter[0].
EpochNanoseconds::try_from(self.get_offset_nanos_for(epoch_ns, provider)?)
EpochNanoseconds::try_from(i128::from(transition_epoch) * 1_000_000_000)
}
}

Expand Down
38 changes: 36 additions & 2 deletions src/components/zoneddatetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,8 +1177,9 @@ pub(crate) fn nanoseconds_to_formattable_offset_minutes(
#[cfg(all(test, feature = "tzdb"))]
mod tests {
use crate::{
options::{Disambiguation, OffsetDisambiguation},
options::{DifferenceSettings, Disambiguation, OffsetDisambiguation, TemporalUnit},
partial::{PartialDate, PartialTime, PartialZonedDateTime},
primitive::FiniteF64,
tzdb::FsTzdbProvider,
Calendar, TimeZone, ZonedDateTime,
};
Expand Down Expand Up @@ -1349,6 +1350,13 @@ mod tests {
// https://github.com/tc39/test262/blob/d9b10790bc4bb5b3e1aa895f11cbd2d31a5ec743/test/intl402/Temporal/ZonedDateTime/from/dst-skipped-cross-midnight.js
fn dst_skipped_cross_midnight() {
let provider = &FsTzdbProvider::default();
let start_of_day = ZonedDateTime::from_str_with_provider(
"1919-03-31[America/Toronto]",
Disambiguation::Compatible,
OffsetDisambiguation::Reject,
provider,
)
.unwrap();
let midnight_disambiguated = ZonedDateTime::from_str_with_provider(
"1919-03-31T00[America/Toronto]",
Disambiguation::Compatible,
Expand All @@ -1357,6 +1365,32 @@ mod tests {
)
.unwrap();

assert_eq!(midnight_disambiguated.epoch_milliseconds(), -1601751600000);
assert_eq!(start_of_day.epoch_nanoseconds(), -1601753400000000000);
assert_eq!(
midnight_disambiguated.epoch_nanoseconds(),
-1601751600000000000
);
let diff = start_of_day
.instant
.until(
&midnight_disambiguated.instant,
DifferenceSettings {
largest_unit: Some(TemporalUnit::Year),
smallest_unit: Some(TemporalUnit::Nanosecond),
..Default::default()
},
)
.unwrap();
let zero = FiniteF64::from(0);
assert_eq!(diff.years(), zero);
assert_eq!(diff.months(), zero);
assert_eq!(diff.weeks(), zero);
assert_eq!(diff.days(), zero);
assert_eq!(diff.hours(), zero);
assert_eq!(diff.minutes(), FiniteF64::from(30));
assert_eq!(diff.seconds(), zero);
assert_eq!(diff.milliseconds(), zero);
assert_eq!(diff.microseconds(), zero);
assert_eq!(diff.nanoseconds(), zero);
}
}
109 changes: 85 additions & 24 deletions src/tzdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use tzif::{
},
};

use crate::components::timezone::TimeZoneOffset;
use crate::{
components::{timezone::TzProvider, EpochNanoseconds},
iso::IsoDateTime,
Expand Down Expand Up @@ -205,26 +206,45 @@ impl Tzif {
.ok_or(TemporalError::general("Only Tzif V2+ is supported."))
}

pub fn get(&self, epoch_seconds: &Seconds) -> TemporalResult<LocalTimeRecord> {
pub fn get(&self, epoch_seconds: &Seconds) -> TemporalResult<TimeZoneOffset> {
let db = self.get_data_block2()?;

let result = db.transition_times.binary_search(epoch_seconds);

match result {
Ok(idx) => Ok(get_local_record(db, idx - 1).into()),
Err(idx) if idx == 0 => Ok(get_local_record(db, idx).into()),
Ok(idx) => Ok(get_timezone_offset(db, idx - 1)),
// <https://datatracker.ietf.org/doc/html/rfc8536#section-3.2>
// If there are no transitions, local time for all timestamps is specified by the TZ
// string in the footer if present and nonempty; otherwise, it is
// specified by time type 0.
Err(_) if db.transition_times.is_empty() => {
if let Some(posix_tz_string) = self.posix_tz_string() {
resolve_posix_tz_string_for_epoch_seconds(posix_tz_string, epoch_seconds.0)
} else {
Ok(TimeZoneOffset {
offset: db.local_time_type_records[0].utoff.0,
transition_epoch: None,
})
}
}
Err(idx) if idx == 0 => Ok(get_timezone_offset(db, idx)),
Err(idx) => {
if db.transition_times.len() <= idx {
// The transition time provided is beyond the length of
// the available transition time, so the time zone is
// resolved with the POSIX tz string.
return resolve_posix_tz_string_for_epoch_seconds(
let mut offset = resolve_posix_tz_string_for_epoch_seconds(
self.posix_tz_string().ok_or(TemporalError::general(
"No POSIX tz string to resolve with.",
))?,
epoch_seconds.0,
);
)?;
offset
.transition_epoch
.get_or_insert_with(|| db.transition_times[idx - 1].0);
return Ok(offset);
}
Ok(get_local_record(db, idx - 1).into())
Ok(get_timezone_offset(db, idx - 1))
}
}
}
Expand Down Expand Up @@ -301,6 +321,17 @@ impl Tzif {
}
}

#[inline]
fn get_timezone_offset(db: &DataBlock, idx: usize) -> TimeZoneOffset {
// NOTE: Transition type can be empty. If no transition_type exists,
// then use 0 as the default index of local_time_type_records.
let offset = db.local_time_type_records[db.transition_types.get(idx).copied().unwrap_or(0)];
TimeZoneOffset {
transition_epoch: db.transition_times.get(idx).map(|s| s.0),
offset: offset.utoff.0,
}
}

#[inline]
fn get_local_record(db: &DataBlock, idx: usize) -> LocalTimeTypeRecord {
// NOTE: Transition type can be empty. If no transition_type exists,
Expand All @@ -312,12 +343,13 @@ fn get_local_record(db: &DataBlock, idx: usize) -> LocalTimeTypeRecord {
fn resolve_posix_tz_string_for_epoch_seconds(
posix_tz_string: &PosixTzString,
seconds: i64,
) -> TemporalResult<LocalTimeRecord> {
) -> TemporalResult<TimeZoneOffset> {
let Some(dst_variant) = &posix_tz_string.dst_info else {
// Regardless of the time, there is one variant and we can return it.
return Ok(LocalTimeRecord::from_standard_time(
&posix_tz_string.std_info,
));
return Ok(TimeZoneOffset {
transition_epoch: None,
offset: LocalTimeRecord::from_standard_time(&posix_tz_string.std_info).offset,
});
};

let start = &dst_variant.start_date;
Expand All @@ -331,14 +363,39 @@ fn resolve_posix_tz_string_for_epoch_seconds(
let (is_transition_day, transition) =
cmp_seconds_to_transitions(&start.day, &end.day, seconds)?;

match compute_tz_for_epoch_seconds(is_transition_day, transition, seconds, dst_variant) {
TransitionType::Dst => Ok(LocalTimeRecord::from_daylight_savings_time(
&dst_variant.variant_info,
)),
TransitionType::Std => Ok(LocalTimeRecord::from_standard_time(
&posix_tz_string.std_info,
)),
}
let transition =
compute_tz_for_epoch_seconds(is_transition_day, transition, seconds, dst_variant);
let offset = match transition {
TransitionType::Dst => {
LocalTimeRecord::from_daylight_savings_time(&dst_variant.variant_info)
}
TransitionType::Std => LocalTimeRecord::from_standard_time(&posix_tz_string.std_info),
};
let transition = match transition {
TransitionType::Dst => start,
TransitionType::Std => end,
};
let year = utils::epoch_time_to_epoch_year(seconds);
let year_epoch = utils::epoch_days_for_year(year) * 86400;
let leap_days = utils::mathematical_days_in_year(year) - 365;

let days = match transition.day {
TransitionDay::NoLeap(day) if day > 59 => i32::from(day) - 1 + leap_days,
TransitionDay::NoLeap(day) => i32::from(day) - 1,
TransitionDay::WithLeap(day) => i32::from(day),
TransitionDay::Mwd(_month, _week, _day) => {
// TODO: build transition epoch from month, week and day.
return Ok(TimeZoneOffset {
offset: offset.offset,
transition_epoch: None,
});
}
};
let transition_epoch = i64::from(year_epoch) + i64::from(days) * 3600 + transition.time.0;
Ok(TimeZoneOffset {
offset: offset.offset,
transition_epoch: Some(transition_epoch),
})
}

/// Resolve the footer of a tzif file.
Expand Down Expand Up @@ -472,6 +529,7 @@ fn cmp_seconds_to_transitions(
};
(is_transition, is_dst)
}
// TODO: do we need to modify the logic for leap years?
(TransitionDay::NoLeap(start), TransitionDay::NoLeap(end)) => {
let day_in_year = utils::epoch_time_to_day_in_year(seconds * 1_000.0) as u16;
let is_transition = *start == day_in_year || *end == day_in_year;
Expand All @@ -484,7 +542,11 @@ fn cmp_seconds_to_transitions(
}
// NOTE: The assumption here is that mismatched day types on
// a POSIX string is an illformed string.
_ => return Err(TemporalError::assert()),
_ => {
return Err(
TemporalError::assert().with_message("Mismatched day types on a POSIX string.")
)
}
};

match cmp_result {
Expand Down Expand Up @@ -575,12 +637,11 @@ impl TzProvider for FsTzdbProvider {
fn get_named_tz_offset_nanoseconds(
&self,
identifier: &str,
epoch_nanoseconds: i128,
) -> TemporalResult<i128> {
utc_epoch: i128,
) -> TemporalResult<TimeZoneOffset> {
let tzif = self.get(identifier)?;
let seconds = (epoch_nanoseconds / 1_000_000_000) as i64;
let local_time_record_result = tzif.get(&Seconds(seconds))?;
Ok(local_time_record_result.offset as i128 * 1_000_000_000)
let seconds = (utc_epoch / 1_000_000_000) as i64;
tzif.get(&Seconds(seconds))
}
}

Expand Down
Loading