///|
/// Calendar date in the proleptic Gregorian calendar (years -999999999..999999999).
/// Construct validated values with date_of or parse_date.
pub(all) struct Date {
year : Int32
month : Int32
day : Int32
}
///|
/// Time of day with optional UTC offset or named IANA zone.
/// Construct validated values with make_time or parse_time.
pub(all) struct Time {
hour : Int32
minute : Int32
second : Int32
nanos : Int32
zone : Zone
}
///|
/// Unzoned, fixed-offset or named time zone representation.
pub(all) enum Zone {
Unzoned
Fixed(Int32) // offset in seconds east of UTC
Named(String)
}
///|
/// Calendar date and local time with an optional zone.
pub(all) struct DateTime {
date : Date
time : Time
}
///|
/// Days and time duration. `days` plus `nanos` (always less than one day),
/// with a separate sign so that the canonical form is unique.
pub(all) struct DurationDS {
negative : Bool
days : Int64
nanos : Int64
}
///|
/// Years and months duration, canonicalised so 0 <= months < 12.
pub(all) struct DurationYM {
negative : Bool
years : Int32
months : Int32
}
///|
/// Test the Gregorian leap-year rule.
pub fn is_leap_year(year : Int32) -> Bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
///|
/// Return the month length, or zero for an invalid month.
pub fn days_in_month(year : Int32, month : Int32) -> Int32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
/// Convert a Gregorian date to signed days since 1970-01-01.
pub fn days_from_civil(year : Int32, month : Int32, day : Int32) -> Int64 {
let y = year.to_int64() - (if month <= 2 { 1L } else { 0L })
let era = (if y >= 0L { y } else { y - 399L }) / 400L
let yoe = y - era * 400L
let mp = month.to_int64() + (if month > 2 { -3L } else { 9L })
let doy = (153L * mp + 2L) / 5L + day.to_int64() - 1L
let doe = yoe * 365L + yoe / 4L - yoe / 100L + doy
era * 146097L + doe - 719468L
}
///|
/// Convert signed days since 1970-01-01 to a Gregorian date.
pub fn civil_from_days(days : Int64) -> Date raise EngineError {
if days < days_from_civil(-999999999, 1, 1) ||
days > days_from_civil(999999999, 12, 31) {
raise fail("E_DATE_RANGE", "epoch day outside supported years")
}
let z = days + 719468L
let era = (if z >= 0L { z } else { z - 146096L }) / 146097L
let doe = z - era * 146097L
let yoe = (doe - doe / 1460L + doe / 36524L - doe / 146096L) / 365L
let y = yoe + era * 400L
let doy = doe - (365L * yoe + yoe / 4L - yoe / 100L)
let mp = (5L * doy + 2L) / 153L
let d = doy - (153L * mp + 2L) / 5L + 1L
let m = if mp < 10L { mp + 3L } else { mp - 9L }
let year = if m <= 2L { y + 1L } else { y }
if year < -999999999L || year > 999999999L {
raise fail("E_DATE_RANGE", "epoch day outside supported years")
}
{ year: year.to_int(), month: m.to_int(), day: d.to_int(), }
}
///|
/// Return signed days since 1970-01-01.
pub fn Date::ordinal(self : Date) -> Int64 {
days_from_civil(self.year, self.month, self.day)
}
///|
/// Return the ISO weekday, Monday=1 through Sunday=7.
pub fn Date::weekday(self : Date) -> Int32 {
let w = (self.ordinal() + 3L) % 7L
(if w < 0L { w + 7L } else { w }).to_int() + 1
}
///|
/// Return the one-based day within the year.
pub fn Date::day_of_year(self : Date) -> Int32 {
(self.ordinal() - days_from_civil(self.year, 1, 1) + 1L).to_int()
}
///|
fn weeks_in_year(year : Int32) -> Int32 {
let jan1 = Date::{ year, month: 1, day: 1, }
let wd = jan1.weekday()
if wd == 4 || (is_leap_year(year) && wd == 3) {
53
} else {
52
}
}
///|
/// Return the ISO week number, including adjacent-year weeks.
pub fn Date::week_of_year(self : Date) -> Int32 {
let ordinal = self.day_of_year()
let weekday = self.weekday()
let mut week = (ordinal - weekday + 10) / 7
if week < 1 {
week = weeks_in_year(self.year - 1)
} else if week > weeks_in_year(self.year) {
week = 1
}
week
}
///|
/// Format the calendar date as an ISO date.
pub fn Date::text(self : Date) -> String {
let month = (if self.month < 10 { "0" } else { "" }) + self.month.to_string()
let day = (if self.day < 10 { "0" } else { "" }) + self.day.to_string()
let magnitude = if self.year < 0 { -self.year } else { self.year }
let mut year = magnitude.to_string()
while year.length() < 4 {
year = "0" + year
}
let year = if self.year < 0 { "-" + year } else { year }
year + "-" + month + "-" + day
}
///|
/// Validate year, month and day and construct a Gregorian date.
pub fn date_of(
year : Int32,
month : Int32,
day : Int32,
) -> Date raise EngineError {
if year < -999999999 || year > 999999999 {
raise fail("E_DATE_RANGE", "year outside supported range")
}
if month < 1 || month > 12 {
raise fail("E_DATE_INVALID", "month outside 1..12")
}
if day < 1 || day > days_in_month(year, month) {
raise fail("E_DATE_INVALID", "day outside month range")
}
{ year, month, day, }
}
///|
fn parse_uint(text : String) -> Int32 raise EngineError {
if text == "" || text.length() > 9 {
raise fail("E_PARSE", "invalid integer")
}
let mut n = 0
for ch in text.to_array() {
if ch < '0' || ch > '9' {
raise fail("E_PARSE", "invalid integer")
}
n = n * 10 + (ch.to_int() - 48)
}
n
}
///|
fn two_digits(s : String, at : Int32, what : String) -> Int32 raise EngineError {
if at + 2 > s.length() {
raise fail("E_PARSE", "incomplete " + what)
}
let slice = String::from_array(s.to_array()[at:at + 2].to_owned())
for ch in slice.to_array() {
if ch < '0' || ch > '9' {
raise fail("E_PARSE", "invalid " + what)
}
}
parse_uint(slice)
}
///|
/// Parse an ISO date, rejecting invalid calendar components.
pub fn parse_date(text : String) -> Date raise EngineError {
let s = text.trim().to_owned()
let chars = s.to_array()
let len = chars.length()
let mut start = 0
let mut negative = false
if len > 0 && chars[0] == '-' {
negative = true
start = 1
}
if len < 10 || chars[len - 3] != '-' || chars[len - 6] != '-' {
raise fail("E_DATE_INVALID", "invalid date: " + s)
}
if len - 6 - start < 4 {
raise fail("E_DATE_INVALID", "invalid year: " + s)
}
let year = String::from_array(chars[start:len - 6].to_owned())
if year.length() > 4 && year.has_prefix("0") {
raise fail("E_DATE_INVALID", "extended year has leading zero")
}
for ch in year.to_array() {
if ch < '0' || ch > '9' {
raise fail("E_DATE_INVALID", "invalid year: " + s)
}
}
let y = parse_uint(year)
let year = if negative { -y } else { y }
date_of(year, two_digits(s, len - 5, "month"), two_digits(s, len - 2, "day"))
}
///|
fn parse_fraction(
text : String,
start : Int32,
) -> (Int32, Int32) raise EngineError {
let out = StringBuilder()
let mut i = start
let chars = text.to_array()
while i < chars.length() && chars[i] >= '0' && chars[i] <= '9' {
out.write_char(chars[i])
i = i + 1
}
let digits = out.to_string()
if digits.length() > 9 {
raise fail("E_TIME_INVALID", "more than nanosecond precision")
}
let mut nanos = if digits == "" { 0 } else { parse_uint(digits) }
let mut k = digits.length()
while k < 9 {
nanos = nanos * 10
k = k + 1
}
(nanos, i)
}
///|
fn parse_offset(
text : String,
start : Int32,
) -> (Zone, Int32) raise EngineError {
let chars = text.to_array()
if start >= chars.length() {
return (Unzoned, start)
}
if chars[start] == 'Z' {
return (Fixed(0), start + 1)
}
if chars[start] == '@' {
let name = String::from_array(chars[start + 1:].to_owned())
if name == "" {
raise fail("E_TIME_INVALID", "empty time zone")
}
return (Named(name), chars.length())
}
if chars[start] != '+' && chars[start] != '-' {
return (Unzoned, start)
}
let sign = if chars[start] == '-' { -1 } else { 1 }
if start + 6 > chars.length() || chars[start + 3] != ':' {
raise fail("E_TIME_INVALID", "invalid UTC offset")
}
let hours = two_digits(text, start + 1, "offset hour")
let minutes = two_digits(text, start + 4, "offset minute")
if hours > 14 || minutes > 59 || (hours == 14 && minutes != 0) {
raise fail("E_TIME_INVALID", "UTC offset out of range")
}
(Fixed(sign * (hours * 3600 + minutes * 60)), start + 6)
}
///|
/// Parse an ISO time with an optional UTC offset or named zone.
pub fn parse_time(text : String) -> Time raise EngineError {
let s = text.trim().to_owned()
let chars = s.to_array()
if chars.length() < 8 || chars[2] != ':' || chars[5] != ':' {
raise fail("E_TIME_INVALID", "invalid time: " + s)
}
let hour = two_digits(s, 0, "hour")
let minute = two_digits(s, 3, "minute")
let second = two_digits(s, 6, "second")
let mut at = 8
let nanos = if at < chars.length() && chars[at] == '.' {
let (n, next) = parse_fraction(s, at + 1)
if next == at + 1 {
raise fail("E_TIME_INVALID", "empty fraction")
}
at = next
n
} else {
0
}
let (zone, end) = parse_offset(s, at)
if end != chars.length() {
raise fail("E_TIME_INVALID", "trailing characters in time: " + s)
}
if minute > 59 ||
second > 59 ||
hour > 24 ||
(hour == 24 && (minute != 0 || second != 0 || nanos != 0)) {
raise fail("E_TIME_INVALID", "time component out of range")
}
if zone is Named(name) && zone_transitions(name) is None {
raise fail("E_ZONE_UNSUPPORTED", "unknown time zone: " + name)
}
{ hour, minute, second, nanos, zone, }
}
///|
/// Validate and construct a time from integer components.
pub fn make_time(
hour : Int32,
minute : Int32,
second : Int32,
nanos : Int32,
zone : Zone,
) -> Time raise EngineError {
if hour < 0 ||
hour > 24 ||
minute < 0 ||
minute > 59 ||
second < 0 ||
second > 59 ||
nanos < 0 ||
nanos > 999999999 ||
(hour == 24 && (minute != 0 || second != 0 || nanos != 0)) {
raise fail("E_TIME_INVALID", "time component out of range")
}
if zone is Fixed(offset) && (offset < -50400 || offset > 50400) {
raise fail("E_TIME_INVALID", "offset outside 14 hours")
}
{ hour, minute, second, nanos, zone, }
}
///|
/// Parse a UTC offset beginning at `start`.
pub fn parse_offset_text(
text : String,
start : Int32,
) -> (Zone, Int32) raise EngineError {
parse_offset(text, start)
}
///|
/// Format time, fractional seconds and the stored zone.
pub fn Time::text(self : Time) -> String {
let hour = (if self.hour < 10 { "0" } else { "" }) + self.hour.to_string()
let minute = (if self.minute < 10 { "0" } else { "" }) +
self.minute.to_string()
let second = (if self.second < 10 { "0" } else { "" }) +
self.second.to_string()
let mut out = hour + ":" + minute + ":" + second
if self.nanos != 0 {
let mut raw = self.nanos.to_string()
while raw.length() < 9 {
raw = "0" + raw
}
while raw.has_suffix("0") {
raw = raw.strip_suffix("0").unwrap().to_owned()
}
out = out + "." + raw
}
match self.zone {
Unzoned => out
Fixed(0) => out + "Z"
Fixed(offset) => {
let sign = if offset < 0 { "-" } else { "+" }
let a = if offset < 0 { -offset } else { offset }
let h = a / 3600
let m = a % 3600 / 60
out +
sign +
(if h < 10 { "0" } else { "" }) +
h.to_string() +
":" +
(if m < 10 { "0" } else { "" }) +
m.to_string() +
(if a % 60 == 0 {
""
} else {
":" + (if a % 60 < 10 { "0" } else { "" }) + (a % 60).to_string()
})
}
Named(name) => out + "@" + name
}
}
///|
/// Parse an ISO date and time separated by T.
pub fn parse_datetime(text : String) -> DateTime raise EngineError {
let s = text.trim().to_owned()
let idx = s.find("T")
let index = match idx {
Some(i) => i
None => raise fail("E_DATETIME_INVALID", "missing T separator")
}
let date_part = String::from_array(s.to_array()[0:index].to_owned())
let time_part = String::from_array(s.to_array()[index + 1:].to_owned())
datetime_of(parse_date(date_part), parse_time(time_part))
}
///|
/// Format the stored date, time and zone as ISO text.
pub fn DateTime::text(self : DateTime) -> String {
self.date.text() + "T" + self.time.text()
}
///|
/// Nanoseconds since midnight for a valid time, ignoring the zone.
pub fn Time::seconds(self : Time) -> Int64 {
(
self.hour.to_int64() * 3600L +
self.minute.to_int64() * 60L +
self.second.to_int64()
) *
1000000000L +
self.nanos.to_int64()
}
///|
/// Absolute instant as nanoseconds since 1970-01-01T00:00:00Z.
/// Fails when the value has no resolvable offset.
pub fn DateTime::instant(self : DateTime) -> Int64 raise EngineError {
let total = self.epoch_nanos()
if total < -9223372036854775808N || total > 9223372036854775807N {
raise fail(
"E_DATE_RANGE", "instant exceeds Int64 nanoseconds; use epoch_nanos",
)
}
total.to_int64()
}
///|
/// Test whether this date-time carries a fixed or named zone.
pub fn DateTime::has_offset(self : DateTime) -> Bool {
match self.time.zone {
Fixed(_) => true
Named(_) => true
Unzoned => false
}
}
///|
/// Resolve an IANA offset at a complete local time using embedded 2026e data.
/// Gaps fail explicitly; overlaps choose the earlier UTC instant.
pub fn datetime_zone_offset(value : DateTime) -> Int32 raise EngineError {
match value.time.zone {
Unzoned => 0
Fixed(offset) => offset
Named(name) => {
let entries = match zone_transitions(name) {
Some(entries) => entries
None => raise fail("E_ZONE_UNSUPPORTED", "unknown time zone: " + name)
}
if value.date.year < 1970 || value.date.year > 2100 {
raise fail("E_ZONE_RANGE", "IANA data covers 1970 through 2100")
}
let local_seconds = value.date.ordinal() * 86400L +
value.time.seconds() / 1000000000L
let mut chosen : Int32? = None
let mut earliest = 9223372036854775807L
for i, entry in entries {
let until = if i + 1 < entries.length() {
entries[i + 1].0
} else {
4133980800L
}
let utc = local_seconds - entry.1.to_int64()
if utc >= entry.0 && utc < until && utc < earliest {
chosen = Some(entry.1)
earliest = utc
}
}
match chosen {
Some(offset) => offset
None =>
raise fail(
"E_ZONE_GAP", "local time is nonexistent or outside embedded UTC coverage",
)
}
}
}
}
///|
/// Compatibility helper: resolve at local midnight, never guess a whole-day offset.
pub fn named_zone_offset(name : String, date : Date) -> Int32? {
Some(
datetime_zone_offset({
date,
time: { hour: 0, minute: 0, second: 0, nanos: 0, zone: Named(name), },
}),
) catch {
_ => None
}
}
///|
/// Compatibility helper for local midnight. Use datetime_zone_offset for a time.
pub fn zone_offset_seconds(zone : Zone, date : Date) -> Int32 raise EngineError {
datetime_zone_offset({
date,
time: { hour: 0, minute: 0, second: 0, nanos: 0, zone, },
})
}
///|
/// Only date-independent named offsets can resolve a time without a date.
pub fn standard_zone_offset(zone : Zone) -> Int32? {
match zone {
Unzoned => Some(0)
Fixed(offset) => Some(offset)
Named(name) =>
match zone_transitions(name) {
Some(entries) if entries.iter().all(e => e.1 == entries[0].1) =>
Some(entries[0].1)
_ => None
}
}
}
///|
/// Add signed calendar days without changing the calendar representation.
pub fn date_add_days(date : Date, days : Int64) -> Date raise EngineError {
ignore(date_of(date.year, date.month, date.day))
date_from_ordinal(
@bigint.BigInt::from_int64(date.ordinal()) +
@bigint.BigInt::from_int64(days),
)
}
///|
/// Add signed months, clamping the day to the destination month.
pub fn date_add_months(date : Date, months : Int64) -> Date raise EngineError {
ignore(date_of(date.year, date.month, date.day))
let base = date.year.to_int64() * 12L + date.month.to_int64() - 1L
let minimum = -999999999L * 12L
let maximum = 999999999L * 12L + 11L
if months < minimum - base || months > maximum - base {
raise fail("E_DATE_RANGE", "month arithmetic exceeds supported years")
}
let total = base + months
let year = total / 12L - (if total % 12L < 0L { 1L } else { 0L })
if year < -999999999L || year > 999999999L {
raise fail("E_DATE_RANGE", "month arithmetic exceeds supported years")
}
let month = (total - year * 12L + 1L).to_int()
let year = year.to_int()
let day = if date.day > days_in_month(year, month) {
days_in_month(year, month)
} else {
date.day
}
date_of(year, month, day)
}
///|
/// Normalize signed day and nanosecond components.
pub fn DurationDS::canonical(
negative : Bool,
days : Int64,
nanos : Int64,
) -> DurationDS raise EngineError {
let total = @bigint.BigInt::from_int64(days) * 86400000000000N +
@bigint.BigInt::from_int64(nanos)
duration_ds_of_nanos(if negative { -total } else { total })
}
///|
/// Construct a zero days-and-time duration.
pub fn DurationDS::zero() -> DurationDS {
{ negative: false, days: 0L, nanos: 0L, }
}
///|
/// Test whether both duration components are zero.
pub fn DurationDS::is_zero(self : DurationDS) -> Bool {
self.days == 0L && self.nanos == 0L
}
///|
/// Return the exact signed nanosecond count as a BigInt.
pub fn DurationDS::total_nanos(self : DurationDS) -> @bigint.BigInt {
let n = @bigint.BigInt::from_int64(self.days) * 86400000000000N +
@bigint.BigInt::from_int64(self.nanos)
if self.negative {
-n
} else {
n
}
}
///|
/// Reverse the duration sign while preserving canonical zero.
pub fn DurationDS::negate(self : DurationDS) -> DurationDS {
{
negative: if self.is_zero() {
false
} else {
!self.negative
},
days: self.days,
nanos: self.nanos,
}
}
///|
/// Add two exact days-and-time durations.
pub fn DurationDS::add(
a : DurationDS,
b : DurationDS,
) -> DurationDS raise EngineError {
let total = a.total_nanos() + b.total_nanos()
duration_ds_of_nanos(total)
}
///|
/// Subtract two exact days-and-time durations.
pub fn DurationDS::subtract(
a : DurationDS,
b : DurationDS,
) -> DurationDS raise EngineError {
a.add(b.negate())
}
///|
/// Build a canonical duration from a signed nanosecond count, rounding
/// sub-nanosecond input is not possible by construction.
pub fn duration_ds_of_nanos(
total : @bigint.BigInt,
) -> DurationDS raise EngineError {
let negative = total < 0N
let a = if negative { -total } else { total }
let day_nanos = @bigint.BigInt::from_int64(86400000000000L)
let days = a / day_nanos
let nanos = a % day_nanos
if days > 9223372036854775807N {
raise fail("E_DURATION_RANGE", "days duration exceeds Int64 day capacity")
}
{
negative: if a.is_zero() {
false
} else {
negative
},
days: days.to_int64(),
nanos: nanos.to_int64(),
}
}
///|
/// Format a canonical ISO days-and-time duration.
pub fn DurationDS::text(self : DurationDS) -> String {
if self.is_zero() {
return "P0D"
}
let out = StringBuilder()
if self.negative {
out.write_char('-')
}
out.write_char('P')
if self.days > 0L {
out.write_string(self.days.to_string())
out.write_char('D')
}
if self.nanos > 0L {
let total = self.nanos
let hours = total / 3600000000000L
let rem = total % 3600000000000L
let minutes = rem / 60000000000L
let rem = rem % 60000000000L
let seconds = rem / 1000000000L
let nanos = rem % 1000000000L
out.write_char('T')
if hours > 0L {
out.write_string(hours.to_string())
out.write_char('H')
}
if minutes > 0L {
out.write_string(minutes.to_string())
out.write_char('M')
}
if seconds > 0L || nanos > 0L {
let mut sec = seconds.to_string()
if nanos > 0L {
let mut frac = nanos.to_string()
while frac.length() < 9 {
frac = "0" + frac
}
while frac.has_suffix("0") {
frac = frac.strip_suffix("0").unwrap().to_owned()
}
sec = sec + "." + frac
}
out.write_string(sec)
out.write_char('S')
}
}
out.to_string()
}
///|
/// Normalize years and months with a separate sign.
pub fn DurationYM::canonical(
negative : Bool,
years : Int32,
months : Int32,
) -> DurationYM raise EngineError {
let total = @bigint.BigInt::from_int(years) * 12N +
@bigint.BigInt::from_int(months)
duration_ym_of_months(if negative { -total } else { total })
}
///|
/// Construct a zero years-and-months duration.
pub fn DurationYM::zero() -> DurationYM {
{ negative: false, years: 0, months: 0, }
}
///|
/// Test whether the years-and-months duration is zero.
pub fn DurationYM::is_zero(self : DurationYM) -> Bool {
self.years == 0 && self.months == 0
}
///|
/// Return the signed total number of months.
pub fn DurationYM::total_months(self : DurationYM) -> Int64 {
let total = self.years.to_int64() * 12L + self.months.to_int64()
if self.negative {
-total
} else {
total
}
}
///|
/// Format a canonical ISO years-and-months duration.
pub fn DurationYM::text(self : DurationYM) -> String {
if self.is_zero() {
return "P0M"
}
let out = StringBuilder()
if self.negative {
out.write_char('-')
}
out.write_char('P')
if self.years != 0 {
out.write_string(self.years.to_string())
out.write_char('Y')
}
if self.months != 0 {
out.write_string(self.months.to_string())
out.write_char('M')
}
out.to_string()
}
///|
/// Convert a decimal component to an integer in `target_scale` units,
/// rounding half-to-even.
pub fn decimal_to_scaled(
d : Decimal,
target_scale : Int32,
) -> @bigint.BigInt raise EngineError {
if target_scale < -6111 || target_scale > 6176 {
raise fail("E_DECIMAL_RANGE", "target scale outside decimal128 range")
}
if d.scale <= target_scale {
d.coefficient *
@bigint.BigInt::from_int(10).pow(
@bigint.BigInt::from_int(target_scale - d.scale),
)
} else {
let drop = d.scale - target_scale
let divisor = @bigint.BigInt::from_int(10).pow(
@bigint.BigInt::from_int(drop),
)
let a = if d.coefficient < 0N { -d.coefficient } else { d.coefficient }
let q = a / divisor
let r = a % divisor
let twice = r * 2N
let rounded = if twice > divisor {
q + 1N
} else if twice < divisor {
q
} else if (q % 2N).is_zero() {
q
} else {
q + 1N
}
if d.coefficient < 0N {
-rounded
} else {
rounded
}
}
}
///|
/// Parse an ISO 8601 duration. Durations mixing date and time-of-day
/// components with years/months are rejected, as required by FEEL.
pub fn parse_duration(
text : String,
) -> (DurationDS, DurationYM) raise EngineError {
let s = text.trim().to_owned()
let chars = s.to_array()
if chars.length() < 2 {
raise fail("E_DURATION_INVALID", "invalid duration: " + s)
}
let mut i = 0
let mut negative = false
if chars[0] == '-' {
negative = true
i = 1
} else if chars[0] == '+' {
i = 1
}
if i >= chars.length() || chars[i] != 'P' {
raise fail("E_DURATION_INVALID", "invalid duration: " + s)
}
i = i + 1
let mut total_nanos = 0N
let mut total_months = 0N
let mut in_time = false
let mut has_ds = false
let mut has_ym = false
let mut seen = false
while i < chars.length() {
if chars[i] == 'T' {
in_time = true
i = i + 1
if i >= chars.length() {
raise fail("E_DURATION_INVALID", "trailing T in duration")
}
continue
}
let start = i
let digits = StringBuilder()
while i < chars.length() &&
(
(chars[i] >= '0' && chars[i] <= '9') ||
chars[i] == '.' ||
chars[i] == ','
) {
digits.write_char(if chars[i] == ',' { '.' } else { chars[i] })
i = i + 1
}
if i >= chars.length() || i == start {
raise fail("E_DURATION_INVALID", "missing duration component")
}
let unit = chars[i]
i = i + 1
let value = decimal(digits.to_string())
let n = decimal_to_scaled(value, 9)
let integral = match unit {
'Y' | 'M' => decimal_to_scaled(value, 0)
_ => n
}
seen = true
match unit {
'Y' => {
if in_time {
raise fail("E_DURATION_INVALID", "years not allowed after T")
}
has_ym = true
total_months = total_months + integral * 12N
}
'M' =>
if in_time {
has_ds = true
total_nanos = total_nanos + n * 60N
} else {
has_ym = true
total_months = total_months + integral
}
'W' => {
if in_time {
raise fail("E_DURATION_INVALID", "weeks not allowed after T")
}
has_ds = true
total_nanos = total_nanos + n * 604800N
}
'D' => {
if in_time {
raise fail("E_DURATION_INVALID", "days not allowed after T")
}
has_ds = true
total_nanos = total_nanos + n * 86400N
}
'H' => {
if !in_time {
raise fail("E_DURATION_INVALID", "hours require T")
}
has_ds = true
total_nanos = total_nanos + n * 3600N
}
'S' => {
if !in_time {
raise fail("E_DURATION_INVALID", "seconds require T")
}
has_ds = true
total_nanos = total_nanos + n
}
_ =>
raise fail(
"E_DURATION_INVALID",
"unknown duration unit: " + unit.to_string(),
)
}
}
if !seen {
raise fail("E_DURATION_INVALID", "empty duration")
}
if has_ds && has_ym {
raise fail(
"E_DURATION_INVALID", "cannot mix years/months with days/time components",
)
}
if has_ym {
(
DurationDS::zero(),
duration_ym_of_months(if negative { -total_months } else { total_months }),
)
} else {
(
duration_ds_of_nanos(if negative { -total_nanos } else { total_nanos }),
DurationYM::zero(),
)
}
}
///|
/// Convert an exact UTC instant to a local date-time in the embedded IANA range.
pub fn datetime_from_instant(
nanos : @bigint.BigInt,
zone : Zone,
) -> DateTime raise EngineError {
if nanos < 0N || nanos >= 4133980800000000000N {
raise fail("E_ZONE_RANGE", "instant outside embedded IANA coverage")
}
let seconds = (nanos / 1000000000N).to_int64()
let offset = match zone {
Named(name) => {
let entries = match zone_transitions(name) {
Some(entries) => entries
None => raise fail("E_ZONE_UNSUPPORTED", "unknown time zone: " + name)
}
let mut offset = entries[0].1
for entry in entries {
if entry.0 > seconds {
break
}
offset = entry.1
}
offset
}
Fixed(offset) => offset
Unzoned => 0
}
let shifted = seconds + offset.to_int64()
let day = if shifted < 0L {
(shifted - 86399L) / 86400L
} else {
shifted / 86400L
}
let within = shifted - day * 86400L
let date = civil_from_days(day)
if date.year < 1970 || date.year > 2100 {
raise fail("E_ZONE_RANGE", "local date outside embedded IANA coverage")
}
{
date,
time: {
hour: (within / 3600L).to_int(),
minute: (within % 3600L / 60L).to_int(),
second: (within % 60L).to_int(),
nanos: (nanos % 1000000000N).to_int(),
zone,
},
}
}
///|
/// Construct a local date-time and reject nonexistent times in covered IANA years.
pub fn datetime_of(date : Date, time : Time) -> DateTime raise EngineError {
ignore(date_of(date.year, date.month, date.day))
ignore(make_time(time.hour, time.minute, time.second, time.nanos, time.zone))
let value : DateTime = { date, time, }
if time.zone is Named(name) {
if zone_transitions(name) is None {
raise fail("E_ZONE_UNSUPPORTED", "unknown time zone: " + name)
}
if date.year >= 1970 && date.year <= 2100 {
ignore(datetime_zone_offset(value))
}
}
value
}
///|
/// Convert an exact epoch day after checking the supported Gregorian year range.
pub fn date_from_ordinal(days : @bigint.BigInt) -> Date raise EngineError {
if days < @bigint.BigInt::from_int64(days_from_civil(-999999999, 1, 1)) ||
days > @bigint.BigInt::from_int64(days_from_civil(999999999, 12, 31)) {
raise fail("E_DATE_RANGE", "epoch day outside supported years")
}
civil_from_days(days.to_int64())
}
///|
/// Normalize an exact month count, rejecting magnitudes beyond Int32 years.
pub fn duration_ym_of_months(
total : @bigint.BigInt,
) -> DurationYM raise EngineError {
let magnitude = if total < 0N { -total } else { total }
if magnitude > 25769803775N {
raise fail("E_DURATION_RANGE", "years duration exceeds Int32 year capacity")
}
{
negative: total < 0N,
years: (magnitude / 12N).to_int(),
months: (magnitude % 12N).to_int(),
}
}
///|
/// Exact UTC nanoseconds for the full date range; no Int64 narrowing.
pub fn DateTime::epoch_nanos(
self : DateTime,
) -> @bigint.BigInt raise EngineError {
if self.time.zone is Unzoned {
raise fail(
"E_ZONE_UNSUPPORTED", "date and time without offset has no instant",
)
}
ignore(date_of(self.date.year, self.date.month, self.date.day))
ignore(
make_time(
self.time.hour,
self.time.minute,
self.time.second,
self.time.nanos,
self.time.zone,
),
)
@bigint.BigInt::from_int64(self.date.ordinal()) * 86400000000000N +
@bigint.BigInt::from_int64(self.time.seconds()) -
@bigint.BigInt::from_int(datetime_zone_offset(self)) * 1000000000N
}