/// Parses an ISO 8601 datetime string and returns the epoch time in milliseconds.
///
/// This is the default `parse_datetime` implementation used by `parse_timespec`.
/// It can be replaced with a custom parser via the `parse_datetime` parameter.
///
/// Accepted formats include `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SS`,
/// `YYYY-MM-DDTHH:MM:SS.sssZ`, and `YYYY-MM-DDTHH:MM:SS+HH:MM`.
/// The `/` separator is also accepted in place of `-`.
/// Inputs without timezone information are interpreted as Local (system timezone).
///
/// Parameters:
///
/// * `input` : The datetime string to parse.
///
/// Returns `Some(epoch_ms)` on success, or `None` if parsing fails.
///|
pub fn default_parse_datetime(input : String) -> Int64? {
Some(parse_iso8601(input)) catch {
_ => None
}
}
/// Creates a `parse_datetime` function that interprets TZ-less datetimes
/// with the given default timezone offset.
///
/// ```
/// let parse_jst = make_parse_datetime(default_tz_offset=Hour(9))
/// parse_timespec("2026-03-15T12:00:00", parse_datetime=parse_jst)
/// ```
///|
pub fn make_parse_datetime(
default_tz_offset? : TzOffset = Local,
) -> (String) -> Int64? {
fn(input) {
Some(parse_iso8601(input, default_tz_offset~)) catch {
_ => None
}
}
}
/// Parses an ISO 8601 datetime string and returns the epoch time in milliseconds
/// along with the parsed timezone offset (if explicitly present in the input).
///
/// Handles date, time, fractional seconds, and timezone offset components.
/// Also supports time-only inputs like `HH:MM[:SS[.mmm]][TZ]` (assumed 1970-01-01).
/// Raises `ParseError` on malformed input.
///
/// Returns `(epoch_ms, Some(tz))` when the input has an explicit timezone,
/// or `(epoch_ms, None)` when `default_tz_offset` was used as fallback.
///|
fn parse_iso8601_with_tz(
input : String,
default_tz_offset? : TzOffset = Local,
) -> (Int64, TzOffset?) raise ParseError {
let s = input.trim()
if s.length() == 0 {
raise ParseError("empty datetime string")
}
let mut pos = 0
// Check if it starts with a 4-digit year
let is_time_only = !is_four_digits_at_sv(s, 0)
let mut year = 1970
let mut month = 1
let mut day = 1
if is_time_only {
// time-only: parse HH:MM directly (no T separator needed)
// Hours (1-2 digits)
let (h, p4) = parse_int_digits(s, pos, 1, 2)
pos = p4
// ':' is required (basis for time-only detection)
match s.get_char(pos) {
Some(':') => pos += 1
_ =>
raise ParseError(
"expected ':' at position " + pos.to_string() + " in: " + input,
)
}
let (mi, p5) = parse_int_digits(s, pos, 1, 2)
pos = p5
let hour = h
let min = mi
let mut sec = 0
let mut ms = 0
// Seconds (optional)
if pos < s.length() {
match s.get_char(pos) {
Some(':') => {
pos += 1
let (sc, p6) = parse_int_digits(s, pos, 1, 2)
sec = sc
pos = p6
}
_ => ()
}
}
// Milliseconds (optional)
match s.get_char(pos) {
Some('.') => {
pos += 1
let (frac, frac_digits, p7) = parse_frac_digits(s, pos, 3)
ms = frac
let mut scale = frac_digits
while scale < 3 {
ms *= 10
scale += 1
}
pos = p7
}
_ => ()
}
// Timezone offset (optional)
let (has_tz, tz_offset_min, new_pos) = parse_tz_suffix(s, pos)
pos = new_pos
// Check for unexpected trailing characters
if pos < s.length() {
raise ParseError(
"unexpected character at position " + pos.to_string() + " in: " + input,
)
}
let (tz, parsed_tz) = resolve_tz(has_tz, tz_offset_min, default_tz_offset)
let epoch = datetime_to_epoch(
year,
month,
day,
hour,
min,
sec,
ms,
tz_offset=tz,
)
return (epoch, parsed_tz)
}
// Year (4 digits)
let (yr, p1) = parse_int_digits(s, pos, 4, 4)
year = yr
pos = p1
// Month (required -- partial dates are rejected to avoid ambiguity)
let has_date_sep = match s.get_char(pos) {
Some('-') | Some('/') => true
_ => false
}
if !has_date_sep && !is_digit_at(s, pos) {
raise ParseError("incomplete date (year-only): " + input)
}
pos = skip_date_sep(s, pos)
let (m, p2) = parse_int_digits(s, pos, 1, 2)
month = m
pos = p2
// Day (required)
if !(match s.get_char(pos) {
Some('-') | Some('/') => true
_ => is_digit_at(s, pos) && !has_date_sep
}) {
raise ParseError("incomplete date (year-month only): " + input)
}
pos = skip_date_sep(s, pos)
let (d, p3) = parse_int_digits(s, pos, 1, 2)
day = d
pos = p3
// Hours, minutes, seconds (optional)
let mut hour = 0
let mut min = 0
let mut sec = 0
let mut ms = 0
if pos < s.length() {
match s.get_char(pos) {
Some('T') | Some('t') | Some(' ') => {
pos += 1
// Hours (1-2 digits)
let (h, p4) = parse_int_digits(s, pos, 1, 2)
hour = h
pos = p4
// Minutes (optional)
let has_time_sep = match s.get_char(pos) {
Some(':') => true
_ => false
}
if pos < s.length() && (has_time_sep || is_digit_at(s, pos)) {
pos = skip_time_sep(s, pos)
let (m, p5) = parse_int_digits(s, pos, 1, 2)
min = m
pos = p5
// Seconds (optional)
if pos < s.length() &&
(match s.get_char(pos) {
Some(':') => true
_ => is_digit_at(s, pos) && !has_time_sep
}) {
pos = skip_time_sep(s, pos)
let (sc, p6) = parse_int_digits(s, pos, 1, 2)
sec = sc
pos = p6
}
}
// Milliseconds (optional)
match s.get_char(pos) {
Some('.') => {
pos += 1
let (frac, frac_digits, p7) = parse_frac_digits(s, pos, 3)
ms = frac
// Zero-pad if fewer digits than needed
let mut scale = frac_digits
while scale < 3 {
ms *= 10
scale += 1
}
pos = p7
}
_ => ()
}
}
_ => ()
}
}
// Timezone offset (optional)
let (has_tz, tz_offset_min, new_pos) = parse_tz_suffix(s, pos)
pos = new_pos
// Check for unexpected trailing characters
if pos < s.length() {
raise ParseError(
"unexpected character at position " + pos.to_string() + " in: " + input,
)
}
let (tz, parsed_tz) = resolve_tz(has_tz, tz_offset_min, default_tz_offset)
let epoch = datetime_to_epoch(
year,
month,
day,
hour,
min,
sec,
ms,
tz_offset=tz,
)
(epoch, parsed_tz)
}
/// Detects a timezone suffix at the end of a datetime string.
///
/// Recognizes trailing `Z`/`z`, `+HH:MM`/`-HH:MM`, and `+HHMM`/`-HHMM`.
/// Returns `Some(tz)` if an explicit timezone is found, `None` otherwise.
///
/// This is independent of the datetime parser, so it works with any format
/// (ISO 8601, locale-specific, etc.) as long as the TZ suffix follows
/// standard notation.
///|
fn detect_tz_suffix(input : String) -> TzOffset? {
let s : StringView = input
let len = s.length()
if len == 0 {
return None
}
// Pattern 1: trailing Z/z
match s.get_char(len - 1) {
Some('Z') | Some('z') => return Some(Utc)
_ => ()
}
// Pattern 2: +/-HH:MM (6 chars: sign + 2 digits + colon + 2 digits)
if len >= 6 {
let p = len - 6
let is_sign = match s.get_char(p) {
Some('+') | Some('-') => true
_ => false
}
let has_colon = match s.get_char(p + 3) {
Some(':') => true
_ => false
}
if is_sign &&
has_colon &&
is_digit_at(s, p + 1) &&
is_digit_at(s, p + 2) &&
is_digit_at(s, p + 4) &&
is_digit_at(s, p + 5) {
let sign = match s.get_char(p) {
Some('-') => -1
_ => 1
}
let hh = digit_value_at(s, p + 1) * 10 + digit_value_at(s, p + 2)
let mm = digit_value_at(s, p + 4) * 10 + digit_value_at(s, p + 5)
return Some(normalize_tz_offset(sign * (hh * 60 + mm)))
}
}
// Pattern 3: +/-HHMM (5 chars: sign + 4 digits)
if len >= 5 {
let p = len - 5
let is_sign = match s.get_char(p) {
Some('+') | Some('-') => true
_ => false
}
if is_sign &&
is_digit_at(s, p + 1) &&
is_digit_at(s, p + 2) &&
is_digit_at(s, p + 3) &&
is_digit_at(s, p + 4) {
let sign = match s.get_char(p) {
Some('-') => -1
_ => 1
}
let hh = digit_value_at(s, p + 1) * 10 + digit_value_at(s, p + 2)
let mm = digit_value_at(s, p + 3) * 10 + digit_value_at(s, p + 4)
return Some(normalize_tz_offset(sign * (hh * 60 + mm)))
}
}
// Pattern 4: +/-HH or +/-H (2-3 chars: sign + 1-2 digits)
// Safe because partial dates (YYYY, YYYY-MM) are rejected by the parser.
// The sign must be preceded by a digit (time component) to avoid matching
// date separators like the '-' in "2024-01-15".
for offset in [3, 2] {
if len >= offset {
let p = len - offset
match s.get_char(p) {
Some('+') | Some('-') => {
// Check all chars after sign are digits
let mut all_digits = true
for i in (p + 1).. 0 && is_digit_at(s, p - 1)
let mut has_colon_before = false
for i in 0.. {
has_colon_before = true
break
}
_ => ()
}
}
if all_digits && preceded_by_digit && has_colon_before {
let sign = match s.get_char(p) {
Some('-') => -1
_ => 1
}
let hh = if offset == 3 {
digit_value_at(s, p + 1) * 10 + digit_value_at(s, p + 2)
} else {
digit_value_at(s, p + 1)
}
return Some(normalize_tz_offset(sign * hh * 60))
}
}
_ => ()
}
}
}
None
}
/// Returns the numeric value of the ASCII digit at position `pos`.
///|
fn digit_value_at(s : StringView, pos : Int) -> Int {
match s.get_char(pos) {
Some(c) => c.to_int() - '0'.to_int()
None => 0
}
}
/// Parses timezone suffix (Z, +HH:MM, -HH:MM, etc.) from position `pos`.
///
/// Returns `(has_tz, tz_offset_min, new_pos)`.
///|
fn parse_tz_suffix(
s : StringView,
pos : Int,
) -> (Bool, Int, Int) raise ParseError {
let mut p = pos
let mut has_tz = false
let mut tz_offset_min = 0
if p < s.length() {
match s.get_char(p) {
Some('Z') | Some('z') => {
p += 1
tz_offset_min = 0
has_tz = true
}
Some('+') | Some('-') => {
let sign : Int = match s.get_char(p) {
Some('+') => 1
_ => -1
}
p += 1
let (tz_h, p8) = parse_int_digits(s, p, 1, 2)
p = p8
let mut tz_m = 0
if p < s.length() {
match s.get_char(p) {
Some(':') => {
p += 1
let (m, p9) = parse_int_digits(s, p, 1, 2)
tz_m = m
p = p9
}
Some(c) =>
if c.is_ascii_digit() {
let (m, p9) = parse_int_digits(s, p, 2, 2)
tz_m = m
p = p9
}
None => ()
}
}
tz_offset_min = sign * (tz_h * 60 + tz_m)
has_tz = true
}
_ => ()
}
}
(has_tz, tz_offset_min, p)
}
/// Resolves timezone from parse results.
///
/// Returns `(tz_to_use, parsed_tz_option)` where `parsed_tz_option` is `Some`
/// only when the input contained an explicit timezone.
///|
fn resolve_tz(
has_tz : Bool,
tz_offset_min : Int,
default_tz_offset : TzOffset,
) -> (TzOffset, TzOffset?) {
if has_tz {
let parsed_tz = normalize_tz_offset(tz_offset_min)
(parsed_tz, Some(parsed_tz))
} else {
(default_tz_offset, None)
}
}
/// Parses an ISO 8601 datetime string and returns the epoch time in milliseconds.
///
/// Handles date, time, fractional seconds, and timezone offset components.
/// Also supports time-only inputs like `HH:MM[:SS[.mmm]][TZ]` (assumed 1970-01-01).
/// Raises `ParseError` on malformed input.
///|
fn parse_iso8601(
input : String,
default_tz_offset? : TzOffset = Local,
) -> Int64 raise ParseError {
parse_iso8601_with_tz(input, default_tz_offset~).0
}
/// Parses an integer from `min_digits` to `max_digits` decimal digits.
///
/// Returns a tuple of `(value, new_pos)`:
/// - `value` : The parsed integer.
/// - `new_pos` : The position immediately after the consumed digits.
///
/// Raises `ParseError` if fewer than `min_digits` are found.
///|
fn parse_int_digits(
s : StringView,
start : Int,
min_digits : Int,
max_digits : Int,
) -> (Int, Int) raise ParseError {
let mut pos = start
let mut value = 0
let mut count = 0
while count < max_digits && pos < s.length() {
match s.get_char(pos) {
Some(c) =>
if c.is_ascii_digit() {
value = value * 10 + (c.to_int() - '0'.to_int())
pos += 1
count += 1
} else {
break
}
None => break
}
}
if count < min_digits {
raise ParseError(
"expected at least " +
min_digits.to_string() +
" digits at position " +
start.to_string(),
)
}
(value, pos)
}
/// Returns whether the character at position `pos` is an ASCII digit.
///|
fn is_digit_at(s : StringView, pos : Int) -> Bool {
match s.get_char(pos) {
Some(c) => c.is_ascii_digit()
None => false
}
}
/// Parses fractional digits up to `max_digits`, discarding any excess digits.
///
/// Returns a tuple of `(value, digits_read, new_pos)`:
/// - `value` : The numeric value of the digits read (not yet scaled).
/// - `digits_read` : The number of significant digits consumed (up to `max_digits`).
/// - `new_pos` : The position after all digits (including discarded excess).
///|
fn parse_frac_digits(
s : StringView,
start : Int,
max_digits : Int,
) -> (Int, Int, Int) {
let mut pos = start
let mut value = 0
let mut count = 0
while count < max_digits && pos < s.length() {
match s.get_char(pos) {
Some(c) =>
if c.is_ascii_digit() {
value = value * 10 + (c.to_int() - '0'.to_int())
pos += 1
count += 1
} else {
break
}
None => break
}
}
// Skip excess digits beyond max_digits
while pos < s.length() {
match s.get_char(pos) {
Some(c) => if c.is_ascii_digit() { pos += 1 } else { break }
None => break
}
}
(value, count, pos)
}
/// Skips an optional date separator (`-` or `/`) at position `pos`.
///|
fn skip_date_sep(s : StringView, pos : Int) -> Int {
match s.get_char(pos) {
Some('-') | Some('/') => pos + 1
_ => pos
}
}
/// Skips an optional time separator (`:`) at position `pos`.
///|
fn skip_time_sep(s : StringView, pos : Int) -> Int {
match s.get_char(pos) {
Some(':') => pos + 1
_ => pos
}
}
/// Converts date-time components to epoch milliseconds with normalization.
///
/// Uses Go-style mktime normalization: out-of-range values for seconds, minutes,
/// hours, days, and months are carried over to the next higher unit.
/// The timezone offset is subtracted to produce a UTC epoch value.
///|
fn datetime_to_epoch(
year : Int,
month : Int,
day : Int,
hour : Int,
min : Int,
sec : Int,
ms : Int,
tz_offset? : TzOffset = Utc,
) -> Int64 {
// Convert TzOffset to minute-based offset and normalize to UTC
let offset_min = tz_offset_to_minutes(tz_offset)
let mut y = year
let mut mo = month
let mut d = day
let mut h = hour
let mut mi = min - offset_min
let mut s = sec
// 1. Carry/borrow from seconds -> minutes -> hours -> days
mi += floor_div(s, 60)
s = floor_mod(s, 60)
h += floor_div(mi, 60)
mi = floor_mod(mi, 60)
d += floor_div(h, 24)
h = floor_mod(h, 24)
// 2. Normalize month to the 1-12 range
y += floor_div(mo - 1, 12)
mo = floor_mod(mo - 1, 12) + 1
// 3. Normalize day: carry/borrow based on days in month
while d > days_in_month(y, mo) {
d -= days_in_month(y, mo)
mo += 1
if mo > 12 {
mo = 1
y += 1
}
}
while d <= 0 {
mo -= 1
if mo <= 0 {
mo = 12
y -= 1
}
d += days_in_month(y, mo)
}
// Convert to epoch: compute total days from 1970-01-01 (= day 0)
let days = days_from_epoch(y, mo, d)
(
days.to_int64() * 86400L +
h.to_int64() * 3600L +
mi.to_int64() * 60L +
s.to_int64()
) *
1000L +
ms.to_int64()
}
/// Computes the number of days from 1970-01-01 for a given civil date.
/// Uses Howard Hinnant's algorithm.
///|
fn days_from_epoch(year : Int, month : Int, day : Int) -> Int {
let y = if month <= 2 { year - 1 } else { year }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let m = month
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era * 146097 + doe - 719468
}
/// Converts days since 1970-01-01 back to a civil date `(year, month, day)`.
/// Uses Howard Hinnant's algorithm.
///|
fn epoch_days_to_civil(total_days : Int) -> (Int, Int, Int) {
let z = total_days + 719468
let era = (if z >= 0 { z } else { z - 146096 }) / 146097
let doe = z - 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 d = doy - (153 * mp + 2) / 5 + 1
let m = if mp < 10 { mp + 3 } else { mp - 9 }
let year = if m <= 2 { y + 1 } else { y }
(year, m, d)
}
/// Returns the number of days in the given month, accounting for leap years.
///|
fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 => 31
2 => if is_leap_year(year) { 29 } else { 28 }
3 => 31
4 => 30
5 => 31
6 => 30
7 => 31
8 => 31
9 => 30
10 => 31
11 => 30
12 => 31
_ => 30
}
}
/// Returns whether the given year is a leap year.
///|
fn is_leap_year(year : Int) -> Bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
/// Converts epoch milliseconds to an ISO 8601 datetime string.
///
/// Parameters:
///
/// * `epoch_ms` : Milliseconds since 1970-01-01T00:00:00Z.
/// * `tz_offset` : The timezone offset to apply. Defaults to `Utc`.
/// With `Utc`, the output ends in `Z`. With `Hour(9)`, it ends in `+09:00`.
/// With `Local`, the system timezone is queried at runtime.
///
/// Returns a formatted string like `"2025-03-15T00:56:14Z"` or
/// `"2025-03-15T09:56:14+09:00"`. Milliseconds are included only when non-zero
/// (e.g. `"2025-03-15T00:56:14.123Z"`).
///|
pub fn epoch_to_iso8601(
epoch_ms : Int64,
tz_offset? : TzOffset = Utc,
) -> String {
// Resolve Local before retaining for TZ suffix formatting
let resolved_tz = match tz_offset {
Local => local_tz_offset()
_ => tz_offset
}
// Add offset to epoch_ms according to TzOffset, with saturating arithmetic
// to prevent overflow for extreme epoch values near Int64 boundaries.
let offset_min = tz_offset_to_minutes(resolved_tz)
let offset_ms = offset_min.to_int64() * 60L * 1000L
let adjusted = saturating_add(epoch_ms, offset_ms)
// Convert epoch_ms back to year/month/day/hour/min/sec
let total_ms = adjusted
let mut total_seconds = total_ms / 1000L
let mut ms = (total_ms % 1000L).to_int()
// Handle negative values
if ms < 0 {
ms += 1000
total_seconds -= 1L
}
let mut total_days = (total_seconds / 86400L).to_int()
let mut remaining = (total_seconds % 86400L).to_int()
if remaining < 0 {
remaining += 86400
total_days -= 1
}
let hour = remaining / 3600
let min = remaining % 3600 / 60
let sec = remaining % 60
// Compute year/month/day from total_days (Howard Hinnant's algorithm)
let (year, month, day) = epoch_days_to_civil(total_days)
// Build the string
let buf = StringBuilder::new()
buf.write_string(pad4(year))
buf.write_char('-')
buf.write_string(pad2(month))
buf.write_char('-')
buf.write_string(pad2(day))
buf.write_char('T')
buf.write_string(pad2(hour))
buf.write_char(':')
buf.write_string(pad2(min))
buf.write_char(':')
buf.write_string(pad2(sec))
// Append .NNN if milliseconds are non-zero
if ms != 0 {
buf.write_char('.')
buf.write_string(pad3(ms))
}
// TzOffset suffix
match resolved_tz {
Utc => buf.write_char('Z')
Hour(h) =>
if h >= 0 {
buf.write_char('+')
buf.write_string(pad2(h))
buf.write_string(":00")
} else {
buf.write_char('-')
buf.write_string(pad2(-h))
buf.write_string(":00")
}
Min(m) => {
let abs_m = if m >= 0 { m } else { -m }
if m >= 0 {
buf.write_char('+')
} else {
buf.write_char('-')
}
buf.write_string(pad2(abs_m / 60))
buf.write_char(':')
buf.write_string(pad2(abs_m % 60))
}
Local => () // unreachable
}
buf.to_string()
}
/// Zero-pads an integer to 2 digits.
///|
fn pad2(n : Int) -> String {
if n < 10 {
"0" + n.to_string()
} else {
n.to_string()
}
}
/// Zero-pads an integer to 3 digits.
///|
fn pad3(n : Int) -> String {
if n < 10 {
"00" + n.to_string()
} else if n < 100 {
"0" + n.to_string()
} else {
n.to_string()
}
}
/// Zero-pads an integer to 4 digits. Handles negative values for BCE years.
///|
fn pad4(n : Int) -> String {
if n < 0 {
"-" + pad4(-n)
} else if n < 10 {
"000" + n.to_string()
} else if n < 100 {
"00" + n.to_string()
} else if n < 1000 {
"0" + n.to_string()
} else {
n.to_string()
}
}
/// Floor division that rounds toward negative infinity (Python-style).
///|
fn floor_div(a : Int, b : Int) -> Int {
if (a >= 0) == (b > 0) {
a / b
} else {
(a - b + 1) / b
}
}
/// Floor modulo that always returns a non-negative result (Python-style).
///|
fn floor_mod(a : Int, b : Int) -> Int {
a - floor_div(a, b) * b
}
/// Saturating addition for Int64: clamps to Int64.max_value / Int64.min_value
/// on overflow instead of wrapping.
/// Uses pre-addition overflow detection to avoid undefined wrapping behavior.
///|
fn saturating_add(a : Int64, b : Int64) -> Int64 {
if b > 0L && a > @int64.MAX_VALUE - b {
// Would overflow positively
@int64.MAX_VALUE
} else if b < 0L && a < @int64.MIN_VALUE - b {
// Would overflow negatively
@int64.MIN_VALUE
} else {
a + b
}
}