diff --git a/std/src/std/bytes.inko b/std/src/std/bytes.inko new file mode 100644 index 00000000..5296cb11 --- /dev/null +++ b/std/src/std/bytes.inko @@ -0,0 +1,101 @@ +# Internal helper methods for working with bytes and byte streams. +import std.cmp (min) +import std.ptr +import std.string (Bytes) + +let NINE = 57 +let ZERO = 48 + +fn inline digit?(byte: Int) -> Bool { + byte >= ZERO and byte <= NINE +} + +# Parses two base 10 digits into an `Int`. +fn inline two_digits[T: Bytes](input: ref T, start: Int) -> Option[Int] { + if start.wrapping_add(2) > input.size { return Option.None } + + let a = input.byte(start) + let b = input.byte(start.wrapping_add(1)) + + if digit?(a) and digit?(b) { + Option.Some( + a.wrapping_sub(ZERO).wrapping_mul(10).wrapping_add(b.wrapping_sub(ZERO)), + ) + } else { + Option.None + } +} + +# Parses four base 10 digits into an `Int`. +fn inline four_digits[T: Bytes](input: ref T, start: Int) -> Option[Int] { + if start.wrapping_add(4) > input.size { return Option.None } + + let a = input.byte(start) + let b = input.byte(start.wrapping_add(1)) + let c = input.byte(start.wrapping_add(2)) + let d = input.byte(start.wrapping_add(3)) + + if digit?(a) and digit?(b) and digit?(c) and digit?(d) { + Option.Some( + a + .wrapping_sub(ZERO) + .wrapping_mul(10) + .wrapping_add(b.wrapping_sub(ZERO)) + .wrapping_mul(10) + .wrapping_add(c.wrapping_sub(ZERO)) + .wrapping_mul(10) + .wrapping_add(d.wrapping_sub(ZERO)), + ) + } else { + Option.None + } +} + +# Parses up to N base 10 digits into an `Int`. +fn digits[T: Bytes]( + input: ref T, + start: Int, + limit: Int, +) -> Option[(Int, Int)] { + let mut idx = start + let mut num = 0 + let max = min(limit + 1, input.size) + + while idx < max { + let byte = input.byte(idx) + + if digit?(byte).false? { break } + + num = num.wrapping_mul(10).wrapping_add(byte.wrapping_sub(ZERO)) + idx = idx.wrapping_add(1) + } + + let len = idx - start + + if len > 0 { Option.Some((num, len)) } else { Option.None } +} + +fn name_index_at[T: Bytes]( + input: ref T, + start: Int, + names: ref Array[String], +) -> Option[(Int, Int)] { + let in_len = input.size - start + let in_ptr = ptr.add(input.to_pointer, start) + let mut i = 0 + let max = names.size + + while i < max { + let name = names.get(i) + let name_ptr = name.to_pointer + let name_len = name.size + + if ptr.starts_with?(in_ptr, in_len, name_ptr, name_len) { + return Option.Some((i, name_len)) + } + + i += 1 + } + + Option.None +} diff --git a/std/src/std/json.inko b/std/src/std/json.inko index c048c2b8..777068f3 100644 --- a/std/src/std/json.inko +++ b/std/src/std/json.inko @@ -70,6 +70,7 @@ # The implementation provided by this module isn't optimised for maximum # performance or optimal memory usage. Instead this module aims to provide an # implementation that's good enough for most cases. +import std.bytes (digit?) import std.cmp (Equal) import std.fmt (Format as FormatTrait, Formatter) import std.int (Format) @@ -132,10 +133,6 @@ let ESCAPE_TABLE = [ # objects. let DEFAULT_PRETTY_INDENT = 2 -fn digit?(byte: Int) -> Bool { - byte >= ZERO and byte <= NINE -} - fn exponent?(byte: Int) -> Bool { byte == LOWER_E or byte == UPPER_E } diff --git a/std/src/std/locale.inko b/std/src/std/locale.inko new file mode 100644 index 00000000..8fc3152e --- /dev/null +++ b/std/src/std/locale.inko @@ -0,0 +1,99 @@ +import std.string (Bytes) + +# A type describing a locale (e.g. English or Dutch). +trait pub Locale { + # Parses a (case sensitive) abbreviated month name. + # + # The return value is an optional tuple containing the index (in the range + # 0-11) and the size of the name in bytes. + # + # Not all locales use abbreviated names, in which case this method should + # simply parse the input as full names. + # + # The `input` argument is a `String` or `ByteArray` to parse. The `start` + # argument is the offset to start parsing at. + # + # If the input points to a valid month name, the return value is an + # `Option.Some` containing the month number, otherwise an `Option.None` is + # returned. + fn parse_short_month[T: Bytes](input: ref T, start: Int) -> Option[(Int, Int)] + + # Parses a (case sensitive) full month name. + # + # The return value is an optional tuple containing the index (in the range + # 0-11) and the size of the name in bytes. + # + # The `input` argument is a `String` or `ByteArray` to parse. The `start` + # argument is the offset to start parsing at. + # + # If the input points to a valid month name, the return value is an + # `Option.Some` containing the month number, otherwise an `Option.None` is + # returned. + fn parse_full_month[T: Bytes](input: ref T, start: Int) -> Option[(Int, Int)] + + # Parses a (case sensitive) abbreviated name of the day of the week. + # + # The return value is an optional tuple containing the number of the day of + # the week (in the range 1-7) and the size of the name in bytes. + # + # Not all locales use abbreviated names, in which case this method should + # simply parse the input as full names. + # + # The `input` argument is a `String` or `ByteArray` to parse. The `start` + # argument is the offset to start parsing at. + # + # If the input points to a valid day name, the return value is an + # `Option.Some` containing the day number, otherwise an `Option.None` is + # returned. + fn parse_short_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] + + # Parses a (case sensitive) full name of the day of the week. + # + # The return value is an optional tuple containing the number of the day of + # the week (in the range 1-7) and the size of the name in bytes. + # + # The `input` argument is a `String` or `ByteArray` to parse. The `start` + # argument is the offset to start parsing at. + # + # If the input points to a valid day name, the return value is an + # `Option.Some` containing the day number, otherwise an `Option.None` is + # returned. + fn parse_full_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] + + # Returns the abbreviated month name for the given month index in the range + # 0-11. + # + # # Panics + # + # This method should panic if the month is out of bounds. + fn short_month(index: Int) -> String + + # Returns the full month name for the given month index in the range 0-11. + # + # # Panics + # + # This method should panic if the month is out of bounds. + fn full_month(index: Int) -> String + + # Returns the abbreviated name of the day of the week for the given day index + # in the range 0-6. + # + # # Panics + # + # This method should panic if the month is out of bounds. + fn short_day_of_week(index: Int) -> String + + # Returns the full name of the day of the week for the given day index in the + # range 0-6. + # + # # Panics + # + # This method should panic if the month is out of bounds. + fn full_day_of_week(index: Int) -> String +} diff --git a/std/src/std/locale/en.inko b/std/src/std/locale/en.inko new file mode 100644 index 00000000..cfe413a1 --- /dev/null +++ b/std/src/std/locale/en.inko @@ -0,0 +1,163 @@ +# Locale information for English. +import std.bytes (name_index_at) +import std.locale (Locale as LocaleTrait) +import std.ptr +import std.string (Bytes) + +let SHORT_MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +let FULL_MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] + +let SHORT_WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +let FULL_WEEKDAYS = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +] + +fn month_prefix_index[T: Bytes](input: ref T, start: Int) -> Option[Int] { + # For English we can take advantage of the fact that for all months the first + # 3 bytes are unique. This allows us to efficiently reduce the amount of + # months to compare in full to just a single month. + let a = (ptr.add(input.to_pointer, start) as Pointer[UInt16]).0 as Int + let b = ptr.add(input.to_pointer, start + 2).0 as Int << 16 + + # These magic values are the result of + # `(byte 2 << 16) | (byte 1 << 8) | byte 0`, i.e. the first three bytes in + # little endian order. + match b | a { + case 0x6E614A -> Option.Some(0) + case 0x626546 -> Option.Some(1) + case 0x72614D -> Option.Some(2) + case 0x727041 -> Option.Some(3) + case 0x79614D -> Option.Some(4) + case 0x6E754A -> Option.Some(5) + case 0x6C754A -> Option.Some(6) + case 0x677541 -> Option.Some(7) + case 0x706553 -> Option.Some(8) + case 0x74634F -> Option.Some(9) + case 0x766F4E -> Option.Some(10) + case 0x636544 -> Option.Some(11) + case _ -> Option.None + } +} + +# Locale data for English. +# +# This type handles both US and UK English as in its current implementation +# there are no differences between the two. +class pub copy Locale { + # Returns a new `Locale`. + fn pub inline static new -> Locale { + Locale() + } +} + +impl LocaleTrait for Locale { + fn parse_short_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + if input.size - start < 3 { return Option.None } + + match month_prefix_index(input, start) { + case Some(v) -> Option.Some((v, 3)) + case _ -> Option.None + } + } + + fn parse_full_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + let len = input.size - start + + # "May" is the shortest month and consists of 3 bytes, so anything shorter + # is by definition not a name of the month. + if len < 3 { return Option.None } + + let name_idx = try month_prefix_index(input, start) + let mon = FULL_MONTHS.get(name_idx) + let mut inp_idx = start + 3 + let mut mon_idx = 3 + let max = input.size + + if len < mon.size { return Option.None } + + while inp_idx < max { + if input.byte(inp_idx) == mon.opt(mon_idx).or(-1) { + inp_idx += 1 + mon_idx += 1 + } else { + break + } + } + + if mon_idx == mon.size { + Option.Some((name_idx, mon.size)) + } else { + Option.None + } + } + + fn parse_short_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, SHORT_WEEKDAYS) + } + + fn parse_full_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, FULL_WEEKDAYS) + } + + fn short_month(index: Int) -> String { + SHORT_MONTHS.get(index) + } + + fn full_month(index: Int) -> String { + FULL_MONTHS.get(index) + } + + fn short_day_of_week(index: Int) -> String { + SHORT_WEEKDAYS.get(index) + } + + fn full_day_of_week(index: Int) -> String { + FULL_WEEKDAYS.get(index) + } +} diff --git a/std/src/std/locale/ja.inko b/std/src/std/locale/ja.inko new file mode 100644 index 00000000..4e2c1570 --- /dev/null +++ b/std/src/std/locale/ja.inko @@ -0,0 +1,87 @@ +# Locale information for Japanese. +import std.bytes (name_index_at) +import std.locale (Locale as LocaleTrait) +import std.string (Bytes) + +let FULL_MONTHS = [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', +] + +let SHORT_WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'] + +let FULL_WEEKDAYS = [ + '日曜日', + '月曜日', + '火曜日', + '水曜日', + '木曜日', + '金曜日', + '土曜日', +] + +# Locale data for Japanese. +class pub copy Locale { + # Returns a new `Locale`. + fn pub inline static new -> Locale { + Locale() + } +} + +impl LocaleTrait for Locale { + fn parse_short_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + # Japanese doesn't use abbreviations for the names of the months, so we + # treat both abbreviations and the full names the same way. + name_index_at(input, start, FULL_MONTHS) + } + + fn parse_full_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, FULL_MONTHS) + } + + fn parse_short_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, SHORT_WEEKDAYS) + } + + fn parse_full_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, FULL_WEEKDAYS) + } + + fn short_month(index: Int) -> String { + full_month(index) + } + + fn full_month(index: Int) -> String { + FULL_MONTHS.get(index) + } + + fn short_day_of_week(index: Int) -> String { + SHORT_WEEKDAYS.get(index) + } + + fn full_day_of_week(index: Int) -> String { + FULL_WEEKDAYS.get(index) + } +} diff --git a/std/src/std/locale/nl.inko b/std/src/std/locale/nl.inko new file mode 100644 index 00000000..3a6e9ebe --- /dev/null +++ b/std/src/std/locale/nl.inko @@ -0,0 +1,100 @@ +# Locale information for Dutch. +import std.bytes (name_index_at) +import std.locale (Locale as LocaleTrait) +import std.string (Bytes) + +let SHORT_MONTHS = [ + 'jan', + 'feb', + 'mrt', + 'apr', + 'mei', + 'jun', + 'jul', + 'aug', + 'sep', + 'okt', + 'nov', + 'dec', +] + +let FULL_MONTHS = [ + 'januari', + 'februari', + 'maart', + 'april', + 'mei', + 'juni', + 'juli', + 'augustus', + 'september', + 'oktober', + 'november', + 'december', +] + +let SHORT_WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] + +let FULL_WEEKDAYS = [ + 'maandag', + 'dinsdag', + 'woensdag', + 'donderdag', + 'vrijdag', + 'zaterdag', + 'zondag', +] + +# Locale data for Dutch. +class pub copy Locale { + # Returns a new `Locale`. + fn pub inline static new -> Locale { + Locale() + } +} + +impl LocaleTrait for Locale { + fn parse_short_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, SHORT_MONTHS) + } + + fn parse_full_month[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, FULL_MONTHS) + } + + fn parse_short_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, SHORT_WEEKDAYS) + } + + fn parse_full_day_of_week[T: Bytes]( + input: ref T, + start: Int, + ) -> Option[(Int, Int)] { + name_index_at(input, start, FULL_WEEKDAYS) + } + + fn short_month(index: Int) -> String { + SHORT_MONTHS.get(index) + } + + fn full_month(index: Int) -> String { + FULL_MONTHS.get(index) + } + + fn short_day_of_week(index: Int) -> String { + SHORT_WEEKDAYS.get(index) + } + + fn full_day_of_week(index: Int) -> String { + FULL_WEEKDAYS.get(index) + } +} diff --git a/std/src/std/sys/unix/time.inko b/std/src/std/sys/unix/time.inko index 5248bc41..6c18ac70 100644 --- a/std/src/std/sys/unix/time.inko +++ b/std/src/std/sys/unix/time.inko @@ -9,7 +9,7 @@ class copy RawDateTime { let @hour: Int let @minute: Int let @second: Int - let @sub_second: Float + let @nanos: Int let @offset: Int fn inline static from( @@ -23,7 +23,7 @@ class copy RawDateTime { hour: tm.tm_hour as Int, minute: tm.tm_min as Int, second: tm.tm_sec as Int, - sub_second: ts.tv_nsec as Float / 1_000_000_000.0, + nanos: ts.tv_nsec as Int, offset: tm.tm_gmtoff as Int, ) } diff --git a/std/src/std/time.inko b/std/src/std/time.inko index 82f31cba..f13fe609 100644 --- a/std/src/std/time.inko +++ b/std/src/std/time.inko @@ -1,10 +1,14 @@ # Types and methods for dealing with time. +import std.bytes (digits, four_digits, name_index_at, two_digits) import std.clone (Clone) import std.cmp (Compare, Equal, Ordering) import std.float (ToFloat) import std.fmt (Format, Formatter) import std.int (ToInt) +import std.locale (Locale) +import std.locale.en (Locale as English) import std.ops (Add, Multiply, Subtract) +import std.string (Bytes) import std.sys.unix.time (self as sys) if unix let SECS_PER_MIN = 60 @@ -23,6 +27,93 @@ let LEAP_DAYS = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 205, 335] # year. let NORMAL_DAYS = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 204, 334] +let COLON = 58 +let DOT = 46 +let LOWER_A = 97 +let LOWER_B = 98 +let LOWER_D = 100 +let LOWER_F = 102 +let LOWER_M = 109 +let LOWER_Z = 122 +let MINUS = 45 +let NINE = 57 +let PERCENT = 37 +let PLUS = 43 +let UPPER_A = 65 +let UPPER_B = 66 +let UPPER_H = 72 +let UPPER_M = 77 +let UPPER_S = 83 +let UPPER_Y = 89 +let UPPER_Z = 90 +let ZERO = 48 + +let RFC_2822_ZONES = ['GMT', 'UT'] + +fn parse_offset[T: Bytes](input: ref T, start: Int) -> Option[(Int, Int)] { + let mut inp_idx = start + + let pos = match input.opt(inp_idx := inp_idx + 1) { + case Some(PLUS) -> true + case Some(MINUS) -> false + case _ -> return Option.None + } + + let hour = try two_digits(input, inp_idx) + + inp_idx += 2 + + if input.opt(inp_idx).or(-1) == COLON { inp_idx += 1 } + + let min = match input.opt(inp_idx) { + case Some(byte) if byte >= ZERO and byte <= NINE -> { + try two_digits(input, inp_idx) + } + case _ -> return Option.None + } + + inp_idx += 2 + + let secs = (hour * 3600) + (min * 60) + + Option.Some((if pos { secs } else { 0 - secs }, inp_idx)) +} + +fn format_digits(bytes: mut ByteArray, value: Int, amount: Int) { + let alphabet = '0123456789' + let mut int = value.absolute + let mut pushed = 0 + + while int > 0 { + bytes.push(alphabet.byte(int % 10)) + int /= 10 + pushed += 1 + } + + while pushed < amount { + bytes.push(ZERO) + pushed += 1 + } + + if value < 0 { + bytes.push(0x2D) + pushed += 1 + } + + bytes.reverse_at(bytes.size - pushed) +} + +fn format_offset(bytes: mut ByteArray, offset: Int) { + let abs = offset.absolute + let hh = (abs / SECS_PER_HOUR) + let mm = (abs % SECS_PER_HOUR / SECS_PER_MIN) + + bytes.push(if offset >= 0 { PLUS } else { MINUS }) + format_digits(bytes, hh, amount: 2) + bytes.push(COLON) + format_digits(bytes, mm, amount: 2) +} + fn negative_time_error(time: Int) -> Never { panic("Instant can't represent a negative time (${time})") } @@ -385,37 +476,41 @@ impl Format for Date { # A type that represents a point in time (hours, minutes and seconds) without a # specific associated date. class pub copy Time { - # The hour, minute and second packed into a single set of bits. + # The hour, minute, second and sub seconds packed into a single set of bits. # # The layout is as follows (starting with the least significant bits): # # 1. 6 bits for the seconds # 1. 6 bits for the minutes # 1. 5 bits for the hour + # 1. 30 bits for the sub seconds (as nanoseconds). # - # For example, the time 12:45:30 is encoded as follows: + # For example, the time 12:45:30.23 is encoded as follows: # - # 1100 101101 011110 + # 1101101101011000010110000000 1100 101101 011110 let @bits: Int - let @sub_second: Float # Returns a new `Time`, provided the given components are valid. # # The arguments must be values in the following ranges: # - # - `hour`: 0 up to and including 24 + # - `hour`: 0 up to and including 23 # - `minute`: 0 up to and including 59 # - `second`: 0 up to and including 60 + # - `nanosecond`: 0 up to but excluding 1 000 000 000 # # If any of the arguments are out of bounds, an `Option.None` is returned. # + # The `nanosecond` argument specifies the number of nanoseconds past the + # second. + # # # Examples # # ```inko # import std.time (Time) # # let time = Time - # .new(hour: 12, minute: 30, second: 0, sub_second: 0.0) + # .new(hour: 12, minute: 30, second: 0, nanosecond: 0) # .or_panic('the Time is invalid') # # time.hour # => 12 @@ -424,18 +519,19 @@ class pub copy Time { hour: Int, minute: Int, second: Int, - sub_second: Float, + nanosecond: Int, ) -> Option[Time] { if hour >= 0 - and hour <= 24 + and hour <= 23 and minute >= 0 and minute <= 59 and second >= 0 and second <= 60 - and sub_second >= 0.0 + and nanosecond >= 0 + and nanosecond < 1_000_000_000 { - Option.Some(new_unchecked(hour, minute, second, sub_second)) + Option.Some(new_unchecked(hour, minute, second, nanosecond)) } else { Option.None } @@ -445,14 +541,14 @@ class pub copy Time { hour: Int, minute: Int, second: Int, - sub_second: Float, + nanosecond: Int, ) -> Time { - Time(bits: (hour << 12) | (minute << 6) | second, sub_second: sub_second) + Time(bits: (nanosecond << 17) | (hour << 12) | (minute << 6) | second) } # Returns the hour of the day in the range `0` to `23`. fn pub inline hour -> Int { - @bits >> 12 + @bits >> 12 & 0x1F } # Returns the minute of the hour in the range `0` to `59`. @@ -465,15 +561,15 @@ class pub copy Time { @bits & 0x3F } - # Returns the number of sub seconds. - fn pub inline sub_second -> Float { - @sub_second + # Returns the number of nanoseconds after the second. + fn pub inline nanosecond -> Int { + @bits >> 17 } } impl Equal[Time] for Time { fn pub ==(other: Time) -> Bool { - @bits == other.bits and @sub_second == other.sub_second + @bits == other.bits } } @@ -482,8 +578,14 @@ impl Format for Time { let hour = hour.to_string.pad_start(with: '0', chars: 2) let min = minute.to_string.pad_start(with: '0', chars: 2) let secs = second.to_string.pad_start(with: '0', chars: 2) + let sub = nanosecond formatter.write('${hour}:${min}:${secs}') + + if sub > 0 { + formatter.write('.') + (sub / MICROS_PER_SEC).fmt(formatter) + } } } @@ -511,7 +613,7 @@ class pub inline DateTime { # import std.time (Date, DateTime, Time) # # let date = Date.new(year: 1992, month: 8, day: 21).get - # let time = Time.new(hour: 12, minute: 15, second: 30, sub_second: 0.0).get + # let time = Time.new(hour: 12, minute: 15, second: 30, nanosecond: 0).get # # DateTime.new(date, time, utc_offset: 3600) # ``` @@ -538,7 +640,7 @@ class pub inline DateTime { DateTime( date: Date.new_unchecked(raw.year, raw.month, raw.day), - time: Time.new_unchecked(raw.hour, raw.minute, raw.second, raw.sub_second), + time: Time.new_unchecked(raw.hour, raw.minute, raw.second, raw.nanos), utc_offset: raw.offset, ) } @@ -558,7 +660,7 @@ class pub inline DateTime { DateTime( date: Date.new_unchecked(raw.year, raw.month, raw.day), - time: Time.new_unchecked(raw.hour, raw.minute, raw.second, raw.sub_second), + time: Time.new_unchecked(raw.hour, raw.minute, raw.second, raw.nanos), utc_offset: 0, ) } @@ -611,16 +713,315 @@ class pub inline DateTime { let second = day_secs % 60 let minute = (day_secs % SECS_PER_HOUR) / 60 let hour = day_secs / SECS_PER_HOUR + let nsec = (time.fractional * NANOS_PER_SEC).to_int Option.Some( DateTime( date: try Date.new(year, month, day), - time: try Time.new(hour, minute, second, time.fractional), + time: try Time.new(hour, minute, second, nsec), utc_offset: utc_offset, ), ) } + # Parses a `DateTime` according to a format string loosely based on the + # `strftime(2)` format strings. + # + # The input is any type that implements `std.string.Bytes`, such as the + # `String` and `ByteArray` types. + # + # The `locale` argument is a type that implements `std.locale.Locale` and is + # used for locale-aware parsing, such as when parsing the names of the months. + # + # # Format sequences + # + # The `format` argument contains a "format string" that specifies how the + # input is to be parsed. This `String` can contain both regular characters to + # parse as-is, and special format sequences. These sequences start with `%` + # followed by one or more characters and determine how the input is to be + # parsed. The following sequences are supported: + # + # |= + # | Sequence + # | Examples + # | Description + # |- + # | `%Y` + # | -1234, 0001, 1970, 2024 + # | The year, zero-padded to four digits, optionally starting with a `-` to + # signal a year before year zero. + # |- + # | `%m` + # | 01, 12 + # | The month of the year (01-12), zero-padded to two digits. + # |- + # | `%d` + # | 01, 31 + # | The day of the month (01-31), zero-padded to two digits. + # |- + # | `%H` + # | 01, 12, 23 + # | The hour of the day (00-23), zero-padded to two digits. + # |- + # | `%M` + # | 01, 12, 59 + # | The minute of the hour (00-59), zero-padded to two digits. + # |- + # | `%S` + # | 01, 12, 59, 60 + # | The second of the hour (00-60), zero-padded to two digits. A value of 60 + # is supported to account for leap seconds. + # |- + # | `%f` + # | .123 + # | A dot followed by up to three digits representing the number of + # milliseconds past the second. This value is not padded. + # |- + # | `%z` + # | +0200, +02:30, -0200, -02:00, Z + # | The UTC offset in the format `[SIGN][HH]:[MM]`, `[SIGN][HH][MM]` or a + # literal `Z` where `[SIGN]` is either `+` or `-`, `[HH]` the amount of + # hours and `[MM]` the amount of minutes. Both the hours and minutes are + # zero-padded to two digits. + # |- + # | +0200, +02:30, -0200, -02:00, UT, GMT + # | The same as `%z` but supports `UT` and `GMT` instead of `Z` for UTC + # offsets of zero. + # | +02)), + # |- + # | `%b` + # | Jan + # | The locale-aware abbreviated name of the month. For locales that don't use + # abbreviated names, this is an alias for `%B`. + # |- + # | `%B` + # | January + # | The locale-aware full name of the month. + # |- + # | `%a` + # | Mon + # | The abbreviated name of the day of the week. Parsed values don't affect + # the resulting `DateTime`. + # |- + # | `%A` + # | Monday + # | The full name of the day of the week. Parsed values don't affect the + # resulting `DateTime`. + # |- + # | `%%` + # | % + # | A literal `%`. + # + # Certain sequences such as `%a` don't influence/affect the resulting date, as + # it's not possible to derive a meaningful value from them (e.g. what date is + # "Monday, December 2024"?). Instead, these sequences are meant for skipping + # over values in the input stream when those values aren't statically known + # (e.g. the day of the week). + # + # Sequences that match locale-aware data such as `%b` and `%A` are all case + # sensitive. + # + # # Examples + # + # Parsing an ISO 8601 date: + # + # ```inko + # import std.time (DateTime) + # import std.locale.en (Locale) + # + # let dt = DateTime + # .parse( + # input: '2024-11-04 12:45:12 -02:45', + # format: '%Y-%m-%d %H:%M:%S %z', + # locale: Locale.new, + # ) + # .or_panic('the input is invalid') + # + # dt.year # => 2024 + # dt.month # => 11 + # dt.hour # => 12 + # dt.second # => 45 + # ``` + # + # Parsing a date in Japanese: + # + # ```inko + # import std.time (DateTime) + # import std.locale.ja (Locale) + # + # let dt = DateTime + # .parse( + # input: '2023年12月31日', + # format: '%Y年%B%d日', + # locale: Locale.new, + # ) + # .or_panic('the input is invalid') + # + # dt.year # => 2023 + # dt.month # => 12 + # dt.day # => 31 + # ``` + fn pub static parse[T: Bytes, L: Locale]( + input: ref T, + format: String, + locale: ref L, + ) -> Option[DateTime] { + if input.size == 0 or format.size == 0 { return Option.None } + + let mut inp_idx = 0 + let mut fmt_idx = 0 + let mut year = 0 + let mut mon = 1 + let mut day = 1 + let mut hour = 0 + let mut min = 0 + let mut sec = 0 + let mut nsec = 0 + let mut off = 0 + + loop { + match format.opt(fmt_idx) { + case Some(PERCENT) -> { + fmt_idx += 1 + + match format.opt(fmt_idx) { + case Some(PERCENT) if input.opt(inp_idx).or(-1) == PERCENT -> { + fmt_idx += 1 + inp_idx += 1 + } + case Some(PERCENT) -> return Option.None + case Some(what) -> { + fmt_idx += 1 + inp_idx += match what { + case UPPER_Y -> { + let neg = match input.opt(inp_idx) { + case Some(MINUS) -> { + inp_idx += 1 + true + } + case _ -> false + } + + match try four_digits(input, inp_idx) { + case v if neg -> year = v.opposite + case v -> year = v + } + + 4 + } + case LOWER_M -> { + mon = try two_digits(input, inp_idx) + 2 + } + case LOWER_D -> { + day = try two_digits(input, inp_idx) + 2 + } + case UPPER_H -> { + hour = try two_digits(input, inp_idx) + 2 + } + case UPPER_M -> { + min = try two_digits(input, inp_idx) + 2 + } + case UPPER_S -> { + sec = try two_digits(input, inp_idx) + 2 + } + case LOWER_B -> { + let idx_len = try locale.parse_short_month(input, inp_idx) + + mon = idx_len.0 + 1 + idx_len.1 + } + case UPPER_B -> { + let idx_len = try locale.parse_full_month(input, inp_idx) + + mon = idx_len.0 + 1 + idx_len.1 + } + case LOWER_A -> { + match try locale.parse_short_day_of_week(input, inp_idx) { + case (_, len) -> len + } + } + case UPPER_A -> { + match try locale.parse_full_day_of_week(input, inp_idx) { + case (_, len) -> len + } + } + case LOWER_F -> { + match input.opt(inp_idx := inp_idx + 1) { + case Some(DOT) -> {} + case _ -> return Option.None + } + + match try digits(input, start: inp_idx, limit: 3) { + case (num, len) -> { + nsec = num * MICROS_PER_SEC + len + } + } + } + case LOWER_Z -> { + match input.opt(inp_idx) { + case Some(UPPER_Z) -> { + inp_idx += 1 + next + } + case _ -> {} + } + + let off_idx = try parse_offset(input, inp_idx) + + off = off_idx.0 + inp_idx = off_idx.1 + 2 + } + case UPPER_Z -> { + match name_index_at(input, inp_idx, RFC_2822_ZONES) { + case Some((_, len)) -> { + inp_idx += len + next + } + case _ -> {} + } + + let off_idx = try parse_offset(input, inp_idx) + + off = off_idx.0 + inp_idx = off_idx.1 + 2 + } + case _ -> return Option.None + } + } + case _ -> return Option.None + } + } + case Some(f) -> { + match input.opt(inp_idx) { + case Some(i) if f == i -> { + fmt_idx += 1 + inp_idx += 1 + } + case _ -> return Option.None + } + } + case _ -> break + } + } + + Option.Some( + DateTime.new( + date: try Date.new(year, mon, day), + time: try Time.new(hour, min, sec, nsec), + utc_offset: off, + ), + ) + } + # Returns the `Date` component of `self`. fn pub inline date -> Date { @date @@ -683,11 +1084,11 @@ class pub inline DateTime { @time.second } - # Returns the number of sub seconds. + # Returns the number nanoseconds past the second. # # Refer to the documentation of `Time.sub_second` for more details. - fn pub inline sub_second -> Float { - @time.sub_second + fn pub inline nanosecond -> Int { + @time.nanosecond } # Returns the number of days between `self` and the Unix epoch. @@ -724,6 +1125,137 @@ class pub inline DateTime { # Since our input is already valid at this point, this can never fail. DateTime.from_timestamp(time: to_float, utc_offset: 0).get } + + # Formats `self` as a `String` according to a formatting string. + # + # The `how` argument specifies the format sequences to use to format `self`. + # + # The `locale` argument is the locale to use for locale-aware formatting, such + # as the names of the months. + # + # # Format sequences + # + # The supported sequences are the same as those supported by `DateTime.parse`, + # with the following notes: + # + # - `%z` produces `Z` if the UTC offset is zero, otherwise it produces an + # offset in the format `[SIGN][HH]:[MM]`. + # - `%Z` always produces `GMT` if the UTC offset is zero. + # + # If an unsupported sequence is found, it's treated as a literal string. + # + # # Examples + # + # ```inko + # import std.locale.en (Locale) + # import std.time (DateTime) + # + # let en = Locale.new + # let dt = DateTime.new( + # date: Date.new(2024, 12, 21).get, + # time: Time.new(21, 47, 30, 123_000_000).get, + # utc_offset: 3600, + # ) + # + # dt.format('%Y-%m-%d %H:%M:%S%f %z', en) # => '2024-12-21 21:47:30.123 +0100' + # ``` + fn pub format[L: Locale](how: String, locale: ref L) -> String { + let buf = ByteArray.new + let mut i = 0 + let max = how.size + + while i < max { + match how.byte(i := i + 1) { + case PERCENT -> { + match how.opt(i := i + 1) { + case Some(PERCENT) -> buf.push(PERCENT) + case Some(UPPER_Y) -> format_digits(buf, year, amount: 4) + case Some(LOWER_M) -> format_digits(buf, month, amount: 2) + case Some(LOWER_D) -> format_digits(buf, day, amount: 2) + case Some(UPPER_H) -> format_digits(buf, hour, amount: 2) + case Some(UPPER_M) -> format_digits(buf, minute, amount: 2) + case Some(UPPER_S) -> format_digits(buf, second, amount: 2) + case Some(LOWER_B) -> buf.append(locale.short_month(month - 1)) + case Some(UPPER_B) -> buf.append(locale.full_month(month - 1)) + case Some(LOWER_A) -> { + buf.append(locale.short_day_of_week(day_of_week - 1)) + } + case Some(UPPER_A) -> { + buf.append(locale.full_day_of_week(day_of_week - 1)) + } + case Some(LOWER_F) -> { + buf.push(DOT) + format_digits(buf, nanosecond / MICROS_PER_SEC, amount: 1) + } + case Some(LOWER_Z) -> { + if utc_offset == 0 { + buf.push(UPPER_Z) + } else { + format_offset(buf, utc_offset) + } + } + case Some(UPPER_Z) -> { + if utc_offset == 0 { + buf.append('GMT') + } else { + format_offset(buf, utc_offset) + } + } + case Some(byte) -> { + buf.push(PERCENT) + buf.push(byte) + } + case _ -> buf.push(PERCENT) + } + } + case byte -> buf.push(byte) + } + } + + buf.into_string + } + + # Formats `self` as an ISO 8601 date and time `String`. + # + # This method always uses the English locale. + # + # # Examples + # + # ```inko + # import std.time (DateTime) + # + # let dt = DateTime.new( + # date: Date.new(2024, 12, 21).get, + # time: Time.new(21, 47, 30, 123_000_000).get, + # utc_offset: 3600, + # ) + # + # dt.to_iso8601 # => '2024-12-21T21:47:30.123+01:00' + # ``` + fn pub to_iso8601 -> String { + format('%Y-%m-%dT%H:%M:%S%f%z', English.new) + } + + # Formats `self` as an RFC 2822 date and time `String`. + # + # This method always uses the English locale. + # + # # Examples + # + # ```inko + # import std.time (DateTime) + # + # let dt = DateTime.new( + # date: Date.new(2024, 12, 21).get, + # time: Time.new(21, 47, 30, 123_000_000).get, + # utc_offset: 3600, + # ) + # + # dt.to_rfc2822 # => 'Sat, 21 Dec 2024 21:47:30 +01:00' + # ``` + fn pub to_rfc2822 -> String { + format('%a, %d %b %Y %H:%M:%S %Z', English.new) + } } impl Clone[DateTime] for DateTime { @@ -781,7 +1313,7 @@ impl ToFloat for DateTime { # Returns the timestamp since the Unix epoch, the including fractional # seconds. fn pub to_float -> Float { - to_int.to_float + @time.sub_second + to_int.to_float + (nanosecond.to_float / NANOS_PER_SEC) } } diff --git a/std/test/std/locale/test_en.inko b/std/test/std/locale/test_en.inko new file mode 100644 index 00000000..af9c90bb --- /dev/null +++ b/std/test/std/locale/test_en.inko @@ -0,0 +1,111 @@ +import std.locale.en (self as locale) +import std.test (Tests) + +fn pub tests(t: mut Tests) { + t.test('Locale.parse_short_month', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_month('January', start: 0), Option.Some((0, 3))) + t.equal(loc.parse_short_month('foo Jan', start: 4), Option.Some((0, 3))) + t.equal(loc.parse_short_month('', start: 0), Option.None) + t.equal(loc.parse_short_month('J', start: 0), Option.None) + t.equal(loc.parse_short_month('Ja', start: 0), Option.None) + t.equal(loc.parse_short_month('JAN', start: 0), Option.None) + t.equal(loc.parse_short_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_month('Marching', start: 0), Option.Some((2, 5))) + t.equal(loc.parse_full_month('foo January', start: 4), Option.Some((0, 7))) + t.equal(loc.parse_full_month('Jan', start: 0), Option.None) + t.equal(loc.parse_full_month('Janua', start: 0), Option.None) + t.equal(loc.parse_full_month('Januar', start: 0), Option.None) + t.equal(loc.parse_full_month('JANUARY', start: 0), Option.None) + t.equal(loc.parse_full_month('', start: 0), Option.None) + t.equal(loc.parse_full_month('J', start: 0), Option.None) + t.equal(loc.parse_full_month('Ja', start: 0), Option.None) + t.equal(loc.parse_full_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_short_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.parse_full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_full_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.short_month', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_month(idx), name) + }) + }) + + t.test('Locale.full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_month(idx), name) + }) + }) + + t.test('Locale.short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_day_of_week(idx), name) + }) + }) + + t.test('Locale.full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_day_of_week(idx), name) + }) + }) +} diff --git a/std/test/std/locale/test_ja.inko b/std/test/std/locale/test_ja.inko new file mode 100644 index 00000000..da9771d8 --- /dev/null +++ b/std/test/std/locale/test_ja.inko @@ -0,0 +1,106 @@ +import std.locale.ja (self as locale) +import std.test (Tests) + +fn pub tests(t: mut Tests) { + t.test('Locale.parse_short_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_month('foo 1月', start: 4), Option.Some((0, 4))) + t.equal(loc.parse_short_month('', start: 0), Option.None) + t.equal(loc.parse_short_month('1', start: 0), Option.None) + t.equal(loc.parse_short_month('月', start: 0), Option.None) + t.equal(loc.parse_short_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_month('3月foo', start: 0), Option.Some((2, 4))) + t.equal(loc.parse_full_month('foo 1月', start: 4), Option.Some((0, 4))) + t.equal(loc.parse_full_month('', start: 0), Option.None) + t.equal(loc.parse_full_month('1', start: 0), Option.None) + t.equal(loc.parse_full_month('10', start: 0), Option.None) + t.equal(loc.parse_full_month('月', start: 0), Option.None) + t.equal(loc.parse_full_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_short_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.parse_full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_full_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.short_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_month(idx), name) + }) + }) + + t.test('Locale.full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_month(idx), name) + }) + }) + + t.test('Locale.short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_day_of_week(idx), name) + }) + }) + + t.test('Locale.full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_day_of_week(idx), name) + }) + }) +} diff --git a/std/test/std/locale/test_nl.inko b/std/test/std/locale/test_nl.inko new file mode 100644 index 00000000..ef8bec45 --- /dev/null +++ b/std/test/std/locale/test_nl.inko @@ -0,0 +1,111 @@ +import std.locale.nl (self as locale) +import std.test (Tests) + +fn pub tests(t: mut Tests) { + t.test('Locale.parse_short_month', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_month('januari', start: 0), Option.Some((0, 3))) + t.equal(loc.parse_short_month('foo jan', start: 4), Option.Some((0, 3))) + t.equal(loc.parse_short_month('', start: 0), Option.None) + t.equal(loc.parse_short_month('j', start: 0), Option.None) + t.equal(loc.parse_short_month('ja', start: 0), Option.None) + t.equal(loc.parse_short_month('JAN', start: 0), Option.None) + t.equal(loc.parse_short_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_month(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_month('maarten', start: 0), Option.Some((2, 5))) + t.equal(loc.parse_full_month('foo januari', start: 4), Option.Some((0, 7))) + t.equal(loc.parse_full_month('jan', start: 0), Option.None) + t.equal(loc.parse_full_month('janua', start: 0), Option.None) + t.equal(loc.parse_full_month('januar', start: 0), Option.None) + t.equal(loc.parse_full_month('JANUARI', start: 0), Option.None) + t.equal(loc.parse_full_month('', start: 0), Option.None) + t.equal(loc.parse_full_month('j', start: 0), Option.None) + t.equal(loc.parse_full_month('ja', start: 0), Option.None) + t.equal(loc.parse_full_month('This does not match', start: 0), Option.None) + }) + + t.test('Locale.parse_short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_short_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_short_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_short_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.parse_full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal( + loc.parse_full_day_of_week(name, start: 0), + Option.Some((idx, name.size)), + ) + }) + + t.equal(loc.parse_full_day_of_week('', start: 0), Option.None) + t.equal( + loc.parse_full_day_of_week('This does not match', start: 0), + Option.None, + ) + }) + + t.test('Locale.short_month', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_month(idx), name) + }) + }) + + t.test('Locale.full_month', fn (t) { + let loc = locale.Locale.new + + locale.FULL_MONTHS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_month(idx), name) + }) + }) + + t.test('Locale.short_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.SHORT_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.short_day_of_week(idx), name) + }) + }) + + t.test('Locale.full_day_of_week', fn (t) { + let loc = locale.Locale.new + + locale.FULL_WEEKDAYS.iter.each_with_index(fn (idx, name) { + t.equal(loc.full_day_of_week(idx), name) + }) + }) +} diff --git a/std/test/std/test_bytes.inko b/std/test/std/test_bytes.inko new file mode 100644 index 00000000..b51bfbcc --- /dev/null +++ b/std/test/std/test_bytes.inko @@ -0,0 +1,70 @@ +import std.bytes +import std.test (Tests) + +fn pub tests(t: mut Tests) { + t.test('bytes.name_index_at', fn (t) { + let ascii = ['Foo', 'Bar', 'Baz', 'Quix'] + let emoji = ['😀', '🐱', '🎉'] + let ja = ['1月', '7月', '10月'] + + t.equal(bytes.name_index_at('Foo', 0, ascii), Option.Some((0, 3))) + t.equal(bytes.name_index_at('Foobar', 0, ascii), Option.Some((0, 3))) + t.equal(bytes.name_index_at('Bar', 0, ascii), Option.Some((1, 3))) + t.equal(bytes.name_index_at('Baz', 0, ascii), Option.Some((2, 3))) + t.equal(bytes.name_index_at('Quix', 0, ascii), Option.Some((3, 4))) + t.equal(bytes.name_index_at('FooBar', 3, ascii), Option.Some((1, 3))) + t.equal(bytes.name_index_at('', 0, ascii), Option.None) + t.equal(bytes.name_index_at('F', 0, ascii), Option.None) + t.equal(bytes.name_index_at('Fo', 0, ascii), Option.None) + t.equal(bytes.name_index_at('This does not match', 0, ascii), Option.None) + + t.equal(bytes.name_index_at('😀', 0, emoji), Option.Some((0, 4))) + t.equal(bytes.name_index_at('😀😀', 0, emoji), Option.Some((0, 4))) + t.equal(bytes.name_index_at('😀🐱', 4, emoji), Option.Some((1, 4))) + t.equal(bytes.name_index_at('🐱', 0, emoji), Option.Some((1, 4))) + t.equal(bytes.name_index_at('🎉', 0, emoji), Option.Some((2, 4))) + t.equal(bytes.name_index_at('f🎉', 0, emoji), Option.None) + t.equal(bytes.name_index_at('😢', 0, emoji), Option.None) + t.equal(bytes.name_index_at('😢😢😢😢😢', 0, emoji), Option.None) + + t.equal(bytes.name_index_at('1月', 0, ja), Option.Some((0, 4))) + t.equal(bytes.name_index_at('7月', 0, ja), Option.Some((1, 4))) + t.equal(bytes.name_index_at('10月', 0, ja), Option.Some((2, 5))) + }) + + t.test('bytes.digit?', fn (t) { + '1234567890'.bytes.each(fn (byte) { t.true(bytes.digit?(byte)) }) + + 0.until(256).iter.each(fn (byte) { + if byte >= 48 and byte <= 57 { return } + + t.false(bytes.digit?(byte)) + }) + }) + + t.test('bytes.two_digits', fn (t) { + t.equal(bytes.two_digits('', start: 0), Option.None) + t.equal(bytes.two_digits('1', start: 0), Option.None) + t.equal(bytes.two_digits('a', start: 0), Option.None) + t.equal(bytes.two_digits('123', start: 0), Option.Some(12)) + t.equal(bytes.two_digits('123a', start: 0), Option.Some(12)) + }) + + t.test('bytes.four_digits', fn (t) { + t.equal(bytes.four_digits('', start: 0), Option.None) + t.equal(bytes.four_digits('1', start: 0), Option.None) + t.equal(bytes.four_digits('a', start: 0), Option.None) + t.equal(bytes.four_digits('123', start: 0), Option.None) + t.equal(bytes.four_digits('1234', start: 0), Option.Some(1234)) + t.equal(bytes.four_digits('1234a', start: 0), Option.Some(1234)) + }) + + t.test('bytes.digits', fn (t) { + t.equal(bytes.digits('', start: 0, limit: 4), Option.None) + t.equal(bytes.digits('1', start: 0, limit: 4), Option.Some((1, 1))) + t.equal(bytes.digits('a', start: 0, limit: 4), Option.None) + t.equal(bytes.digits('123', start: 0, limit: 4), Option.Some((123, 3))) + t.equal(bytes.digits('1234', start: 0, limit: 4), Option.Some((1234, 4))) + t.equal(bytes.digits('1234a', start: 0, limit: 4), Option.Some((1234, 4))) + }) +} diff --git a/std/test/std/test_time.inko b/std/test/std/test_time.inko index cb144dc5..54a249a6 100644 --- a/std/test/std/test_time.inko +++ b/std/test/std/test_time.inko @@ -1,18 +1,57 @@ import std.cmp (Ordering) import std.fmt (fmt) +import std.locale.en (Locale) import std.process (sleep) -import std.test (Tests) -import std.time (Date, DateTime, Duration, Instant, Time) +import std.test (Failure, Tests) +import std.time ( + Date, DateTime, Duration, Instant, Time, format_digits, parse_offset, +) fn ymd(year: Int, month: Int, day: Int) -> DateTime { DateTime( date: Date.new(year, month, day).get, - time: Time.new(hour: 12, minute: 0, second: 0, sub_second: 0.0).get, + time: Time.new(hour: 12, minute: 0, second: 0, nanosecond: 0).get, utc_offset: 0, ) } fn pub tests(t: mut Tests) { + t.test('time.parse_offset', fn (t) { + t.equal(parse_offset('+0130', start: 0), Option.Some((5400, 5))) + t.equal(parse_offset('+01:30', start: 0), Option.Some((5400, 6))) + t.equal(parse_offset('-0130', start: 0), Option.Some((-5400, 5))) + t.equal(parse_offset('-01:30', start: 0), Option.Some((-5400, 6))) + }) + + t.test('time.format_digits', fn (t) { + let buf = ByteArray.new + + format_digits(buf, 123_456, amount: 6) + t.equal(buf.drain_to_string, '123456') + + format_digits(buf, 1, amount: 4) + t.equal(buf.drain_to_string, '0001') + + format_digits(buf, 12, amount: 4) + t.equal(buf.drain_to_string, '0012') + + format_digits(buf, 123, amount: 4) + t.equal(buf.drain_to_string, '0123') + + format_digits(buf, -123, amount: 4) + t.equal(buf.drain_to_string, '-0123') + + let buf = 'hello '.to_byte_array + + format_digits(buf, 123, amount: 4) + t.equal(buf.drain_to_string, 'hello 0123') + + let buf = 'hello '.to_byte_array + + format_digits(buf, 123, amount: 0) + t.equal(buf.drain_to_string, 'hello 123') + }) + t.test('Duration.from_secs', fn (t) { t.equal(Duration.from_secs(1.2).to_secs, 1.2) t.equal(Duration.from_secs(-1.2).to_secs, -1.2) @@ -200,46 +239,63 @@ fn pub tests(t: mut Tests) { }) t.ok('Time.new with a valid time', fn (t) { - let time = try Time - .new(hour: 12, minute: 15, second: 30, sub_second: 1.2) + let t1 = try Time + .new(hour: 12, minute: 15, second: 30, nanosecond: 100) + .ok_or(nil) + let t2 = try Time + .new(hour: 23, minute: 59, second: 60, nanosecond: 999_999_999) .ok_or(nil) - t.equal(time.hour, 12) - t.equal(time.minute, 15) - t.equal(time.second, 30) - t.equal(time.sub_second, 1.2) + t.equal(t1.hour, 12) + t.equal(t1.minute, 15) + t.equal(t1.second, 30) + t.equal(t1.nanosecond, 100) + + t.equal(t2.hour, 23) + t.equal(t2.minute, 59) + t.equal(t2.second, 60) + t.equal(t2.nanosecond, 999_999_999) Result.Ok(nil) }) t.test('Time.new with an invalid time', fn (t) { - t.true(Time.new(hour: 25, minute: 1, second: 1, sub_second: 0.0).none?) - t.true(Time.new(hour: 1, minute: -1, second: 1, sub_second: 0.0).none?) - t.true(Time.new(hour: 1, minute: 1, second: -1, sub_second: 0.0).none?) - t.true(Time.new(hour: 1, minute: 1, second: 1, sub_second: -1.0).none?) - t.true(Time.new(hour: 1, minute: 60, second: 1, sub_second: 0.0).none?) - t.true(Time.new(hour: 1, minute: 0, second: 61, sub_second: 0.0).none?) + t.true(Time.new(hour: 25, minute: 1, second: 1, nanosecond: 0).none?) + t.true(Time.new(hour: 24, minute: 1, second: 1, nanosecond: 0).none?) + t.true(Time.new(hour: 1, minute: -1, second: 1, nanosecond: 0).none?) + t.true(Time.new(hour: 1, minute: 1, second: -1, nanosecond: 0).none?) + t.true( + Time.new(hour: 1, minute: 1, second: 1, nanosecond: 1_000_000_000).none?, + ) + t.true(Time.new(hour: 1, minute: 60, second: 1, nanosecond: 0).none?) + t.true(Time.new(hour: 1, minute: 0, second: 61, nanosecond: 0).none?) }) t.test('Time.==', fn (t) { t.equal( - Time.new_unchecked(hour: 12, minute: 15, second: 10, sub_second: 0.0), - Time.new_unchecked(hour: 12, minute: 15, second: 10, sub_second: 0.0), + Time.new_unchecked(hour: 12, minute: 15, second: 10, nanosecond: 0), + Time.new_unchecked(hour: 12, minute: 15, second: 10, nanosecond: 0), ) t.not_equal( - Time.new_unchecked(hour: 12, minute: 15, second: 10, sub_second: 0.0), - Time.new_unchecked(hour: 12, minute: 15, second: 10, sub_second: 1.0), + Time.new_unchecked(hour: 12, minute: 15, second: 10, nanosecond: 0), + Time.new_unchecked(hour: 12, minute: 15, second: 10, nanosecond: 10), ) }) t.test('Time.fmt', fn (t) { t.equal( - fmt(Time.new_unchecked(hour: 12, minute: 15, second: 15, sub_second: 0.0)), + fmt(Time.new_unchecked(hour: 12, minute: 15, second: 15, nanosecond: 0)), '12:15:15', ) t.equal( - fmt(Time.new_unchecked(hour: 1, minute: 1, second: 1, sub_second: 0.0)), + fmt(Time.new_unchecked(hour: 1, minute: 1, second: 1, nanosecond: 0)), '01:01:01', ) + t.equal( + fmt( + Time.new_unchecked(hour: 1, minute: 1, second: 1, nanosecond: 123000000), + ), + '01:01:01.123', + ) }) t.test('DateTime.local', fn (t) { @@ -264,7 +320,7 @@ fn pub tests(t: mut Tests) { t.equal(t1.time.hour, 0) t.equal(t1.time.minute, 0) t.equal(t1.time.second, 0) - t.equal(t1.time.sub_second, 0.0) + t.equal(t1.time.nanosecond, 0) t.equal(t1.utc_offset, 0) t.equal(t2.date.year, 1970) @@ -273,7 +329,7 @@ fn pub tests(t: mut Tests) { t.equal(t2.time.hour, 1) t.equal(t2.time.minute, 0) t.equal(t2.time.second, 0) - t.equal(t2.time.sub_second, 0.0) + t.equal(t2.time.nanosecond, 0) t.equal(t2.utc_offset, 3_600) t.equal(t3.date.year, 2022) @@ -282,7 +338,7 @@ fn pub tests(t: mut Tests) { t.equal(t3.time.hour, 20) t.equal(t3.time.minute, 34) t.equal(t3.time.second, 28) - t.true(t3.time.sub_second >= 0.12 and t3.time.sub_second <= 0.123) + t.true(t3.time.nanosecond >= 120000000 and t3.time.nanosecond <= 123000000) t.equal(t3.utc_offset, 7200) t.equal(t4.date.year, 1969) @@ -293,6 +349,199 @@ fn pub tests(t: mut Tests) { t.equal(t4.time.second, 0) }) + t.test('DateTime.parse', fn (t) { + let tests = [ + # %Y + ('2024', '%Y', Option.Some((2024, 1, 1, 0, 0, 0, 0, 0))), + ('9999', '%Y', Option.Some((9999, 1, 1, 0, 0, 0, 0, 0))), + ('-9999', '%Y', Option.Some((-9999, 1, 1, 0, 0, 0, 0, 0))), + ('+9999', '%Y', Option.None), + ('20', '%Y', Option.None), + ('', '%Y', Option.None), + + # %m + ('07', '%m', Option.Some((0, 7, 1, 0, 0, 0, 0, 0))), + ('12', '%m', Option.Some((0, 12, 1, 0, 0, 0, 0, 0))), + ('13', '%m', Option.None), + ('7', '%m', Option.None), + ('', '%m', Option.None), + + # %b + ('Jul', '%b', Option.Some((0, 7, 1, 0, 0, 0, 0, 0))), + ('Dec', '%b', Option.Some((0, 12, 1, 0, 0, 0, 0, 0))), + ('JUL', '%b', Option.None), + ('test', '%b', Option.None), + ('Ju', '%b', Option.None), + ('', '%b', Option.None), + + # %B + ('July', '%b', Option.Some((0, 7, 1, 0, 0, 0, 0, 0))), + ('December', '%b', Option.Some((0, 12, 1, 0, 0, 0, 0, 0))), + ('JULY', '%b', Option.None), + ('Test', '%b', Option.None), + ('Ju', '%b', Option.None), + ('', '%b', Option.None), + + # %d + ('07', '%d', Option.Some((0, 1, 7, 0, 0, 0, 0, 0))), + ('12', '%d', Option.Some((0, 1, 12, 0, 0, 0, 0, 0))), + ('31', '%d', Option.Some((0, 1, 31, 0, 0, 0, 0, 0))), + ('32', '%d', Option.None), + ('7', '%d', Option.None), + ('', '%d', Option.None), + + # %a + ('Mon 12', '%a %m', Option.Some((0, 12, 1, 0, 0, 0, 0, 0))), + ('M 12', '%a %m', Option.None), + ('Mo 12', '%a %m', Option.None), + ('MON 12', '%a %m', Option.None), + ('mon 12', '%a %m', Option.None), + + # %A + ('Monday 12', '%A %m', Option.Some((0, 12, 1, 0, 0, 0, 0, 0))), + ('M 12', '%A %m', Option.None), + ('MO 12', '%A %m', Option.None), + ('MONDAY 12', '%A %m', Option.None), + + # %H + ('00', '%H', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('07', '%H', Option.Some((0, 1, 1, 7, 0, 0, 0, 0))), + ('23', '%H', Option.Some((0, 1, 1, 23, 0, 0, 0, 0))), + ('24', '%H', Option.None), + ('1', '%H', Option.None), + ('', '%H', Option.None), + + # %M + ('00', '%M', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('07', '%M', Option.Some((0, 1, 1, 0, 7, 0, 0, 0))), + ('59', '%M', Option.Some((0, 1, 1, 0, 59, 0, 0, 0))), + ('60', '%M', Option.None), + ('1', '%M', Option.None), + ('', '%M', Option.None), + + # %S + ('00', '%S', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('07', '%S', Option.Some((0, 1, 1, 0, 0, 7, 0, 0))), + ('59', '%S', Option.Some((0, 1, 1, 0, 0, 59, 0, 0))), + ('60', '%S', Option.Some((0, 1, 1, 0, 0, 60, 0, 0))), + ('61', '%S', Option.None), + ('1', '%S', Option.None), + ('', '%S', Option.None), + + # %f + ('.1', '%f', Option.Some((0, 1, 1, 0, 0, 0, 0, 1_000_000))), + ('.123', '%f', Option.Some((0, 1, 1, 0, 0, 0, 0, 123_000_000))), + ('.999', '%f', Option.Some((0, 1, 1, 0, 0, 0, 0, 999_000_000))), + ('.9999', '%f', Option.Some((0, 1, 1, 0, 0, 0, 0, 999_000_000))), + ('.1a', '%f', Option.Some((0, 1, 1, 0, 0, 0, 0, 1_000_000))), + ('.a', '%f', Option.None), + ('.', '%f', Option.None), + ('', '%f', Option.None), + + # %z + ('+0000', '%z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('Z', '%z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('+0030', '%z', Option.Some((0, 1, 1, 0, 0, 0, 1800, 0))), + ('+0100', '%z', Option.Some((0, 1, 1, 0, 0, 0, 3600, 0))), + ('+0130', '%z', Option.Some((0, 1, 1, 0, 0, 0, 5400, 0))), + ('+00:00', '%z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('+00:30', '%z', Option.Some((0, 1, 1, 0, 0, 0, 1800, 0))), + ('+01:00', '%z', Option.Some((0, 1, 1, 0, 0, 0, 3600, 0))), + ('+01:30', '%z', Option.Some((0, 1, 1, 0, 0, 0, 5400, 0))), + ('-0000', '%z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('-0030', '%z', Option.Some((0, 1, 1, 0, 0, 0, -1800, 0))), + ('-0100', '%z', Option.Some((0, 1, 1, 0, 0, 0, -3600, 0))), + ('-0130', '%z', Option.Some((0, 1, 1, 0, 0, 0, -5400, 0))), + ('-00:00', '%z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('-00:30', '%z', Option.Some((0, 1, 1, 0, 0, 0, -1800, 0))), + ('-01:00', '%z', Option.Some((0, 1, 1, 0, 0, 0, -3600, 0))), + ('-01:30', '%z', Option.Some((0, 1, 1, 0, 0, 0, -5400, 0))), + ('+01', '%z', Option.None), + ('-01', '%z', Option.None), + ('+01:', '%z', Option.None), + ('-01:', '%z', Option.None), + ('+01:0', '%z', Option.None), + ('-01:0', '%z', Option.None), + ('+', '%z', Option.None), + ('-', '%z', Option.None), + ('', '%z', Option.None), + + # %Z + ('+0000', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('UT', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('GMT', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('+0030', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 1800, 0))), + ('+0100', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 3600, 0))), + ('+0130', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 5400, 0))), + ('+00:00', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('+00:30', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 1800, 0))), + ('+01:00', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 3600, 0))), + ('+01:30', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 5400, 0))), + ('-0000', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('-0030', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -1800, 0))), + ('-0100', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -3600, 0))), + ('-0130', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -5400, 0))), + ('-00:00', '%Z', Option.Some((0, 1, 1, 0, 0, 0, 0, 0))), + ('-00:30', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -1800, 0))), + ('-01:00', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -3600, 0))), + ('-01:30', '%Z', Option.Some((0, 1, 1, 0, 0, 0, -5400, 0))), + ('+01', '%Z', Option.None), + ('-01', '%Z', Option.None), + ('+01:', '%Z', Option.None), + ('-01:', '%Z', Option.None), + ('+01:0', '%Z', Option.None), + ('-01:0', '%Z', Option.None), + ('+', '%Z', Option.None), + ('-', '%Z', Option.None), + ('', '%Z', Option.None), + ('Z', '%Z', Option.None), + + # %% + ('%2024', '%%%Y', Option.Some((2024, 1, 1, 0, 0, 0, 0, 0))), + + # Combinations + ('2024-12-07', '%Y-%m-%d', Option.Some((2024, 12, 7, 0, 0, 0, 0, 0))), + ('2024😀12😀07', '%Y😀%m😀%d', Option.Some((2024, 12, 7, 0, 0, 0, 0, 0))), + ('2024x12x07', '%Y-%m-%d', Option.None), + ('2024😀12😀07', '%Y-%m-%d', Option.None), + ( + 'December 14, 2024', + '%B %d, %Y', + Option.Some((2024, 12, 14, 0, 0, 0, 0, 0)), + ), + ( + 'Saturday, December 14, 2024', + '%A, %B %d, %Y', + Option.Some((2024, 12, 14, 0, 0, 0, 0, 0)), + ), + ] + + tests.into_iter.each(fn (rule) { + let en = Locale.new + let input = rule.0 + let format = rule.1 + let res = DateTime.parse(input, format, locale: en) + let exp = match rule { + case (_, _, Some((year, mon, day, hh, mm, s, utc, nanos))) -> { + let date = Date.new(year, mon, day).get + let time = Time.new(hh, mm, s, nanos).get + + Option.Some(DateTime.new(date, time, utc)) + } + case _ -> Option.None + } + + if res == exp { return } + + t.failures.push( + Failure.new( + fmt(res), + "'${format}' to parse '${input}' into ${fmt(exp)}", + ), + ) + }) + }) + t.test('DateTime.days_since_unix_epoch', fn (t) { let dt = DateTime.from_timestamp(time: 1661538868, utc_offset: 7200).get @@ -345,11 +594,19 @@ fn pub tests(t: mut Tests) { let t2 = DateTime.from_timestamp(time: 0, utc_offset: 0).get let t3 = DateTime.from_timestamp(time: -3600, utc_offset: 0).get let t4 = DateTime.from_timestamp(time: 1661538868, utc_offset: -7200).get + let t5 = DateTime.new( + date: Date.new(year: 2022, month: 8, day: 26).get, + time: Time + .new(hour: 16, minute: 34, second: 28, nanosecond: 123000000) + .get, + utc_offset: -7200, + ) t.equal(fmt(t1), '2022-08-26 20:34:28 +0200') t.equal(fmt(t2), '1970-01-01 00:00:00 UTC') t.equal(fmt(t3), '1969-12-31 23:00:00 UTC') t.equal(fmt(t4), '2022-08-26 16:34:28 -0200') + t.equal(fmt(t5), '2022-08-26 16:34:28.123 -0200') }) t.test('DateTime.to_int', fn (t) { @@ -416,6 +673,85 @@ fn pub tests(t: mut Tests) { t.equal(t1, t2) }) + t.test('DateTime.format', fn (t) { + let en = Locale.new + let t1 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 3600, + ) + let t2 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 0, + ) + let t3 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 0).get, + utc_offset: -5400, + ) + + t.equal( + t1.format('%b %B %a %A % %% %o %Y-%m-%d %H:%M:%S%f %z', en), + 'Dec December Sat Saturday % % %o 2024-12-21 21:47:30.123 +01:00', + ) + t.equal( + t2.format('%b %B %a %A % %% %o %Y-%m-%d %H:%M:%S%f %z', en), + 'Dec December Sat Saturday % % %o 2024-12-21 21:47:30.123 Z', + ) + t.equal( + t3.format('%b %B %a %A % %% %o %Y-%m-%d %H:%M:%S%f %z', en), + 'Dec December Sat Saturday % % %o 2024-12-21 21:47:30.0 -01:30', + ) + t.equal(t1.format('%Z', en), '+01:00') + t.equal(t2.format('%Z', en), 'GMT') + t.equal(t3.format('%Z', en), '-01:30') + }) + + t.test('DateTime.to_iso8601', fn (t) { + let t1 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 3600, + ) + let t2 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 0, + ) + let t3 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 0).get, + utc_offset: -5400, + ) + + t.equal(t1.to_iso8601, '2024-12-21T21:47:30.123+01:00') + t.equal(t2.to_iso8601, '2024-12-21T21:47:30.123Z') + t.equal(t3.to_iso8601, '2024-12-21T21:47:30.0-01:30') + }) + + t.test('DateTime.to_rfc2822', fn (t) { + let t1 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 3600, + ) + let t2 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 123_000_000).get, + utc_offset: 0, + ) + let t3 = DateTime.new( + date: Date.new(2024, 12, 21).get, + time: Time.new(21, 47, 30, 0).get, + utc_offset: -5400, + ) + + t.equal(t1.to_rfc2822, 'Sat, 21 Dec 2024 21:47:30 +01:00') + t.equal(t2.to_rfc2822, 'Sat, 21 Dec 2024 21:47:30 GMT') + t.equal(t3.to_rfc2822, 'Sat, 21 Dec 2024 21:47:30 -01:30') + }) + t.test('Instant.new', fn (t) { let t1 = Instant.new let t2 = Instant.new