///|
/// Rounding a duration against a reference point.
///
/// Calendar units have no fixed length, so rounding a duration that contains
/// them means asking where the reference point lands. The algorithm brackets
/// the true result between two candidate durations, `r1` and `r2`, measures how
/// far along that bracket the destination falls, and applies the rounding mode
/// to that fraction. The spec calls this "nudging".
///
/// Every fraction is kept as an exact numerator and denominator in 128-bit
/// integers, so no intermediate step loses precision.
///
/// Spec:
///|
/// The outcome of one nudge step.
priv struct NudgeRecord {
duration : InternalDurationRecord
nudged_epoch_nanoseconds : @int128.Int128
/// Whether rounding pushed the calendar unit up to the next value.
expanded : Bool
}
///|
/// The bracket a nudge works within.
priv struct NudgeWindow {
/// The candidate count of the smallest unit at the start of the bracket.
r1 : @int128.Int128
/// The candidate count one increment further along.
r2 : @int128.Int128
start_epoch_nanoseconds : @int128.Int128
end_epoch_nanoseconds : @int128.Int128
start_duration : DateDuration
end_duration : DateDuration
}
///|
/// The reference point a relative rounding is measured against: a date-time
/// and, when the rounding is time-zone aware, the zone it sits in.
priv struct RelativeContext[P] {
date_time : PlainDateTime
time_zone : TimeZone?
provider : P
}
///|
/// Resolves a local date-time to an instant, honouring the time zone when the
/// context has one.
fn[P : TimeZoneProvider] RelativeContext::epoch_nanoseconds_for(
self : RelativeContext[P],
local_iso : IsoDateTime,
) -> @int128.Int128 raise TemporalError {
match self.time_zone {
None => local_iso.as_nanoseconds()
Some(zone) =>
zone.epoch_nanoseconds_for(local_iso, Compatible, self.provider).nanoseconds
}
}
///|
/// `ComputeNudgeWindow`: brackets the rounded value between two candidate
/// durations and finds the instants they map to.
fn[P : TimeZoneProvider] InternalDurationRecord::compute_nudge_window(
self : InternalDurationRecord,
sign : Int,
origin_epoch_nanoseconds : @int128.Int128,
context : RelativeContext[P],
options : ResolvedRoundingOptions,
additional_shift : Bool,
) -> NudgeWindow raise TemporalError {
let increment = @int128.of_int(options.increment.get())
let increment_x_sign = increment.mul(@int128.of_int(sign))
let date = self.date
let dt = context.date_time
let (r1, r2, start_duration, end_duration) = match options.smallest_unit {
Year => {
let years = truncate_to_increment(@int128.of_int64(date.years), increment)
let r1 = if additional_shift {
years.add(increment_x_sign)
} else {
years
}
let r2 = r1.add(increment_x_sign)
(
r1,
r2,
DateDuration::new(int64_of(r1), 0L, 0L, 0L),
DateDuration::new(int64_of(r2), 0L, 0L, 0L),
)
}
Month => {
let months = truncate_to_increment(
@int128.of_int64(date.months),
increment,
)
let r1 = if additional_shift {
months.add(increment_x_sign)
} else {
months
}
let r2 = r1.add(increment_x_sign)
(
r1,
r2,
date.adjust(0L, months=int64_of(r1)),
date.adjust(0L, months=int64_of(r2)),
)
}
Week => {
// Weeks are counted against the calendar walk from the start of the
// duration's year-and-month span, so both ends have to be materialized
// as dates first.
let start = IsoDate::try_balance(
dt.iso.date.year + date.years.to_int(),
dt.iso.date.month + date.months.to_int(),
dt.iso.date.day.to_int64(),
)
let end = IsoDate::try_balance(
dt.iso.date.year + date.years.to_int(),
dt.iso.date.month + date.months.to_int(),
dt.iso.date.day.to_int64() + date.days,
)
let weeks_start = PlainDate::new_unchecked(start, dt.calendar)
let weeks_end = PlainDate::new_unchecked(end, dt.calendar)
let until = weeks_start.internal_diff_date(weeks_end, Week)
let weeks = truncate_to_increment(
@int128.of_int64(date.weeks + until.weeks),
increment,
)
let r1 = weeks
let r2 = weeks.add(increment_x_sign)
(
r1,
r2,
DateDuration::new(date.years, date.months, int64_of(r1), 0L),
DateDuration::new(date.years, date.months, int64_of(r2), 0L),
)
}
Day => {
let days = truncate_to_increment(@int128.of_int64(date.days), increment)
let r1 = days
let r2 = days.add(increment_x_sign)
(
r1,
r2,
DateDuration::new(date.years, date.months, date.weeks, int64_of(r1)),
DateDuration::new(date.years, date.months, date.weeks, int64_of(r2)),
)
}
_ =>
raise AssertError(
"NudgeToCalendarUnit was invoked with unit '\{options.smallest_unit}'",
)
}
// A zero start duration lands exactly on the origin, so the calendar walk
// can be skipped.
let start_epoch_nanoseconds = if start_duration.sign() is Zero {
origin_epoch_nanoseconds
} else {
let start = dt.iso.date.add_date_duration(start_duration, Constrain)
context.epoch_nanoseconds_for(
IsoDateTime::new_unchecked(start, dt.iso.time),
)
}
let end = dt.iso.date.add_date_duration(end_duration, Constrain)
let end_epoch_nanoseconds = context.epoch_nanoseconds_for(
IsoDateTime::new_unchecked(end, dt.iso.time),
)
{
r1,
r2,
start_epoch_nanoseconds,
end_epoch_nanoseconds,
start_duration,
end_duration,
}
}
///|
/// Computes the nudge window, widening it by one increment when the
/// destination turns out to lie outside the first bracket.
///
/// Returns the window along with whether the widening happened, which the
/// caller reports as a calendar-unit expansion.
fn[P : TimeZoneProvider] InternalDurationRecord::compute_and_adjust_nudge_window(
self : InternalDurationRecord,
sign : Int,
origin_epoch_nanoseconds : @int128.Int128,
dest_epoch_nanoseconds : @int128.Int128,
context : RelativeContext[P],
options : ResolvedRoundingOptions,
) -> (NudgeWindow, Bool) raise TemporalError {
let window = self.compute_nudge_window(
sign, origin_epoch_nanoseconds, context, options, false,
)
let contains = if sign >= 0 {
window.start_epoch_nanoseconds.compare(dest_epoch_nanoseconds) <= 0 &&
dest_epoch_nanoseconds.compare(window.end_epoch_nanoseconds) <= 0
} else {
window.end_epoch_nanoseconds.compare(dest_epoch_nanoseconds) <= 0 &&
dest_epoch_nanoseconds.compare(window.start_epoch_nanoseconds) <= 0
}
if contains {
(window, false)
} else {
(
self.compute_nudge_window(
sign, origin_epoch_nanoseconds, context, options, true,
),
true,
)
}
}
///|
/// `NudgeToCalendarUnit`: rounds the duration to a whole number of years,
/// months, weeks or days.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_calendar_unit(
self : InternalDurationRecord,
sign : Int,
origin_epoch_nanoseconds : @int128.Int128,
dest_epoch_nanoseconds : @int128.Int128,
context : RelativeContext[P],
options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
let (window, did_expand) = self.compute_and_adjust_nudge_window(
sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
)
// The spec computes `total = r1 + progress × increment × sign` where
// `progress` is a fraction. Multiplying through by the denominator keeps
// everything in exact integer arithmetic:
// total × divisor = r1 × divisor + dividend × increment × sign
let dividend = dest_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
let divisor = window.end_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
if divisor.is_zero() {
raise AssertError("nudge window collapsed to a single instant")
}
let total_times_divisor = window.r1
.mul(divisor)
.add(
dividend.mul(
@int128.of_int(options.increment.get() * sign_multiplier(sign)),
),
)
let unsigned_mode = options.rounding_mode.to_unsigned(sign >= 0)
// Detect `progress = 1` exactly, which the rounding modes must not be asked
// to decide because the value sits on the far endpoint rather than between
// the two.
let (quotient, remainder) = total_times_divisor.div_rem_euclid(divisor)
let total_is_r2 = quotient == window.r2 && remainder.is_zero()
let rounded_unit = if total_is_r2 {
window.r2.abs()
} else {
unsigned_mode.apply(
total_times_divisor.abs(),
divisor.abs(),
window.r1.abs(),
window.r2.abs(),
)
}
if rounded_unit == window.r2.abs() {
{
duration: InternalDurationRecord::new(
window.end_duration,
time_duration_zero,
),
nudged_epoch_nanoseconds: window.end_epoch_nanoseconds,
expanded: true,
}
} else {
{
duration: InternalDurationRecord::new(
window.start_duration,
time_duration_zero,
),
nudged_epoch_nanoseconds: window.start_epoch_nanoseconds,
expanded: did_expand,
}
}
}
///|
/// `NudgeToCalendarUnit` in its "total" form, which reports the exact
/// fractional count rather than rounding.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_calendar_unit_total(
self : InternalDurationRecord,
sign : Int,
origin_epoch_nanoseconds : @int128.Int128,
dest_epoch_nanoseconds : @int128.Int128,
context : RelativeContext[P],
options : ResolvedRoundingOptions,
) -> Double raise TemporalError {
let (window, _) = self.compute_and_adjust_nudge_window(
sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
)
let dividend = dest_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
let divisor = window.end_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
if divisor.is_zero() {
raise AssertError("nudge window collapsed to a single instant")
}
let numerator = window.r1
.mul(divisor)
.add(
dividend.mul(
@int128.of_int(options.increment.get() * sign_multiplier(sign)),
),
)
exact_ratio_to_double(numerator, divisor)
}
///|
/// `NudgeToZonedTime`: rounds a time unit against a day whose length is set by
/// the time zone, so that a DST day is 23 or 25 hours long.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_to_zoned_time(
self : InternalDurationRecord,
sign : Int,
context : RelativeContext[P],
time_zone : TimeZone,
options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
let dt = context.date_time
let start = dt.iso.date.add_date_duration(self.date, Constrain)
let start_dt = IsoDateTime::new_unchecked(start, dt.iso.time)
let end_date = IsoDate::balance(
start.year,
start.month,
start.day + sign_multiplier(sign),
)
let end_dt = IsoDateTime::new_unchecked(end_date, dt.iso.time)
let start_ns = time_zone.epoch_nanoseconds_for(
start_dt,
Compatible,
context.provider,
).nanoseconds
let end_ns = time_zone.epoch_nanoseconds_for(
end_dt,
Compatible,
context.provider,
).nanoseconds
let day_span = TimeDuration::from_nanosecond_difference(end_ns, start_ns)
let unit_length = temporal_unwrap(
options.smallest_unit.as_nanoseconds(),
"time unit length",
)
let increment = @int128.of_int64(unit_length).mul(
@int128.of_int(options.increment.get()),
)
let rounded_time = self.time.round_to_increment(
increment,
options.rounding_mode,
)
let beyond_day_span = rounded_time.add(day_span.negated())
// Rounding may push past the end of the day; when it does, the result is
// re-rounded relative to the following day.
let (expanded, day_delta, rounded_time, nudged) = if beyond_day_span.sign() !=
Sign::of_int(sign).negate() {
let rounded = beyond_day_span.round_to_increment(
increment,
options.rounding_mode,
)
(
true,
sign_multiplier(sign).to_int64(),
rounded,
rounded.nanoseconds().add(end_ns),
)
} else {
(false, 0L, rounded_time, rounded_time.nanoseconds().add(start_ns))
}
let date = DateDuration::new(
self.date.years,
self.date.months,
self.date.weeks,
self.date.days + day_delta,
)
{
duration: InternalDurationRecord::new(date, rounded_time),
nudged_epoch_nanoseconds: nudged,
expanded,
}
}
///|
/// `NudgeToDayOrTime`: rounds when every unit involved has a fixed length.
fn InternalDurationRecord::nudge_to_day_or_time(
self : InternalDurationRecord,
dest_epoch_nanoseconds : @int128.Int128,
options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
let time_duration = self.time.add_days(self.date.days)
let unit_length = temporal_unwrap(
options.smallest_unit.as_nanoseconds(),
"time unit length",
)
let increment = @int128.of_int64(unit_length).mul(
@int128.of_int(options.increment.get()),
)
let rounded_time = time_duration.round_to_increment(
increment,
options.rounding_mode,
)
let diff_time = rounded_time.sub(time_duration)
let whole_days = time_duration.truncated_divide(NS_PER_DAY)
let rounded_whole_days = rounded_time.truncated_divide(NS_PER_DAY)
let delta = rounded_whole_days - whole_days
// Rounding "expanded" the day count only if it moved away from zero.
let did_expand_days = Sign::of_int64(delta) == time_duration.sign()
let nudged = diff_time.nanoseconds().add(dest_epoch_nanoseconds)
// With a date largest unit the whole days belong in the date part; with a
// time largest unit they stay in the time part as hours.
let (days, remainder) = if options.largest_unit.is_date_unit() {
(
rounded_whole_days,
rounded_time.add(
TimeDuration::from_components(
-rounded_whole_days * 24L,
0L,
0L,
0L,
@int128.zero,
@int128.zero,
),
),
)
} else {
(0L, rounded_time)
}
{
duration: InternalDurationRecord::combine(self.date.adjust(days), remainder),
nudged_epoch_nanoseconds: nudged,
expanded: did_expand_days,
}
}
///|
/// `BubbleRelativeDuration`: after an expansion, carries the result up into
/// larger units where it now reaches them exactly.
///
/// Rounding 11 months up to 12 should report one year, not twelve months, and
/// this is the pass that notices.
fn[P : TimeZoneProvider] InternalDurationRecord::bubble_relative_duration(
self : InternalDurationRecord,
sign : Int,
nudged_epoch_nanoseconds : @int128.Int128,
context : RelativeContext[P],
largest_unit : DateTimeUnit,
smallest_unit : DateTimeUnit,
) -> InternalDurationRecord raise TemporalError {
let mut duration = self
if smallest_unit == largest_unit {
return duration
}
let iso_date_time = context.date_time.iso
let largest_index = largest_unit.table_index()
let smallest_index = smallest_unit.table_index()
// The caller may pass a smallest unit below the largest one; clamping keeps
// the walk from running backwards.
let upper_bound = @cmp.maximum(smallest_index, largest_index)
for index = upper_bound - 1; index >= largest_index; index = index - 1 {
let unit = unit_from_table_index(index)
// Weeks only participate when the caller actually asked for weeks.
if unit is Week && !(largest_unit is Week) {
continue
}
let end_duration = match unit {
Year =>
DateDuration::new(
duration.date.years + sign_multiplier(sign).to_int64(),
0L,
0L,
0L,
)
Month =>
duration.date.adjust(
0L,
weeks=0L,
months=duration.date.months + sign_multiplier(sign).to_int64(),
)
_ =>
duration.date.adjust(
0L,
weeks=duration.date.weeks + sign_multiplier(sign).to_int64(),
)
}
let end = iso_date_time.date.add_date_duration(end_duration, Constrain)
let end_date_time = IsoDateTime::new_unchecked(end, iso_date_time.time)
let end_epoch_nanoseconds = context.epoch_nanoseconds_for(end_date_time)
let beyond_end = nudged_epoch_nanoseconds.sub(end_epoch_nanoseconds)
// Stop as soon as stepping one more unit would overshoot.
if beyond_end.signum() != -sign_multiplier(sign) {
duration = InternalDurationRecord::from_date_duration(end_duration)
} else {
break
}
}
duration
}
///|
/// `RoundRelativeDuration`: rounds a duration against a reference point.
fn[P : TimeZoneProvider] InternalDurationRecord::round_relative_duration(
self : InternalDurationRecord,
origin_epoch_nanoseconds : @int128.Int128,
dest_epoch_nanoseconds : @int128.Int128,
date_time : PlainDateTime,
time_zone : TimeZone?,
provider : P,
options : ResolvedRoundingOptions,
) -> InternalDurationRecord raise TemporalError {
let context = { date_time, time_zone, provider }
// A unit is "irregular" when its length depends on the reference point:
// calendar units always, and days too once a time zone is involved.
let irregular_length_unit = options.smallest_unit.is_calendar_unit() ||
(time_zone is Some(_) && options.smallest_unit is Day)
let sign = self.sign().to_multiplier()
let nudge_result = if irregular_length_unit {
self.nudge_calendar_unit(
sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
)
} else if time_zone is Some(zone) {
self.nudge_to_zoned_time(sign, context, zone, options)
} else {
self.nudge_to_day_or_time(dest_epoch_nanoseconds, options)
}
let duration = nudge_result.duration
if nudge_result.expanded && !(options.smallest_unit is Week) {
let start_unit = options.smallest_unit.larger(Day)
duration.bubble_relative_duration(
sign,
nudge_result.nudged_epoch_nanoseconds,
context,
options.largest_unit,
start_unit,
)
} else {
duration
}
}
///|
/// `TotalRelativeDuration`: the duration as an exact fractional count of
/// `unit`, measured against a reference point.
fn[P : TimeZoneProvider] InternalDurationRecord::total_relative_duration(
self : InternalDurationRecord,
origin_epoch_nanoseconds : @int128.Int128,
dest_epoch_nanoseconds : @int128.Int128,
date_time : PlainDateTime,
time_zone : TimeZone?,
provider : P,
unit : DateTimeUnit,
) -> Double raise TemporalError {
if unit.is_calendar_unit() || (time_zone is Some(_) && unit is Day) {
let context = { date_time, time_zone, provider }
return self.nudge_calendar_unit_total(
self.sign().to_multiplier(),
origin_epoch_nanoseconds,
dest_epoch_nanoseconds,
context,
{
largest_unit: unit,
smallest_unit: unit,
increment: rounding_increment_one,
rounding_mode: Trunc,
},
)
}
self.time.add_days(self.date.days).total(unit)
}
///|
/// Truncates a value toward zero to a multiple of `increment`.
fn truncate_to_increment(
value : @int128.Int128,
increment : @int128.Int128,
) -> @int128.Int128 {
value.div(increment).mul(increment)
}
///|
/// Narrows a 128-bit count to `Int64`, raising when it does not fit.
///
/// A duration whose component exceeds this range cannot be valid, so the
/// failure surfaces as a range error rather than a silent wrap.
fn int64_of(value : @int128.Int128) -> Int64 raise TemporalError {
match value.to_int64() {
Some(v) => v
None => raise RangeError("duration component exceeds its valid range")
}
}
///|
/// Coerces a sign to `-1` or `1`, mapping zero to `1`.
fn sign_multiplier(sign : Int) -> Int {
if sign < 0 {
-1
} else {
1
}
}