// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A unit of time, from a year down to a nanosecond.
///
/// The first four have no fixed length — a month is not a number of days and a
/// day is not a number of hours wherever the clocks change — so they are the
/// units a [`Period`] is counted in rather than a [`Span`]. The rest are exact,
/// and are what a span's nanoseconds are read back as.
#derive(Eq, Debug)
pub(all) enum TimeUnit {
Years
Months
Weeks
Days
Hours
Minutes
Seconds
Millis
Micros
Nanos
}
///|
pub extend TimeUnit with Debug::{to_repr}
///|
pub extend TimeUnit with Eq::{not_equal, equal}
///|
/// How many nanoseconds the exact units hold. `None` for the four that have no
/// fixed length.
pub fn TimeUnit::nanos(self : TimeUnit) -> Int64? {
match self {
Hours => Some(3_600_000_000_000L)
Minutes => Some(60_000_000_000L)
Seconds => Some(1_000_000_000L)
Millis => Some(1_000_000L)
Micros => Some(1_000L)
Nanos => Some(1L)
_ => None
}
}
///|
/// Whether the unit has a fixed length: everything below a day.
pub fn TimeUnit::is_exact(self : TimeUnit) -> Bool {
match self {
Years | Months | Weeks | Days => false
_ => true
}
}
///|
/// Which way a value that falls between two others is moved.
///
/// `Trunc` is towards zero, `Expand` away from it, which is what a negative
/// value needs to be rounded "up" the way `Ceil` rounds a positive one. The
/// `Half` forms differ only in what they do with an exact half, and `HalfEven`
/// is the banker's rounding that keeps a long sum from drifting.
#derive(Eq, Debug)
pub(all) enum Rounding {
Ceil
Floor
Expand
Trunc
HalfCeil
HalfFloor
HalfExpand
HalfTrunc
HalfEven
}
///|
pub extend Rounding with Debug::{to_repr}
///|
pub extend Rounding with Eq::{not_equal, equal}
///|
/// `value` moved to the nearest multiple of `increment`.
///
/// The remainder is taken towards zero, so the sign is the value's own and the
/// direction words mean what they say for either sign: `Ceil` is towards
/// positive infinity, `Expand` away from zero.
pub fn round_increment(
value : Int64,
increment : Int64,
rounding : Rounding,
) -> Int64 {
if increment <= 1L {
return value
}
let rest = value % increment
if rest == 0L {
return value
}
let down = value - rest
let up = if value > 0L { down + increment } else { down - increment }
// The two candidates, named by which way they move: `near` is the one the
// mode's default reaches for, `far` the other.
let twice = (if rest < 0L { -rest } else { rest }) * 2L
match rounding {
Ceil => if value > 0L { up } else { down }
Floor => if value > 0L { down } else { up }
// Away from zero is the same direction on both sides of it.
Expand => up
Trunc => down
HalfCeil =>
if twice > increment || (twice == increment && value > 0L) {
up
} else {
down
}
HalfFloor =>
if twice > increment || (twice == increment && value < 0L) {
up
} else {
down
}
HalfExpand => if twice >= increment { up } else { down }
HalfTrunc => if twice > increment { up } else { down }
HalfEven =>
if twice > increment {
up
} else if twice < increment {
down
} else if down / increment % 2L == 0L {
down
} else {
up
}
}
}
///|
/// A length of time written in calendar units as well as clock units.
///
/// [`Span`] is a number of nanoseconds: an hour is sixty minutes and nothing
/// else. A period is what a calendar adds — a month is the same day of the next
/// month, a day is the same clock reading tomorrow, and both are a different
/// number of nanoseconds depending on where and when they are added. That is why
/// adding one needs a zone and a starting point, and why the answer to "how far
/// apart are these two dates" depends on the units it is asked for.
///
/// The fields are not normalized against each other: `Period::new(months=25)` is
/// twenty-five months and not two years and a month, because that is what was
/// asked for and what a round trip has to give back.
#derive(Eq, Debug)
pub(all) struct Period {
years : Int64
months : Int64
weeks : Int64
days : Int64
hours : Int64
minutes : Int64
seconds : Int64
nanos : Int64
}
///|
pub extend Period with Debug::{to_repr}
///|
pub extend Period with Eq::{not_equal, equal}
///|
/// A period of each named field.
pub fn Period::new(
years~ : Int64 = 0L,
months~ : Int64 = 0L,
weeks~ : Int64 = 0L,
days~ : Int64 = 0L,
hours~ : Int64 = 0L,
minutes~ : Int64 = 0L,
seconds~ : Int64 = 0L,
millis~ : Int64 = 0L,
micros~ : Int64 = 0L,
nanos~ : Int64 = 0L,
) -> Period {
{
years,
months,
weeks,
days,
hours,
minutes,
seconds,
nanos: millis * 1_000_000L + micros * 1_000L + nanos,
}
}
///|
/// The period of no length at all.
pub let zero : Period = {
years: 0L,
months: 0L,
weeks: 0L,
days: 0L,
hours: 0L,
minutes: 0L,
seconds: 0L,
nanos: 0L,
}
///|
/// `n` of one unit.
pub fn Period::of(unit : TimeUnit, n : Int64) -> Period {
match unit {
Years => { ..zero, years: n, }
Months => { ..zero, months: n, }
Weeks => { ..zero, weeks: n, }
Days => { ..zero, days: n, }
Hours => { ..zero, hours: n, }
Minutes => { ..zero, minutes: n, }
Seconds => { ..zero, seconds: n, }
Millis => { ..zero, nanos: n * 1_000_000L, }
Micros => { ..zero, nanos: n * 1_000L, }
Nanos => { ..zero, nanos: n, }
}
}
///|
/// Whether every field is zero.
pub fn Period::is_zero(self : Period) -> Bool {
self.years == 0L &&
self.months == 0L &&
self.weeks == 0L &&
self.days == 0L &&
self.hours == 0L &&
self.minutes == 0L &&
self.seconds == 0L &&
self.nanos == 0L
}
///|
/// The same period the other way round.
pub fn Period::neg(self : Period) -> Period {
{
years: -self.years,
months: -self.months,
weeks: -self.weeks,
days: -self.days,
hours: -self.hours,
minutes: -self.minutes,
seconds: -self.seconds,
nanos: -self.nanos,
}
}
///|
/// One period added to another, field by field.
pub fn Period::add(self : Period, other : Period) -> Period {
{
years: self.years + other.years,
months: self.months + other.months,
weeks: self.weeks + other.weeks,
days: self.days + other.days,
hours: self.hours + other.hours,
minutes: self.minutes + other.minutes,
seconds: self.seconds + other.seconds,
nanos: self.nanos + other.nanos,
}
}
///|
/// The clock part as a span: the fields from hours down, which are exact.
pub fn Period::span(self : Period) -> Span {
{
nanos: self.hours * 3_600_000_000_000L +
self.minutes * 60_000_000_000L +
self.seconds * 1_000_000_000L +
self.nanos,
}
}
///|
/// Whether the period has a part a calendar has to place: years, months, weeks
/// or days.
pub fn Period::has_date(self : Period) -> Bool {
self.years != 0L || self.months != 0L || self.weeks != 0L || self.days != 0L
}
///|
/// The sign of the whole period: 1, -1, or 0 when it is empty. A period whose
/// fields disagree has no single sign, and the largest field that is not zero
/// is what a difference between two values gives.
pub fn Period::sign(self : Period) -> Int32 {
for
n in [
self.years,
self.months,
self.weeks,
self.days,
self.hours,
self.minutes,
self.seconds,
self.nanos,
] {
if n > 0L {
return 1
}
if n < 0L {
return -1
}
}
0
}
///|
/// Whether the fields disagree about which way the period runs.
///
/// A period is a thing to add, and a calendar cannot add one that both goes
/// forward and back: the answer would depend on the order the fields were taken
/// in, which is not a length. A difference between two values never has this
/// shape; a period written by hand can.
pub fn Period::has_mixed_sign(self : Period) -> Bool {
let mut forward = false
let mut backward = false
for
n in [
self.years,
self.months,
self.weeks,
self.days,
self.hours,
self.minutes,
self.seconds,
self.nanos,
] {
if n > 0L {
forward = true
}
if n < 0L {
backward = true
}
}
forward && backward
}
///|
/// A period as a sentence: the ISO 8601 text for a duration.
pub impl Show for Period with fn output(self, logger) {
logger.write_string(self.to_string())
}
///|
pub extend Period with Show::{output}
///|
/// The period as the ISO 8601 text for a duration: `P1Y2M3DT4H5M6.007S`.
///
/// A period whose fields all run the same way is written with one sign in front
/// and the fields in absolute value, which is how a reader expects a duration to
/// look; a period that mixes them — `until` never gives one, a caller can — is
/// written with each field signed, which is the only way to say it in the
/// grammar.
pub fn Period::to_string(self : Period) -> String {
if self.is_zero() {
return "PT0S"
}
let sign = self.sign()
let mut mixed = false
for
n in [
self.years,
self.months,
self.weeks,
self.days,
self.hours,
self.minutes,
self.seconds,
self.nanos,
] {
if n != 0L && (if n > 0L { 1 } else { -1 }) != sign {
mixed = true
}
}
let put = fn(n : Int64) -> String {
if mixed {
n.to_string()
} else {
(if n < 0L { -n } else { n }).to_string()
}
}
let out = StringBuilder()
if !mixed && sign < 0 {
out.write_string("-")
}
out.write_string("P")
for
(n, letter) in [
(self.years, "Y"),
(self.months, "M"),
(self.weeks, "W"),
(self.days, "D"),
] {
if n != 0L {
out.write_string(put(n))
out.write_string(letter)
}
}
let seconds = self.seconds
if self.hours != 0L || self.minutes != 0L || seconds != 0L || self.nanos != 0L {
out.write_string("T")
if self.hours != 0L {
out.write_string(put(self.hours))
out.write_string("H")
}
if self.minutes != 0L {
out.write_string(put(self.minutes))
out.write_string("M")
}
if seconds != 0L || self.nanos != 0L {
out.write_string(put(seconds))
if self.nanos != 0L {
let rest = if self.nanos < 0L { -self.nanos } else { self.nanos }
let mut digits = rest.to_string()
while digits.length() < 9 {
digits = "0" + digits
}
while digits.length() > 1 &&
digits[digits.length() - 1].unsafe_to_char() == '0' {
digits = digits[:digits.length() - 1].to_owned()
}
out.write_string(".")
out.write_string(digits)
}
out.write_string("S")
}
}
out.to_string()
}