Packages

Parse ISO8601 date-times! But you should probably use RFC3339 instead

Current section

Files

Jump to
datetime_iso8601 src datetime_iso8601.gleam
Raw

src/datetime_iso8601.gleam

import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/time/calendar
const nanoseconds_per_second: Int = 1_000_000_000
const byte_colon: Int = 0x3A
const byte_minus: Int = 0x2D
const byte_plus: Int = 0x2B
const byte_dot: Int = 0x2E
const byte_zero: Int = 0x30
const byte_nine: Int = 0x39
const byte_t_lowercase: Int = 0x74
const byte_t_uppercase: Int = 0x54
const byte_w_lowercase: Int = 0x77
const byte_w_uppercase: Int = 0x57
const byte_z_lowercase: Int = 0x7A
const byte_z_uppercase: Int = 0x5A
const byte_space: Int = 0x20
/// An ISO8601 date-time. This format is very complex! You should use the
/// RFC3339 format instead, unless you need the more niche formats such
/// as `2024-W11-5`.
///
/// Complete dates (calendar, ordinal, week) can have an optional time component.
/// Reduced precision dates (year-only, month-only, week-only) cannot have times.
///
pub type DateTime {
/// A calendar date with optional time component.
///
/// ## Examples
///
/// ```
/// 2024-03-15
/// 20240315
/// 2024-03-15T14:30:00Z
/// 2024-03-15T14:30:00+05:30
/// ```
///
CalendarDate(year: Int, month: calendar.Month, day: Int, time: Option(Time))
/// A calendar date with month precision (no day).
///
/// ## Examples
///
/// ```
/// 2024-03
/// ```
///
CalendarMonthDate(year: Int, month: calendar.Month)
/// A calendar date with year precision only.
///
/// ## Examples
///
/// ```
/// 2024
/// ```
///
CalendarYearDate(year: Int)
/// An ordinal date (year and day of year) with optional time component.
///
/// ## Examples
///
/// ```
/// 2024-075
/// 2024075
/// 2024-075T14:30:00Z
/// ```
///
OrdinalDate(year: Int, day_of_year: Int, time: Option(Time))
/// A week date (year, week, and day of week) with optional time component.
///
/// ## Examples
///
/// ```
/// 2024-W11-5
/// 2024W115
/// 2024-W11-5T14:30:00Z
/// ```
///
WeekDate(year: Int, week: Int, day_of_week: DayOfWeek, time: Option(Time))
/// A week date with week precision (no day of week).
///
/// ## Examples
///
/// ```
/// 2024-W11
/// 2024W11
/// ```
///
YearWeek(year: Int, week: Int)
}
/// Time of day with varying precision
/// Nanosecond represents the fractional part of the smallest unit
/// Timezone is None for naive times
pub type Time {
/// A time of day with fractional second precision.
///
/// ## Examples
///
/// ```
/// 14:30:00
/// 143000
/// 14:30:00Z
/// 14:30:00.123
/// 14:30:00+05:30
/// ```
///
TimeSecond(
hour: Int,
minute: Int,
second: Int,
nanosecond: Option(Int),
timezone: Option(Timezone),
)
/// A time of day with fractional minute precision.
///
/// ## Examples
///
/// ```
/// 14:30
/// 1430
/// 14:30Z
/// 14:30.5
/// 14:30+05:30
/// ```
///
TimeMinute(
hour: Int,
minute: Int,
nanosecond: Option(Int),
timezone: Option(Timezone),
)
/// A time of day with fractional hour precision.
///
/// ## Examples
///
/// ```
/// 14
/// 14Z
/// 14.5
/// 14+05:30
/// ```
///
TimeHour(hour: Int, nanosecond: Option(Int), timezone: Option(Timezone))
}
/// A day of the week, where Monday is 1 and Sunday is 7.
pub type DayOfWeek {
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday
}
pub type Timezone {
/// UTC timezone, represented as "Z".
///
Utc
/// An offset from UTC in hours and minutes.
///
/// ## Examples
///
/// ```
/// +00:00
/// +05:30
/// -08:00
/// ```
///
Offset(hours: Int, minutes: Int)
}
/// Parse an ISO8601 formatted date-time.
///
/// The ISO8601 date-time format is complex. Unless you have particular need
/// for things like `2024-W11` you are better off using the RFC3339 format,
/// which the `gleam_time` package supports.
///
pub fn parse(input: String) -> Result(DateTime, Nil) {
use #(year, bytes) <- result.try(parse_digits(from: <<input:utf8>>, count: 4))
parse_after_year(bytes, year)
}
fn parse_after_year(bytes: BitArray, year: Int) -> Result(DateTime, Nil) {
case bytes {
<<>> -> Ok(CalendarYearDate(year))
<<byte, rest:bytes>> if byte == byte_minus ->
parse_extended_format(rest, year)
<<byte, rest:bytes>>
if byte == byte_w_uppercase || byte == byte_w_lowercase
-> parse_basic_week(rest, year)
_ -> parse_basic_format(bytes, year)
}
}
fn parse_extended_format(bytes: BitArray, year: Int) -> Result(DateTime, Nil) {
case bytes {
<<byte, rest:bytes>>
if byte == byte_w_uppercase || byte == byte_w_lowercase
-> parse_extended_week(rest, year)
_ -> parse_extended_calendar_or_ordinal(bytes, year)
}
}
fn parse_extended_calendar_or_ordinal(
bytes: BitArray,
year: Int,
) -> Result(DateTime, Nil) {
use #(first_two, bytes) <- result.try(parse_digits(from: bytes, count: 2))
case bytes {
<<byte, rest:bytes>> if byte == byte_minus -> {
// YYYY-MM-DD format
let month = first_two
use month <- result.try(calendar.month_from_int(month))
use #(day, bytes) <- result.try(parse_digits(from: rest, count: 2))
use _ <- result.try(validate_day(year, month, day))
use #(time, bytes) <- result.try(parse_optional_time(bytes))
case bytes {
<<>> -> Ok(CalendarDate(year, month, day, time))
_ -> Error(Nil)
}
}
<<byte, _:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
// YYYY-DDD ordinal format (we have 2 digits, need 1 more)
use #(third_digit, bytes) <- result.try(parse_digits(
from: bytes,
count: 1,
))
let day_of_year = first_two * 10 + third_digit
use _ <- result.try(validate_day_of_year(year, day_of_year))
use #(time, bytes) <- result.try(parse_optional_time(bytes))
case bytes {
<<>> -> Ok(OrdinalDate(year, day_of_year, time))
_ -> Error(Nil)
}
}
<<>> -> {
// YYYY-MM format (month precision)
let month = first_two
use month <- result.try(calendar.month_from_int(month))
Ok(CalendarMonthDate(year, month))
}
<<byte, _:bytes>>
if byte == byte_t_uppercase
|| byte == byte_t_lowercase
|| byte == byte_space
-> {
Error(Nil)
}
_ -> Error(Nil)
}
}
fn parse_extended_week(bytes: BitArray, year: Int) -> Result(DateTime, Nil) {
use #(week, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_week(year, week))
case bytes {
<<byte, rest:bytes>> if byte == byte_minus -> {
use #(day_num, bytes) <- result.try(parse_digits(from: rest, count: 1))
use day_of_week <- result.try(int_to_day_of_week(day_num))
use #(time, bytes) <- result.try(parse_optional_time(bytes))
case bytes {
<<>> -> Ok(WeekDate(year, week, day_of_week, time))
_ -> Error(Nil)
}
}
<<>> -> Ok(YearWeek(year, week))
_ -> Error(Nil)
}
}
fn parse_basic_week(bytes: BitArray, year: Int) -> Result(DateTime, Nil) {
use #(week, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_week(year, week))
case bytes {
<<byte, rest:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
let day_num = byte - byte_zero
use day_of_week <- result.try(int_to_day_of_week(day_num))
use #(time, bytes) <- result.try(parse_optional_time(rest))
case bytes {
<<>> -> Ok(WeekDate(year, week, day_of_week, time))
_ -> Error(Nil)
}
}
<<>> -> Ok(YearWeek(year, week))
_ -> Error(Nil)
}
}
fn parse_basic_format(bytes: BitArray, year: Int) -> Result(DateTime, Nil) {
// Could be YYYYMMDD (8 total) or YYYYDDD (7 total)
// We already consumed 4 digits for year, so we have 3 or 4 more
use #(digit1, bytes) <- result.try(parse_digits(from: bytes, count: 1))
use #(digit2, bytes) <- result.try(parse_digits(from: bytes, count: 1))
use #(digit3, bytes) <- result.try(parse_digits(from: bytes, count: 1))
case parse_digits(from: bytes, count: 1) {
Ok(#(digit4, bytes)) -> {
// 4 more digits = YYYYMMDD
let month = digit1 * 10 + digit2
use month <- result.try(calendar.month_from_int(month))
let day = digit3 * 10 + digit4
use _ <- result.try(validate_day(year, month, day))
use #(time, bytes) <- result.try(parse_optional_time(bytes))
case bytes {
<<>> -> Ok(CalendarDate(year, month, day, time))
_ -> Error(Nil)
}
}
Error(Nil) -> {
// 3 more digits = YYYYDDD
let day_of_year = digit1 * 100 + digit2 * 10 + digit3
use _ <- result.try(validate_day_of_year(year, day_of_year))
use #(time, bytes) <- result.try(parse_optional_time(bytes))
case bytes {
<<>> -> Ok(OrdinalDate(year, day_of_year, time))
_ -> Error(Nil)
}
}
}
}
fn parse_optional_time(
bytes: BitArray,
) -> Result(#(Option(Time), BitArray), Nil) {
case bytes {
<<byte, rest:bytes>>
if byte == byte_t_uppercase
|| byte == byte_t_lowercase
|| byte == byte_space
-> {
use #(time, bytes) <- result.try(parse_time(rest))
Ok(#(Some(time), bytes))
}
_ -> Ok(#(None, bytes))
}
}
fn parse_time(bytes: BitArray) -> Result(#(Time, BitArray), Nil) {
use #(hour, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_hour(hour))
use #(time, bytes) <- result.try(parse_time_after_hour(bytes, hour))
use _ <- result.try(validate_midnight(time))
Ok(#(time, bytes))
}
fn parse_time_after_hour(
bytes: BitArray,
hour: Int,
) -> Result(#(Time, BitArray), Nil) {
case bytes {
<<byte, rest:bytes>> if byte == byte_colon -> {
// Extended format: HH:MM...
parse_time_extended_after_hour(rest, hour)
}
<<byte, rest:bytes>> if byte == byte_dot -> {
// Fractional hour
use #(nanosecond, bytes) <- result.try(parse_fraction_as_nanoseconds(rest))
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeHour(hour, Some(nanosecond), timezone), bytes))
}
<<byte, _:bytes>>
if byte == byte_z_uppercase
|| byte == byte_z_lowercase
|| byte == byte_plus
|| byte == byte_minus
-> {
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeHour(hour, None, timezone), bytes))
}
<<byte, _:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
// Basic format: HHMM...
parse_time_basic_after_hour(bytes, hour)
}
<<>> -> Ok(#(TimeHour(hour, None, None), bytes))
_ -> Error(Nil)
}
}
fn parse_time_extended_after_hour(
bytes: BitArray,
hour: Int,
) -> Result(#(Time, BitArray), Nil) {
use #(minute, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_minute(minute))
case bytes {
<<byte, rest:bytes>> if byte == byte_colon -> {
// HH:MM:SS
use #(second, bytes) <- result.try(parse_digits(from: rest, count: 2))
use _ <- result.try(validate_second(second))
use #(nanosecond, bytes) <- result.try(
parse_optional_fraction_as_nanoseconds(bytes),
)
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeSecond(hour, minute, second, nanosecond, timezone), bytes))
}
<<byte, rest:bytes>> if byte == byte_dot -> {
// HH:MM.fraction
use #(nanosecond, bytes) <- result.try(parse_fraction_as_nanoseconds(rest))
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeMinute(hour, minute, Some(nanosecond), timezone), bytes))
}
_ -> {
// HH:MM with optional timezone
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeMinute(hour, minute, None, timezone), bytes))
}
}
}
fn parse_time_basic_after_hour(
bytes: BitArray,
hour: Int,
) -> Result(#(Time, BitArray), Nil) {
use #(minute, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_minute(minute))
case bytes {
<<byte, _:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
// HHMMSS
use #(second, bytes) <- result.try(parse_digits(from: bytes, count: 2))
use _ <- result.try(validate_second(second))
use #(nanosecond, bytes) <- result.try(
parse_optional_fraction_as_nanoseconds(bytes),
)
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeSecond(hour, minute, second, nanosecond, timezone), bytes))
}
<<byte, rest:bytes>> if byte == byte_dot -> {
// HHMM.fraction
use #(nanosecond, bytes) <- result.try(parse_fraction_as_nanoseconds(rest))
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeMinute(hour, minute, Some(nanosecond), timezone), bytes))
}
_ -> {
// HHMM with optional timezone
use #(timezone, bytes) <- result.try(parse_optional_timezone(bytes))
Ok(#(TimeMinute(hour, minute, None, timezone), bytes))
}
}
}
fn parse_optional_fraction_as_nanoseconds(
bytes: BitArray,
) -> Result(#(Option(Int), BitArray), Nil) {
case bytes {
<<byte, rest:bytes>> if byte == byte_dot -> {
use #(ns, bytes) <- result.try(parse_fraction_as_nanoseconds(rest))
Ok(#(Some(ns), bytes))
}
_ -> Ok(#(None, bytes))
}
}
fn parse_fraction_as_nanoseconds(
bytes: BitArray,
) -> Result(#(Int, BitArray), Nil) {
case bytes {
<<byte, rest:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
parse_fraction_as_nanoseconds_loop(
<<byte, rest:bits>>,
0,
nanoseconds_per_second,
)
}
_ -> Error(Nil)
}
}
fn parse_fraction_as_nanoseconds_loop(
bytes: BitArray,
acc: Int,
power: Int,
) -> Result(#(Int, BitArray), Nil) {
let power = power / 10
case bytes {
<<byte, rest:bytes>> if byte >= byte_zero && byte <= byte_nine && power < 1 -> {
// Truncate remaining digits beyond nanosecond precision
parse_fraction_as_nanoseconds_loop(rest, acc, power)
}
<<byte, rest:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
let digit = byte - byte_zero
parse_fraction_as_nanoseconds_loop(rest, acc + digit * power, power)
}
_ -> Ok(#(acc, bytes))
}
}
fn parse_optional_timezone(
bytes: BitArray,
) -> Result(#(Option(Timezone), BitArray), Nil) {
case bytes {
<<byte, rest:bytes>>
if byte == byte_z_uppercase || byte == byte_z_lowercase
-> Ok(#(Some(Utc), rest))
<<byte, _:bytes>> if byte == byte_plus || byte == byte_minus ->
parse_timezone_offset(bytes)
_ -> Ok(#(None, bytes))
}
}
fn parse_timezone_offset(
bytes: BitArray,
) -> Result(#(Option(Timezone), BitArray), Nil) {
use #(sign, bytes) <- result.try(parse_sign(bytes))
use #(hours, bytes) <- result.try(parse_digits(from: bytes, count: 2))
case bytes {
<<byte, rest:bytes>> if byte == byte_colon -> {
// Extended: +HH:MM
use #(minutes, bytes) <- result.try(parse_digits(from: rest, count: 2))
let hours = case sign {
"-" -> -hours
_ -> hours
}
Ok(#(Some(Offset(hours, minutes)), bytes))
}
<<byte, _:bytes>> if byte >= byte_zero && byte <= byte_nine -> {
// Basic: +HHMM
use #(minutes, bytes) <- result.try(parse_digits(from: bytes, count: 2))
let hours = case sign {
"-" -> -hours
_ -> hours
}
Ok(#(Some(Offset(hours, minutes)), bytes))
}
_ -> {
// Hour only: +HH
let hours = case sign {
"-" -> -hours
_ -> hours
}
Ok(#(Some(Offset(hours, 0)), bytes))
}
}
}
fn parse_sign(bytes: BitArray) -> Result(#(String, BitArray), Nil) {
case bytes {
<<byte, rest:bytes>> if byte == byte_plus -> Ok(#("+", rest))
<<byte, rest:bytes>> if byte == byte_minus -> Ok(#("-", rest))
_ -> Error(Nil)
}
}
fn parse_digits(
from bytes: BitArray,
count count: Int,
) -> Result(#(Int, BitArray), Nil) {
parse_digits_loop(bytes, count, 0, 0)
}
fn parse_digits_loop(
bytes: BitArray,
count: Int,
acc: Int,
k: Int,
) -> Result(#(Int, BitArray), Nil) {
case bytes {
_ if k >= count -> Ok(#(acc, bytes))
<<byte, rest:bytes>> if byte >= byte_zero && byte <= byte_nine ->
parse_digits_loop(rest, count, acc * 10 + { byte - byte_zero }, k + 1)
_ -> Error(Nil)
}
}
fn int_to_day_of_week(day: Int) -> Result(DayOfWeek, Nil) {
case day {
1 -> Ok(Monday)
2 -> Ok(Tuesday)
3 -> Ok(Wednesday)
4 -> Ok(Thursday)
5 -> Ok(Friday)
6 -> Ok(Saturday)
7 -> Ok(Sunday)
_ -> Error(Nil)
}
}
fn validate_day(year: Int, month: calendar.Month, day: Int) -> Result(Nil, Nil) {
let max_day = case month {
calendar.January
| calendar.March
| calendar.May
| calendar.July
| calendar.August
| calendar.October
| calendar.December -> 31
calendar.April | calendar.June | calendar.September | calendar.November ->
30
calendar.February ->
case is_leap_year(year) {
True -> 29
False -> 28
}
}
case day >= 1 && day <= max_day {
True -> Ok(Nil)
False -> Error(Nil)
}
}
fn validate_day_of_year(year: Int, day: Int) -> Result(Nil, Nil) {
let max_day = case is_leap_year(year) {
True -> 366
False -> 365
}
case day >= 1 && day <= max_day {
True -> Ok(Nil)
False -> Error(Nil)
}
}
fn validate_week(year: Int, week: Int) -> Result(Nil, Nil) {
let max_week = weeks_in_year(year)
case week >= 1 && week <= max_week {
True -> Ok(Nil)
False -> Error(Nil)
}
}
fn weeks_in_year(year: Int) -> Int {
// A year has 53 weeks if it starts or ends on a Thursday
let y = year - 1
let jan1_weekday = { 1 + y + y / 4 - y / 100 + y / 400 } % 7
let days_in_year = case is_leap_year(year) {
True -> 366
False -> 365
}
let dec31_weekday = { jan1_weekday + days_in_year - 1 } % 7
case jan1_weekday == 4 || dec31_weekday == 4 {
True -> 53
False -> 52
}
}
fn is_leap_year(year: Int) -> Bool {
year % 4 == 0 && { year % 100 != 0 || year % 400 == 0 }
}
fn validate_hour(hour: Int) -> Result(Nil, Nil) {
case hour >= 0 && hour <= 24 {
True -> Ok(Nil)
False -> Error(Nil)
}
}
fn validate_midnight(time: Time) -> Result(Nil, Nil) {
case time {
TimeSecond(24, minute, second, nanosecond, _) ->
case minute == 0 && second == 0 && nanosecond == None {
True -> Ok(Nil)
False -> Error(Nil)
}
TimeMinute(24, minute, nanosecond, _) ->
case minute == 0 && nanosecond == None {
True -> Ok(Nil)
False -> Error(Nil)
}
TimeHour(24, nanosecond, _) ->
case nanosecond == None {
True -> Ok(Nil)
False -> Error(Nil)
}
_ -> Ok(Nil)
}
}
fn validate_minute(minute: Int) -> Result(Nil, Nil) {
case minute >= 0 && minute <= 59 {
True -> Ok(Nil)
False -> Error(Nil)
}
}
fn validate_second(second: Int) -> Result(Nil, Nil) {
case second >= 0 && second <= 59 {
True -> Ok(Nil)
False -> Error(Nil)
}
}