// Time humanization (faithful port of python-humanize time.py).
// Phase 2 target functions: naturaltime, naturalday, naturaldate, naturaldelta,
// precisedelta. See docs/specs/port-to-moonbit.md.
//
// The Python API accepts a `datetime.timedelta`, a number of seconds, or a
// `datetime`. MoonBit is statically typed, so the dual "timedelta or number"
// input is modeled with the `TimeInput` enum:
// * `TimeInput::Seconds(Double)` — a number of seconds (note: for
// `naturaldelta`/`precisedelta` the value is used verbatim; for
// `naturaltime` it is rounded to the nearest second like Python).
// * `TimeInput::Delta(TimeDelta)` — an explicit duration.
// A duration mirroring Python's `datetime.timedelta` (days/seconds/microseconds).
// All components are kept non-negative; `is_neg` records the original sign of the
// input so callers can recover it (used by `naturaltime` tense).
pub struct TimeDelta {
days : Int
seconds : Int
microseconds : Int
is_neg : Bool
}
/// Enumeration of the supported time units, ordered from smallest to largest.
pub enum TimeUnit {
MICROSECONDS
MILLISECONDS
SECONDS
MINUTES
HOURS
DAYS
MONTHS
YEARS
}
// Public accessors so callers (and tests) can reference the unit variants,
// which are not individually exported by `pub enum` in this toolchain.
pub fn TimeUnit::microseconds() -> TimeUnit { TimeUnit::MICROSECONDS }
pub fn TimeUnit::milliseconds() -> TimeUnit { TimeUnit::MILLISECONDS }
pub fn TimeUnit::seconds() -> TimeUnit { TimeUnit::SECONDS }
pub fn TimeUnit::minutes() -> TimeUnit { TimeUnit::MINUTES }
pub fn TimeUnit::hours() -> TimeUnit { TimeUnit::HOURS }
pub fn TimeUnit::days() -> TimeUnit { TimeUnit::DAYS }
pub fn TimeUnit::months() -> TimeUnit { TimeUnit::MONTHS }
pub fn TimeUnit::years() -> TimeUnit { TimeUnit::YEARS }
impl Eq for TimeUnit with equal(self, other) {
match (self, other) {
(TimeUnit::MICROSECONDS, TimeUnit::MICROSECONDS) => true
(TimeUnit::MILLISECONDS, TimeUnit::MILLISECONDS) => true
(TimeUnit::SECONDS, TimeUnit::SECONDS) => true
(TimeUnit::MINUTES, TimeUnit::MINUTES) => true
(TimeUnit::HOURS, TimeUnit::HOURS) => true
(TimeUnit::DAYS, TimeUnit::DAYS) => true
(TimeUnit::MONTHS, TimeUnit::MONTHS) => true
(TimeUnit::YEARS, TimeUnit::YEARS) => true
_ => false
}
}
/// Dual input for the time humanizers: a number of seconds or an explicit delta.
/// Construct via the `from_seconds` / `from_delta` factory functions (pub enum
/// constructors are not individually exported in this toolchain).
pub enum TimeInput {
Seconds(Double)
Delta(TimeDelta)
}
/// Public entry point: build a `TimeInput` from a number of seconds.
pub fn TimeInput::from_seconds(s : Double) -> TimeInput {
TimeInput::Seconds(s)
}
/// Public entry point: build a `TimeInput` from an explicit delta.
pub fn TimeInput::from_delta(d : TimeDelta) -> TimeInput {
TimeInput::Delta(d)
}
// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------
/// Build a `TimeDelta` from explicit days/seconds/microseconds, normalized the
/// same way as Python's `datetime.timedelta`.
pub fn timedelta(
days~ : Int = 0,
seconds~ : Int = 0,
microseconds~ : Int = 0
) -> TimeDelta {
let day_us : Int64 = 86400L * 1_000_000L
let total : Int64 =
days.to_int64() * day_us + seconds.to_int64() * 1_000_000L +
microseconds.to_int64()
let neg = total < 0L
let t = if neg { -total } else { total }
let d = (t / day_us).to_int()
let rem = t % day_us
let s = (rem / 1_000_000L).to_int()
let us = (rem % 1_000_000L).to_int()
TimeDelta::{ days: d, seconds: s, microseconds: us, is_neg: neg }
}
/// Build a `TimeDelta` from a (possibly fractional) number of seconds, without
/// rounding — matching `_date_and_delta`'s `timedelta(seconds=float(value))`.
fn seconds_to_delta(n : Double) -> TimeDelta {
let neg = n < 0.0
let abs = if neg { -n } else { n }
// Use Int64 to avoid Int32 overflow: 86400 * 1_000_000 exceeds 2^31.
let day_us : Int64 = 86400L * 1_000_000L
let total : Int64 = (abs * 1_000_000.0).round().to_int64()
let d = (total / day_us).to_int()
let rem = total % day_us
let s = (rem / 1_000_000L).to_int()
let us = (rem % 1_000_000L).to_int()
TimeDelta::{ days: d, seconds: s, microseconds: us, is_neg: neg }
}
// Round half to even (banker's rounding), mirroring Python's `round` used by
// python-humanize's `naturaltime` for the plain-seconds input path.
fn round_half_even(x : Double) -> Double {
let f = @math.floor(x)
if (x - f) == 0.5 {
if (f.to_int() % 2) == 0 {
f
} else {
f + 1.0
}
} else {
@math.round(x)
}
}
// ---------------------------------------------------------------------------
// Unit helpers
// ---------------------------------------------------------------------------
fn TimeUnit::noun(self : TimeUnit) -> String {
match self {
TimeUnit::MICROSECONDS => "microsecond"
TimeUnit::MILLISECONDS => "millisecond"
TimeUnit::SECONDS => "second"
TimeUnit::MINUTES => "minute"
TimeUnit::HOURS => "hour"
TimeUnit::DAYS => "day"
TimeUnit::MONTHS => "month"
TimeUnit::YEARS => "year"
}
}
// Substitute the first "%d" placeholder in `s` with `repl`. The python-humanize
// `.po` msgids follow the gettext convention `"%d second"` / `"%d seconds"`;
// after translation we fill in the count. A manual scan (rather than relying on
// a `String::replace` availability) keeps this version-agnostic.
fn replace_first_d(s : String, repl : String) -> String {
let chars = s.to_array()
let n = chars.length()
let mut i = 0
while i < n - 1 {
if chars[i] == '%' && chars[i + 1] == 'd' {
return String::from_array(chars[0:i]) + repl +
String::from_array(chars[i + 2:n])
}
i = i + 1
}
s
}
// Localized unit noun (without the count) for `precisedelta` / year combos.
// Mirrors python-humanize, whose `.po` msgids are `"%d second"` /
// `"%d seconds"`; the count is stripped so callers can prepend a (possibly
// grouped) number themselves.
fn _plural_unit_text(unit : TimeUnit, count : Int) -> String {
let singular = unit.noun()
let word = ngettext("%d " + singular, "%d " + singular + "s", count)
// Drop the leading "%d " placeholder so only the unit word remains.
let chars = word.to_array()
if chars.length() >= 3 && chars[0] == '%' && chars[1] == 'd' && chars[2] == ' ' {
String::from_array(chars[3:])
} else {
word
}
}
fn TimeUnit::ordinal(self : TimeUnit) -> Int {
match self {
TimeUnit::MICROSECONDS => 0
TimeUnit::MILLISECONDS => 1
TimeUnit::SECONDS => 2
TimeUnit::MINUTES => 3
TimeUnit::HOURS => 4
TimeUnit::DAYS => 5
TimeUnit::MONTHS => 6
TimeUnit::YEARS => 7
}
}
fn TimeUnit::same(a : TimeUnit, b : TimeUnit) -> Bool {
a.ordinal() == b.ordinal()
}
fn TimeUnit::contains(list : Array[TimeUnit], u : TimeUnit) -> Bool {
let mut i = 0
while i < list.length() {
if TimeUnit::same(list[i], u) { return true }
i = i + 1
}
false
}
// ---------------------------------------------------------------------------
// naturaldelta
// ---------------------------------------------------------------------------
/// Humanize a time delta into a short, natural-language string.
pub fn naturaldelta(
value : TimeInput,
months~ : Bool = true,
minimum_unit~ : TimeUnit = TimeUnit::SECONDS
) -> String {
if minimum_unit != TimeUnit::SECONDS &&
minimum_unit != TimeUnit::MILLISECONDS &&
minimum_unit != TimeUnit::MICROSECONDS {
abort("Minimum unit '" + minimum_unit.noun() + "s' not supported")
}
let delta = match value {
TimeInput::Delta(d) => d
TimeInput::Seconds(s) => seconds_to_delta(s)
}
let neg = delta.is_neg
let d = TimeDelta::{
days: delta.days,
seconds: delta.seconds,
microseconds: delta.microseconds,
is_neg: neg,
}
let use_months = months
let years = d.days / 365
let days = d.days % 365
let num_months = @math.round(days.to_double() / 30.5).to_int()
if years == 0 && days < 1 {
if d.seconds == 0 {
if minimum_unit == TimeUnit::MICROSECONDS && d.microseconds < 1000 {
return _nunit(d.microseconds, "microsecond", "microseconds")
}
if minimum_unit == TimeUnit::MILLISECONDS ||
(minimum_unit == TimeUnit::MICROSECONDS &&
1000 <= d.microseconds && d.microseconds < 1_000_000) {
let ms = d.microseconds / 1000
return _nunit(ms, "millisecond", "milliseconds")
}
return gettext("a moment")
}
if d.seconds == 1 {
return gettext("a second")
}
if d.seconds < 60 {
return _nunit(d.seconds, "second", "seconds")
}
if 60 <= d.seconds && d.seconds < 3600 {
let minutes = @math.round(d.seconds.to_double() / 60.0).to_int()
if minutes == 1 {
return gettext("a minute")
}
if minutes == 60 {
return gettext("an hour")
}
return _nunit(minutes, "minute", "minutes")
}
if 3600 <= d.seconds {
let hours = @math.round(d.seconds.to_double() / 3600.0).to_int()
if hours == 1 {
return gettext("an hour")
}
if hours == 24 {
return gettext("a day")
}
return _nunit(hours, "hour", "hours")
}
} else if years == 0 {
if days == 1 {
return gettext("a day")
}
if !(use_months) {
return _nunit(days, "day", "days")
}
if num_months == 0 {
return _nunit(days, "day", "days")
}
if num_months == 1 {
return gettext("a month")
}
if num_months == 12 {
return gettext("a year")
}
return _nunit(num_months, "month", "months")
} else if years == 1 {
if num_months == 0 && days == 0 {
return gettext("a year")
}
if num_months == 0 {
return _nyear_days(days)
}
if use_months {
if num_months == 1 {
return gettext("1 year, 1 month")
}
if num_months == 12 {
return _nunit(2, "year", "years")
}
return _nyear_months(num_months)
}
return _nyear_days(days)
}
_nunit_big(years, "year", "years")
}
fn _nunit(n : Int, singular : String, plural : String) -> String {
// Look up the python gettext msgids ("%d second" / "%d seconds") and fill
// in the count so localized unit forms render correctly.
let word = ngettext("%d " + singular, "%d " + plural, n)
replace_first_d(word, n.to_string())
}
fn _nyear_days(days : Int) -> String {
if days == 1 {
gettext("1 year, 1 day")
} else {
gettext("1 year, ") + days.to_string() + " " + _plural_unit_text(
TimeUnit::DAYS,
days,
)
}
}
fn _nyear_months(months : Int) -> String {
if months == 1 {
gettext("1 year, 1 month")
} else {
gettext("1 year, ") + months.to_string() + " " + _plural_unit_text(
TimeUnit::MONTHS,
months,
)
}
}
// Years >= 2: use intcomma for thousands separators.
fn _nunit_big(n : Int, singular : String, plural : String) -> String {
// Like `_nunit`, but the count is grouped with the locale's thousands
// separator (handled by `intcomma`).
let word = ngettext("%d " + singular, "%d " + plural, n)
replace_first_d(word, intcomma(n.to_string()))
}
// ---------------------------------------------------------------------------
// naturaltime
// ---------------------------------------------------------------------------
/// Humanize a time relative to a reference point. For `TimeInput::Delta` the
/// tense follows the sign of the delta (negative → "from now"); for
/// `TimeInput::Seconds` the value is rounded to the nearest second (matching
/// Python) and rendered in the past tense by default.
pub fn naturaltime(
value : TimeInput,
future~ : Bool = false,
months~ : Bool = true,
minimum_unit~ : TimeUnit = TimeUnit::SECONDS,
when~ : DateTime? = None
) -> String {
// NOTE: MoonBit's `TimeInput` has no absolute-datetime variant, so for
// Seconds/Delta inputs `when~` does not change the output (Python also derives
// tense from sign and cancels `when` for timedelta/float inputs). Full effect
// requires an absolute datetime input, deferred (YAGNI).
let (delta, is_future) = match value {
TimeInput::Delta(d) => (d, d.is_neg)
TimeInput::Seconds(s) => {
let rounded = round_half_even(s)
(seconds_to_delta(rounded), false)
}
}
let base = naturaldelta(TimeInput::Delta(delta), months~, minimum_unit~)
if base == gettext("a moment") {
return gettext("now")
}
if is_future || future {
base + gettext(" from now")
} else {
base + gettext(" ago")
}
}
// naturaltime `when~` signature-compat test (whitebox). For relative inputs
// (Seconds/Delta) `when~` does not change output (same as Python for timedelta/
// float inputs), so passing when~=None must equal the no-`when` call.
test "naturaltime when param compat" {
@test.assert_eq(
naturaltime(TimeInput::from_seconds(22.5), when = None),
"22 seconds ago",
)
@test.assert_eq(naturaltime(TimeInput::from_seconds(22.5)), "22 seconds ago")
@test.assert_eq(
naturaltime(TimeInput::from_delta(timedelta(days=1)), when = None),
"a day ago",
)
}
// ---------------------------------------------------------------------------
// naturalday / naturaldate
// ---------------------------------------------------------------------------
/// A calendar date (proleptic Gregorian).
pub(all) struct Date {
year : Int
month : Int
day : Int
}
/// An absolute date-time. Declared to mirror python-humanize's `naturaltime(
/// when=datetime)` reference point. NOTE: MoonBit's `TimeInput` only models
/// *relative* durations (seconds/delta), so `when` does not currently change
/// output for those inputs (Python also cancels `when` for timedelta/float
/// inputs). Full effect requires an absolute-datetime `TimeInput` variant,
/// deferred (YAGNI).
pub struct DateTime {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
}
/// Current local calendar day, derived from `now()` (nanosecond epoch).
pub fn Date::today() -> Date {
let nanos = now()
let days_since_epoch = (nanos / 86400_000_000_000L).to_int()
let (y, m, d) = _gregorian_from_epoch_days(days_since_epoch)
Date::{ year: y, month: m, day: d }
}
/// Inverse of `_days_since_epoch`: recover (year, month, day) from days since
/// the proleptic-Gregorian epoch. Uses Howard Hinnant's civil-from-days
/// (exact inverse of `_days_since_epoch`'s `era*146097+doe-719468`).
fn _gregorian_from_epoch_days(z : Int) -> (Int, Int, Int) {
let z0 = z + 719468
let era = (if z0 >= 0 { z0 } else { z0 - 146096 }) / 146097
let doe = z0 - era * 146097
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let y = yoe + era * 400
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let m = if mp < 10 { mp + 3 } else { mp - 9 }
let d = doy - (153 * mp + 2) / 5 + 1
let year = if m <= 2 { y + 1 } else { y }
(year, m, d)
}
test "date today valid" {
let t = Date::today()
@test.assert_eq(t.month >= 1 && t.month <= 12, true)
@test.assert_eq(t.day >= 1 && t.day <= 31, true)
@test.assert_eq(t.year > 1970, true)
}
test "date epoch roundtrip" {
let samples = [
Date::{ year: 1970, month: 1, day: 1 },
Date::{ year: 2026, month: 8, day: 19 },
Date::{ year: 2000, month: 2, day: 29 },
Date::{ year: 2024, month: 12, day: 31 },
Date::{ year: 1999, month: 3, day: 1 },
]
for s in samples {
let z = _days_since_epoch(s)
let (y, m, d) = _gregorian_from_epoch_days(z)
@test.assert_eq((y, m, d), (s.year, s.month, s.day))
}
}
/// Humanize a date relative to `when`. Returns today/tomorrow/yesterday or a
/// formatted date string.
pub fn naturalday(
value : Date,
when~ : Date = Date::today(),
format~ : String = "%b %d"
) -> String {
let d = _date_diff_days(value, when)
if d == 0 {
return gettext("today")
}
if d == 1 {
return gettext("tomorrow")
}
if d == -1 {
return gettext("yesterday")
}
_format_date(value, format)
}
/// Like `naturalday`, but append a year for dates more than ~five months away
/// from `when` (Python's `abs(value - when).days >= 5 * 365 / 12` rule).
pub fn naturaldate(
value : Date,
when~ : Date = Date::today()
) -> String {
let d = _date_diff_days(value, when)
if d == 0 {
return gettext("today")
}
if d == 1 {
return gettext("tomorrow")
}
if d == -1 {
return gettext("yesterday")
}
if d.abs() >= 153 {
_format_date(value, "%b %d %Y")
} else {
_format_date(value, "%b %d")
}
}
// naturalday / naturaldate fixed-`when~` tests (whitebox: Date is constructible
// here; the blackbox test package sees only the published read-only Date).
// Expected strings derive from Python's formatting rule (strftime "%b %d";
// abs(diff)>=152 -> "%b %d %Y"). Python's naturalday/naturaldate take no `when`
// param, so MoonBit's `when~` is a super-set extension; golden values come from
// the formatting rule.
test "naturalday fixed when" {
let today = Date::{ year: 2026, month: 8, day: 19 }
@test.assert_eq(naturalday(Date::{ year: 2026, month: 8, day: 19 }, when = today), "today")
@test.assert_eq(naturalday(Date::{ year: 2026, month: 8, day: 20 }, when = today), "tomorrow")
@test.assert_eq(naturalday(Date::{ year: 2026, month: 8, day: 18 }, when = today), "yesterday")
@test.assert_eq(naturalday(Date::{ year: 2026, month: 8, day: 21 }, when = today), "Aug 21")
@test.assert_eq(naturalday(Date::{ year: 2026, month: 8, day: 14 }, when = today), "Aug 14")
}
test "naturaldate fixed when" {
let today = Date::{ year: 2026, month: 8, day: 19 }
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 8, day: 21 }, when = today), "Aug 21")
// diff = -157 (>=152) -> with year
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 3, day: 15 }, when = today), "Mar 15 2026")
}
// naturaldate five-month-rule boundary. Python 4.16.0 uses
// `delta.days >= 5*365/12` where `5*365/12 = 152.0833`, so for integer
// `delta.days` the cutoff is `abs(diff) >= 153`. Golden values derived from
// Python's formatting rule (strftime "%b %d %Y" vs "%b %d").
test "naturaldate five month rule" {
let today = Date::{ year: 2026, month: 8, day: 19 }
// diff = -199 (>=153) -> with year
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 2, day: 1 }, when = today), "Feb 01 2026")
// diff = -100 (<153) -> without year
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 5, day: 11 }, when = today), "May 11")
// diff = -152 (exactly 152, <153) -> WITHOUT year (Python: "Mar 20")
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 3, day: 20 }, when = today), "Mar 20")
// diff = -153 (>=153) -> with year
@test.assert_eq(naturaldate(Date::{ year: 2026, month: 3, day: 19 }, when = today), "Mar 19 2026")
}
fn _date_diff_days(value : Date, when : Date) -> Int {
_days_since_epoch(value) - _days_since_epoch(when)
}
fn _days_since_epoch(d : Date) -> Int {
let y = if d.month <= 2 { d.year - 1 } else { d.year }
let m = if d.month <= 2 { d.month + 12 } else { d.month }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let doy = (153 * (m - 3) + 2) / 5 + d.day - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era * 146097 + doe - 719468
}
fn _format_date(d : Date, format : String) -> String {
if format == "%b %d" {
_month_abbr(d.month) + " " + _two_digit(d.day)
} else if format == "%b %d %Y" {
_month_abbr(d.month) + " " + _two_digit(d.day) + " " + d.year.to_string()
} else if format == "%Y.%m.%d" {
d.year.to_string() + "." + _two_digit(d.month) + "." + _two_digit(d.day)
} else {
d.year.to_string() + "-" + _two_digit(d.month) + "-" + _two_digit(d.day)
}
}
fn _two_digit(n : Int) -> String {
if n < 10 { "0" + n.to_string() } else { n.to_string() }
}
fn _month_abbr(m : Int) -> String {
match m {
1 => "Jan"
2 => "Feb"
3 => "Mar"
4 => "Apr"
5 => "May"
6 => "Jun"
7 => "Jul"
8 => "Aug"
9 => "Sep"
10 => "Oct"
11 => "Nov"
12 => "Dec"
_ => "???"
}
}
// ---------------------------------------------------------------------------
// precisedelta
// ---------------------------------------------------------------------------
/// Humanize a time delta in full detail, combining several units
/// (e.g. "5 days, 4 hours, 3 seconds and 2 microseconds").
pub fn precisedelta(
value : TimeInput,
minimum_unit~ : TimeUnit = TimeUnit::SECONDS,
format~ : String = "%0.2f",
suppress~ : Array[TimeUnit] = []
) -> String {
let delta = match value {
TimeInput::Delta(d) => d
TimeInput::Seconds(s) => seconds_to_delta(s)
}
let neg = delta.is_neg
let d = TimeDelta::{
days: delta.days,
seconds: delta.seconds,
microseconds: delta.microseconds,
is_neg: neg,
}
let mut suppress_set = suppress
let mut min_unit = minimum_unit
// Find a suitable minimum unit (greater than the given one if suppressed).
if TimeUnit::contains(suppress_set, min_unit) {
let order = [
TimeUnit::MICROSECONDS, TimeUnit::MILLISECONDS, TimeUnit::SECONDS, TimeUnit::MINUTES,
TimeUnit::HOURS, TimeUnit::DAYS, TimeUnit::MONTHS, TimeUnit::YEARS,
]
let mut found : TimeUnit? = None
let mut i = 0
while i < order.length() {
if order[i].ordinal() > min_unit.ordinal() && !(TimeUnit::contains(suppress_set, order[i])) {
found = Some(order[i])
break
}
i = i + 1
}
match found {
Some(u) => min_unit = u
None => abort("Minimum unit is suppressed and no suitable replacement was found")
}
}
// Extend the suppressed set with all units below the minimum unit.
let all_order = [
TimeUnit::MICROSECONDS, TimeUnit::MILLISECONDS, TimeUnit::SECONDS, TimeUnit::MINUTES,
TimeUnit::HOURS, TimeUnit::DAYS, TimeUnit::MONTHS, TimeUnit::YEARS,
]
let mut si = 0
while si < all_order.length() {
if TimeUnit::same(all_order[si], min_unit) {
break
}
suppress_set = suppress_set + [all_order[si]]
si = si + 1
}
let mut years = d.days.to_double()
let mut days = 0.0
let mut months = 0.0
let mut secs = d.seconds.to_double()
let mut usecs = d.microseconds.to_double()
let mut msecs = 0.0
let mut hours = 0.0
let mut minutes = 0.0
let q1 = _quotient_and_remainder(years, 365.0, TimeUnit::YEARS, min_unit, suppress_set, format)
years = q1.0
days = q1.1
let q2 = _quotient_and_remainder(days, 30.5, TimeUnit::MONTHS, min_unit, suppress_set, format)
months = q2.0
days = q2.1
let secs_from_days = days * 24.0 * 3600.0
let total_secs = secs_from_days + secs
let q3 = _quotient_and_remainder(total_secs, 24.0 * 3600.0, TimeUnit::DAYS, min_unit, suppress_set, format)
days = q3.0
secs = q3.1
let q4 = _quotient_and_remainder(secs, 3600.0, TimeUnit::HOURS, min_unit, suppress_set, format)
hours = q4.0
secs = q4.1
let q5 = _quotient_and_remainder(secs, 60.0, TimeUnit::MINUTES, min_unit, suppress_set, format)
minutes = q5.0
secs = q5.1
let total_usecs = secs * 1000000.0 + usecs
let q6 = _quotient_and_remainder(total_usecs, 1000000.0, TimeUnit::SECONDS, min_unit, suppress_set, format)
secs = q6.0
usecs = q6.1
let q7 = _quotient_and_remainder(usecs, 1000.0, MILLISECONDS, min_unit, suppress_set, format)
msecs = q7.0
usecs = q7.1
// Promotions due to rounding.
if msecs >= 1000.0 && !(TimeUnit::contains(suppress_set, TimeUnit::SECONDS)) {
msecs = msecs - 1000.0
secs = secs + 1.0
}
if secs >= 60.0 && !(TimeUnit::contains(suppress_set, TimeUnit::MINUTES)) {
secs = secs - 60.0
minutes = minutes + 1.0
}
if minutes >= 60.0 && !(TimeUnit::contains(suppress_set, TimeUnit::HOURS)) {
minutes = minutes - 60.0
hours = hours + 1.0
}
if hours >= 24.0 && !(TimeUnit::contains(suppress_set, TimeUnit::DAYS)) {
hours = hours - 24.0
days = days + 1.0
}
if days >= 31.0 && !(TimeUnit::contains(suppress_set, TimeUnit::MONTHS)) {
days = days - 31.0
months = months + 1.0
}
if months >= 12.0 && !(TimeUnit::contains(suppress_set, TimeUnit::YEARS)) {
months = months - 12.0
years = years + 1.0
}
let fmts : Array[(TimeUnit, Double)] = [
(YEARS, years),
(MONTHS, months),
(DAYS, days),
(HOURS, hours),
(MINUTES, minutes),
(SECONDS, secs),
(MILLISECONDS, msecs),
(MICROSECONDS, usecs),
]
let mut texts : Array[String] = []
let order2 = [
TimeUnit::YEARS, TimeUnit::MONTHS, TimeUnit::DAYS, TimeUnit::HOURS, TimeUnit::MINUTES,
TimeUnit::SECONDS, TimeUnit::MILLISECONDS, TimeUnit::MICROSECONDS,
]
let mut oi = 0
while oi < order2.length() {
let unit = order2[oi]
let fmt_value = _lookup(fmts, unit)
if fmt_value > 0.0 || (texts.length() == 0 && TimeUnit::same(unit, min_unit)) {
// Mirror Python: fmt_value forced to 2 when 1 < v < 2, else int(v).
let _fmt_value =
if 1.0 < fmt_value && fmt_value < 2.0 { 2.0 } else { @math.floor(fmt_value) }
let count = @math.floor(_fmt_value).to_int()
let is_min = TimeUnit::same(unit, min_unit)
let text =
if is_min && _is_fractional(fmt_value) {
// format applied to the original (fractional) value; plural form
_format_value(fmt_value, format) + " " + _plural_unit_text(unit, 2)
} else if TimeUnit::same(unit, YEARS) {
// years rendered with intcomma (locale thousands sep) as integer
if _fmt_value == 1.0 {
"1 " + _plural_unit_text(unit, 1)
} else {
intcomma(@math.floor(fmt_value).to_int().to_string()) + " " +
_plural_unit_text(unit, count)
}
} else if _fmt_value == 1.0 {
// singular
"1 " + _plural_unit_text(unit, 1)
} else {
// plural, integer-valued (truncated)
@math.floor(fmt_value).to_int().to_string() + " " +
_plural_unit_text(unit, count)
}
texts = texts + [text]
}
if TimeUnit::same(unit, min_unit) {
break
}
oi = oi + 1
}
if texts.length() == 0 {
if TimeUnit::same(min_unit, TimeUnit::YEARS) {
texts = [_plural_unit_text(TimeUnit::YEARS, 0)]
} else {
texts = ["0 " + _plural_unit_text(min_unit, 0)]
}
}
if texts.length() == 1 {
return texts[0]
}
_join_precise(texts)
}
fn _join_precise(ordered : Array[String]) -> String {
let n = ordered.length()
if n == 0 {
return ""
}
if n == 1 {
return ordered[0]
}
if n == 2 {
return ordered[0] + gettext(" and ") + ordered[1]
}
let mut acc = ordered[0]
let mut i = 1
while i < n - 1 {
acc = acc + gettext(", ") + ordered[i]
i = i + 1
}
acc + gettext(" and ") + ordered[n - 1]
}
fn _lookup(fmts : Array[(TimeUnit, Double)], u : TimeUnit) -> Double {
let mut i = 0
while i < fmts.length() {
let (unit, v) = fmts[i]
if TimeUnit::same(unit, u) {
return v
}
i = i + 1
}
0.0
}
fn _is_fractional(v : Double) -> Bool {
let f = v - @math.floor(v)
f > 0.0
}
fn _quotient_and_remainder(
value : Double,
divisor : Double,
unit : TimeUnit,
minimum_unit : TimeUnit,
suppress : Array[TimeUnit],
format : String
) -> (Double, Double) {
if unit == minimum_unit {
return (_rounding_by_fmt(format, value / divisor), 0.0)
}
if TimeUnit::contains(suppress, unit) {
return (0.0, value)
}
let q = @math.floor(value / divisor)
let r = value - q * divisor
// python-humanize keeps the months remainder as an integer day count
// (1 month = 30.5 days on average, but 31 days must round to a whole month
// rather than 1 month + 0.5 day).
if TimeUnit::same(unit, TimeUnit::months()) {
(q, r.to_int().to_double())
} else {
(q, r)
}
}
fn _round_decimals(value : Double, prec : Int) -> Double {
let mut f = 1.0
let mut k = 0
while k < prec {
f = f * 10.0
k = k + 1
}
@math.round(value * f) / f
}
fn _rounding_by_fmt(format : String, value : Double) -> Double {
// Mimic Python's printf formatting: "%d"/"%i" truncate to an integer,
// fractional formats round to the requested precision.
if _str_contains(format, "d") || _str_contains(format, "i") {
@math.floor(value)
} else {
let prec = _parse_precision(format)
_round_decimals(value, prec)
}
}
fn _parse_precision(format : String) -> Int {
let chars = format.to_array()
let mut i = 0
let mut dot = -1
while i < chars.length() {
if chars[i] == '.' {
dot = i
}
i = i + 1
}
if dot == -1 {
return 2
}
let mut num = ""
let mut j = dot + 1
while j < chars.length() && chars[j] >= '0' && chars[j] <= '9' {
num = num + chars[j].to_string()
j = j + 1
}
if num == "" { 2 } else { _str_to_int(num) }
}
fn _format_value(value : Double, format : String) -> String {
if _str_contains(format, "d") || _str_contains(format, "i") {
@math.floor(value).to_int().to_string()
} else {
let prec = _parse_precision(format)
format_fixed(value, prec)
}
}
fn _str_to_int(s : String) -> Int {
let a = s.to_array()
let mut r = 0
let mut i = 0
while i < a.length() {
if a[i] >= '0' && a[i] <= '9' {
r = r * 10 + (a[i].to_int() - '0'.to_int())
}
i = i + 1
}
r
}
fn _str_contains(s : String, sub : String) -> Bool {
let a = s.to_array()
let b = sub.to_array()
if b.length() == 0 {
return true
}
if b.length() > a.length() {
return false
}
let mut i = 0
while i + b.length() <= a.length() {
let mut k = 0
let mut ok = true
while k < b.length() {
if a[i + k] != b[k] {
ok = false
break
}
k = k + 1
}
if ok {
return true
}
i = i + 1
}
false
}
// `format_fixed` is defined in util.mbt.
// `intcomma` is defined in number.mbt.