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: <>, count: 4)) parse_after_year(bytes, year) } fn parse_after_year(bytes: BitArray, year: Int) -> Result(DateTime, Nil) { case bytes { <<>> -> Ok(CalendarYearDate(year)) <> if byte == byte_minus -> parse_extended_format(rest, year) <> 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 { <> 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 { <> 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) } } <> 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)) } <> 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 { <> 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 { <> 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 { <> 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 { <> if byte == byte_colon -> { // Extended format: HH:MM... parse_time_extended_after_hour(rest, hour) } <> 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)) } <> 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)) } <> 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 { <> 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)) } <> 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 { <> 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)) } <> 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 { <> 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 { <> if byte >= byte_zero && byte <= byte_nine -> { parse_fraction_as_nanoseconds_loop( <>, 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 { <> if byte >= byte_zero && byte <= byte_nine && power < 1 -> { // Truncate remaining digits beyond nanosecond precision parse_fraction_as_nanoseconds_loop(rest, acc, power) } <> 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 { <> if byte == byte_z_uppercase || byte == byte_z_lowercase -> Ok(#(Some(Utc), rest)) <> 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 { <> 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)) } <> 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 { <> if byte == byte_plus -> Ok(#("+", rest)) <> 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)) <> 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) } }