From 72c15d7699507a5984d7fcc6426d1e38517c407f Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Wed, 13 Mar 2024 19:04:39 +0100 Subject: [PATCH 1/6] Add WiP datetime package and tests. A new package `core:time/datetime` has been added which can represent moments much further in the past and future than `core:time`. It is based on *the* reference work on the subject, Calendrical Calculations Ultimate Edition, Reingold & Dershowitz. More procedures will be added to it in the future, to for example calculate the 3rd Thursday in March to figure out holidays. The package has been tested for more than a year and can handle dates 25 quadrillion years into the past and future with 64-bit day ordinals, or 5 million with 32-bit ones. This also fixes a longstanding bug where converting between YYYY-MM:DD hh:mm:ss and `time.Time` and back could result in a mismatch. RFC 3339 timestamps can now also be parsed using the `core:time` package. --- core/time/datetime/constants.odin | 86 +++++++++ core/time/datetime/datetime.odin | 262 ++++++++++++++++++++++++++++ core/time/datetime/internal.odin | 95 ++++++++++ core/time/datetime/validation.odin | 67 +++++++ core/time/rfc3339.odin | 122 +++++++++++++ core/time/time.odin | 80 +++------ tests/core/Makefile | 6 +- tests/core/build.bat | 5 + tests/core/time/test_core_time.odin | 177 +++++++++++++++++++ 9 files changed, 845 insertions(+), 55 deletions(-) create mode 100644 core/time/datetime/constants.odin create mode 100644 core/time/datetime/datetime.odin create mode 100644 core/time/datetime/internal.odin create mode 100644 core/time/datetime/validation.odin create mode 100644 core/time/rfc3339.odin create mode 100644 tests/core/time/test_core_time.odin diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin new file mode 100644 index 000000000..5b6c2d77c --- /dev/null +++ b/core/time/datetime/constants.odin @@ -0,0 +1,86 @@ +package datetime + +// Ordinal 1 = Midnight Monday, January 1, 1 A.D. (Gregorian) +// | Midnight Monday, January 3, 1 A.D. (Julian) +Ordinal :: int +EPOCH :: Ordinal(1) + +// Minimum and maximum dates and ordinals. Chosen for safe roundtripping. +when size_of(int) == 4 { + MIN_DATE :: Date{year = -5_879_608, month = 1, day = 1} + MAX_DATE :: Date{year = 5_879_608, month = 12, day = 31} + + MIN_ORD :: Ordinal(-2_147_483_090) + MAX_ORD :: Ordinal( 2_147_482_725) +} else { + MIN_DATE :: Date{year = -25_252_734_927_766_552, month = 1, day = 1} + MAX_DATE :: Date{year = 25_252_734_927_766_552, month = 12, day = 31} + + MIN_ORD :: Ordinal(-9_223_372_036_854_775_234) + MAX_ORD :: Ordinal( 9_223_372_036_854_774_869) +} + +Error :: enum { + None, + Invalid_Year, + Invalid_Month, + Invalid_Day, + Invalid_Hour, + Invalid_Minute, + Invalid_Second, + Invalid_Nano, + Invalid_Ordinal, + Invalid_Delta, +} + +Date :: struct { + year: int, + month: int, + day: int, +} + +Time :: struct { + hour: int, + minute: int, + second: int, + nano: int, +} + +DateTime :: struct { + using date: Date, + using time: Time, +} + +Delta :: struct { + days: int, + seconds: int, + nanos: int, +} + +Month :: enum int { + January = 1, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, +} + +Weekday :: enum int { + Sunday = 0, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, +} + +@(private) +MONTH_DAYS :: [?]int{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} \ No newline at end of file diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin new file mode 100644 index 000000000..9998e0a76 --- /dev/null +++ b/core/time/datetime/datetime.odin @@ -0,0 +1,262 @@ +/* + Calendrical conversions using a proleptic Gregorian calendar. + + Implemented using formulas from: Calendrical Calculations Ultimate Edition, Reingold & Dershowitz +*/ +package datetime + +import "base:intrinsics" + +// Procedures that return an Ordinal +date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal, err: Error) { + validate(date) or_return + return unsafe_date_to_ordinal(date), .None +} + +components_to_ordinal :: proc "contextless" (year, month, day: int) -> (ordinal: Ordinal, err: Error) { + return date_to_ordinal(Date{year, month, day}) +} + +// Procedures that return a Date +ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date, err: Error) { + validate(ordinal) or_return + return unsafe_ordinal_to_date(ordinal), .None +} + +components_to_date :: proc "contextless" (year, month, day: int) -> (date: Date, err: Error) { + date = Date{year, month, day} + validate(date) or_return + return date, .None +} + +ordinal_to_datetime :: proc "contextless" (ordinal: Ordinal) -> (datetime: DateTime, err: Error) { + d := ordinal_to_date(ordinal) or_return + return {Date(d), {}}, .None +} + +day_of_week :: proc "contextless" (ordinal: Ordinal) -> (day: Weekday) { + return Weekday((ordinal - EPOCH) %% 7) +} + +subtract_dates :: proc "contextless" (a, b: Date) -> (delta: Delta, err: Error) { + ord_a := date_to_ordinal(a) or_return + ord_b := date_to_ordinal(b) or_return + + delta = Delta{days=ord_a - ord_b} + return +} + +subtract_datetimes :: proc "contextless" (a, b: DateTime) -> (delta: Delta, err: Error) { + ord_a := date_to_ordinal(a) or_return + ord_b := date_to_ordinal(b) or_return + + validate(a.time) or_return + validate(b.time) or_return + + seconds_a := a.hour * 3600 + a.minute * 60 + a.second + seconds_b := b.hour * 3600 + b.minute * 60 + b.second + + delta = Delta{ord_a - ord_b, seconds_a - seconds_b, a.nano - b.nano} + return +} + +subtract_deltas :: proc "contextless" (a, b: Delta) -> (delta: Delta, err: Error) { + delta = Delta{a.days - b.days, a.seconds - b.seconds, a.nanos - b.nanos} + delta = normalize_delta(delta) or_return + return +} +sub :: proc{subtract_datetimes, subtract_dates, subtract_deltas} + +add_days_to_date :: proc "contextless" (a: Date, days: int) -> (date: Date, err: Error) { + ord := date_to_ordinal(a) or_return + ord += days + return ordinal_to_date(ord) +} + +add_delta_to_date :: proc "contextless" (a: Date, delta: Delta) -> (date: Date, err: Error) { + ord := date_to_ordinal(a) or_return + // Because the input is a Date, we add only the days from the Delta. + ord += delta.days + return ordinal_to_date(ord) +} + +add_delta_to_datetime :: proc "contextless" (a: DateTime, delta: Delta) -> (datetime: DateTime, err: Error) { + days := date_to_ordinal(a) or_return + + a_seconds := a.hour * 3600 + a.minute * 60 + a.second + a_delta := Delta{days=days, seconds=a_seconds, nanos=a.nano} + + sum_delta := Delta{days=a_delta.days + delta.days, seconds=a_delta.seconds + delta.seconds, nanos=a_delta.nanos + delta.nanos} + sum_delta = normalize_delta(sum_delta) or_return + + datetime.date = ordinal_to_date(sum_delta.days) or_return + + r: int + datetime.hour, r = divmod(sum_delta.seconds, 3600) + datetime.minute, datetime.second = divmod(r, 60) + datetime.nano = sum_delta.nanos + + return +} +add :: proc{add_days_to_date, add_delta_to_date, add_delta_to_datetime} + +day_number :: proc "contextless" (date: Date) -> (day_number: int, err: Error) { + validate(date) or_return + + ord := unsafe_date_to_ordinal(date) + _, day_number = unsafe_ordinal_to_year(ord) + return +} + +days_remaining :: proc "contextless" (date: Date) -> (days_remaining: int, err: Error) { + // Alternative formulation `day_number` subtracted from 365 or 366 depending on leap year + validate(date) or_return + delta := sub(date, Date{date.year, 12, 31}) or_return + return delta.days, .None +} + +last_day_of_month :: proc "contextless" (year, month: int) -> (day: int, err: Error) { + // Not using formula 2.27 from the book. This is far simpler and gives the same answer. + + validate(Date{year, month, 1}) or_return + month_days := MONTH_DAYS + + day = month_days[month] + if month == 2 && is_leap_year(year) { + day += 1 + } + return +} + +new_year :: proc "contextless" (year: int) -> (new_year: Date, err: Error) { + new_year = {year, 1, 1} + validate(new_year) or_return + return +} + +year_end :: proc "contextless" (year: int) -> (year_end: Date, err: Error) { + year_end = {year, 12, 31} + validate(year_end) or_return + return +} + +year_range :: proc (year: int, allocator := context.allocator) -> (range: []Date) { + is_leap := is_leap_year(year) + + days := 366 if is_leap else 365 + range = make([]Date, days, allocator) + + month_days := MONTH_DAYS + if is_leap { + month_days[2] = 29 + } + + i := 0 + for month in 1..=len(month_days) { + for day in 1..=month_days[month] { + range[i] = Date{year, month, day} + i += 1 + } + } + return +} + +normalize_delta :: proc "contextless" (delta: Delta) -> (normalized: Delta, err: Error) { + // Distribute nanos into seconds and remainder + seconds, nanos := divmod(delta.nanos, 1e9) + + // Add original seconds to rolled over seconds. + seconds += delta.seconds + days: int + + // Distribute seconds into number of days and remaining seconds. + days, seconds = divmod(seconds, 24 * 3600) + + // Add original days + days += delta.days + + if days <= MIN_ORD || days >= MAX_ORD { + return {}, .Invalid_Delta + } + return Delta{days, seconds, nanos}, .None +} + +// The following procedures don't check whether their inputs are in a valid range. +// They're still exported for those who know their inputs have been validated. + +unsafe_date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal) { + year_minus_one := date.year - 1 + + // Day before epoch + ordinal = EPOCH - 1 + + // Add non-leap days + ordinal += 365 * year_minus_one + + // Add leap days + ordinal += floor_div(year_minus_one, 4) // Julian-rule leap days + ordinal -= floor_div(year_minus_one, 100) // Prior century years + ordinal += floor_div(year_minus_one, 400) // Prior 400-multiple years + ordinal += floor_div(367 * date.month - 362, 12) // Prior days this year + + // Apply correction + if date.month <= 2 { + ordinal += 0 + } else if is_leap_year(date.year) { + ordinal -= 1 + } else { + ordinal -= 2 + } + + // Add days + ordinal += date.day + return +} + +unsafe_ordinal_to_year :: proc "contextless" (ordinal: Ordinal) -> (year: int, day_ordinal: int) { + // Days after epoch + d0 := ordinal - EPOCH + + // Number of 400-year cycles and remainder + n400, d1 := divmod(d0, 146097) + + // Number of 100-year cycles and remainder + n100, d2 := divmod(d1, 36524) + + // Number of 4-year cycles and remainder + n4, d3 := divmod(d2, 1461) + + // Number of remaining days + n1, d4 := divmod(d3, 365) + + year = 400 * n400 + 100 * n100 + 4 * n4 + n1 + + if n1 != 4 && n100 != 4 { + day_ordinal = d4 + 1 + } else { + day_ordinal = 366 + } + + if n100 == 4 || n1 == 4 { + return year, day_ordinal + } + return year + 1, day_ordinal +} + +unsafe_ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date) { + year, _ := unsafe_ordinal_to_year(ordinal) + + prior_days := ordinal - unsafe_date_to_ordinal(Date{year, 1, 1}) + correction := Ordinal(2) + + if ordinal < unsafe_date_to_ordinal(Date{year, 3, 1}) { + correction = 0 + } else if is_leap_year(year) { + correction = 1 + } + + month := floor_div((12 * (prior_days + correction) + 373), 367) + day := ordinal - unsafe_date_to_ordinal(Date{year, month, 1}) + 1 + + return {year, month, day} +} \ No newline at end of file diff --git a/core/time/datetime/internal.odin b/core/time/datetime/internal.odin new file mode 100644 index 000000000..8a5efdb37 --- /dev/null +++ b/core/time/datetime/internal.odin @@ -0,0 +1,95 @@ +package datetime + +// Internal helper functions for calendrical conversions + +import "base:intrinsics" + +sign :: proc "contextless" (v: int) -> (res: int) { + if v == 0 { + return 0 + } else if v > 0 { + return 1 + } + return -1 +} + +// Caller has to ensure y != 0 +divmod :: proc "contextless" (x, y: $T, loc := #caller_location) -> (a: T, r: T) + where intrinsics.type_is_integer(T) { + a = x / y + r = x % y + if (r > 0 && y < 0) || (r < 0 && y > 0) { + a -= 1 + r += y + } + return a, r +} + +// Divides and floors +floor_div :: proc "contextless" (x, y: $T) -> (res: T) + where intrinsics.type_is_integer(T) { + res = x / y + r := x % y + if (r > 0 && y < 0) || (r < 0 && y > 0) { + res -= 1 + } + return res +} + +// Half open: x mod [1..b] +interval_mod :: proc "contextless" (x, a, b: int) -> (res: int) { + if a == b { + return x + } + return a + ((x - a) %% (b - a)) +} + +// x mod [1..b] +adjusted_remainder :: proc "contextless" (x, b: int) -> (res: int) { + m := x %% b + return b if m == 0 else m +} + +gcd :: proc "contextless" (x, y: int) -> (res: int) { + if y == 0 { + return x + } + + m := x %% y + return gcd(y, m) +} + +lcm :: proc "contextless" (x, y: int) -> (res: int) { + return x * y / gcd(x, y) +} + +sum :: proc "contextless" (i: int, f: proc "contextless" (n: int) -> int, cond: proc "contextless" (n: int) -> bool) -> (res: int) { + for idx := i; cond(idx); idx += 1 { + res += f(idx) + } + return +} + +product :: proc "contextless" (i: int, f: proc "contextless" (n: int) -> int, cond: proc "contextless" (n: int) -> bool) -> (res: int) { + res = 1 + for idx := i; cond(idx); idx += 1 { + res *= f(idx) + } + return +} + +smallest :: proc "contextless" (k: int, cond: proc "contextless" (n: int) -> bool) -> (d: int) { + k := k + for !cond(k) { + k += 1 + } + return k +} + +biggest :: proc "contextless" (k: int, cond: proc "contextless" (n: int) -> bool) -> (d: int) { + k := k + for !cond(k) { + k -= 1 + } + return k +} \ No newline at end of file diff --git a/core/time/datetime/validation.odin b/core/time/datetime/validation.odin new file mode 100644 index 000000000..0bf2a2a25 --- /dev/null +++ b/core/time/datetime/validation.odin @@ -0,0 +1,67 @@ +package datetime + +// Validation helpers +is_leap_year :: proc "contextless" (year: int) -> (leap: bool) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +validate_date :: proc "contextless" (date: Date) -> (err: Error) { + return validate(date.year, date.month, date.day) +} + +validate_year_month_day :: proc "contextless" (year, month, day: int) -> (err: Error) { + if year < MIN_DATE.year || year > MAX_DATE.year { + return .Invalid_Year + } + if month < 1 || month > 12 { + return .Invalid_Month + } + + month_days := MONTH_DAYS + days_this_month := month_days[month] + if month == 2 && is_leap_year(year) { + days_this_month = 29 + } + + if day < 1 || day > days_this_month { + return .Invalid_Day + } + return .None +} + +validate_ordinal :: proc "contextless" (ordinal: Ordinal) -> (err: Error) { + if ordinal < MIN_ORD || ordinal > MAX_ORD { + return .Invalid_Ordinal + } + return +} + +validate_time :: proc "contextless" (time: Time) -> (err: Error) { + if time.hour < 0 || time.hour > 23 { + return .Invalid_Hour + } + if time.minute < 0 || time.minute > 59 { + return .Invalid_Minute + } + if time.second < 0 || time.second > 59 { + return .Invalid_Second + } + if time.nano < 0 || time.nano > 1e9 { + return .Invalid_Nano + } + return .None +} + +validate_datetime :: proc "contextless" (using datetime: DateTime) -> (err: Error) { + validate(date) or_return + validate(time) or_return + return .None +} + +validate :: proc{ + validate_date, + validate_year_month_day, + validate_ordinal, + validate_time, + validate_datetime, +} \ No newline at end of file diff --git a/core/time/rfc3339.odin b/core/time/rfc3339.odin new file mode 100644 index 000000000..5a3ac77c3 --- /dev/null +++ b/core/time/rfc3339.odin @@ -0,0 +1,122 @@ +package time +// Parsing RFC 3339 date/time strings into time.Time. +// See https://www.rfc-editor.org/rfc/rfc3339 for the definition + +import dt "core:time/datetime" + +// Parses an RFC 3339 string and returns Time in UTC, with any UTC offset applied to it. +// Only 4-digit years are accepted. +// Optional pointer to boolean `is_leap` will return `true` if the moment was a leap second. +// Leap seconds are smeared into 23:59:59. +rfc3339_to_time_utc :: proc(rfc_datetime: string, is_leap: ^bool = nil) -> (res: Time, consumed: int) { + offset: int + + res, offset, consumed = rfc3339_to_time_and_offset(rfc_datetime, is_leap) + res._nsec += (i64(-offset) * i64(Minute)) + return res, consumed +} + +// Parses an RFC 3339 string and returns Time and a UTC offset in minutes. +// e.g. 1985-04-12T23:20:50.52Z +// Note: Only 4-digit years are accepted. +// Optional pointer to boolean `is_leap` will return `true` if the moment was a leap second. +// Leap seconds are smeared into 23:59:59. +rfc3339_to_time_and_offset :: proc(rfc_datetime: string, is_leap: ^bool = nil) -> (res: Time, utc_offset: int, consumed: int) { + moment, offset, count := rfc3339_to_components(rfc_datetime) + if count == 0 { + return + } + + // Leap second handling + if moment.minute == 59 && moment.second == 60 { + moment.second = 59 + if is_leap != nil { + is_leap^ = true + } + } + + if _res, ok := datetime_to_time(moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, moment.nano); !ok { + return {}, 0, 0 + } else { + return _res, offset, count + } +} + +// Parses an RFC 3339 string and returns Time and a UTC offset in minutes. +// e.g. 1985-04-12T23:20:50.52Z +// Performs no validation on whether components are valid, e.g. it'll return hour = 25 if that's what it's given +rfc3339_to_components :: proc(rfc_datetime: string) -> (res: dt.DateTime, utc_offset: int, consumed: int) { + count: int + moment, offset, ok := _rfc3339_to_components(rfc_datetime, &count) + if !ok { + return + } + return moment, offset, count +} + +// Parses an RFC 3339 string and returns datetime.DateTime. +// Performs no validation on whether components are valid, e.g. it'll return hour = 25 if that's what it's given +@(private) +_rfc3339_to_components :: proc(rfc_datetime: string, consume_count: ^int = nil) -> (res: dt.DateTime, utc_offset: int, ok: bool) { + // A compliant date is at minimum 20 characters long, e.g. YYYY-MM-DDThh:mm:ssZ + (len(rfc_datetime) >= 20) or_return + + // Scan and eat YYYY-MM-DD[Tt] + res.year = scan_digits(rfc_datetime[0:], "-", 4) or_return + res.month = scan_digits(rfc_datetime[5:], "-", 2) or_return + res.day = scan_digits(rfc_datetime[8:], "Tt", 2) or_return + + // Scan and eat HH:MM:SS, leave separator + res.hour = scan_digits(rfc_datetime[11:], ":", 2) or_return + res.minute = scan_digits(rfc_datetime[14:], ":", 2) or_return + res.second = scan_digits(rfc_datetime[17:], "", 2) or_return + count := 19 + + if rfc_datetime[count] == '.' { + // Scan hundredths. The string must be at least 4 bytes long (.hhZ) + (len(rfc_datetime[count:]) >= 4) or_return + hundredths := scan_digits(rfc_datetime[count+1:], "", 2) or_return + count += 3 + + res.nano = 10_000_000 * hundredths + } + + // Scan UTC offset + switch rfc_datetime[count] { + case 'Z': + utc_offset = 0 + count += 1 + case '+', '-': + (len(rfc_datetime[count:]) >= 6) or_return + offset_hour := scan_digits(rfc_datetime[count+1:], ":", 2) or_return + offset_minute := scan_digits(rfc_datetime[count+4:], "", 2) or_return + + utc_offset = 60 * offset_hour + offset_minute + utc_offset *= -1 if rfc_datetime[count] == '-' else 1 + count += 6 + } + + if consume_count != nil { + consume_count^ = count + } + return res, utc_offset, true +} + +@(private) +scan_digits :: proc(s: string, sep: string, count: int) -> (res: int, ok: bool) { + needed := count + min(1, len(sep)) + (len(s) >= needed) or_return + + #no_bounds_check for i in 0..= '0' && v <= '9' { + res = res * 10 + int(v - '0') + } else { + return 0, false + } + } + found_sep := len(sep) == 0 + #no_bounds_check for v in sep { + found_sep |= rune(s[count]) == v + } + return res, found_sep +} \ No newline at end of file diff --git a/core/time/time.odin b/core/time/time.odin index 72a09ad94..6716be35c 100644 --- a/core/time/time.odin +++ b/core/time/time.odin @@ -1,6 +1,7 @@ package time -import "base:intrinsics" +import "base:intrinsics" +import dt "core:time/datetime" Duration :: distinct i64 @@ -299,10 +300,6 @@ _time_abs :: proc "contextless" (t: Time) -> u64 { @(private) _abs_date :: proc "contextless" (abs: u64, full: bool) -> (year: int, month: Month, day: int, yday: int) { - _is_leap_year :: proc "contextless" (year: int) -> bool { - return year%4 == 0 && (year%100 != 0 || year%400 == 0) - } - d := abs / SECONDS_PER_DAY // 400 year cycles @@ -335,7 +332,7 @@ _abs_date :: proc "contextless" (abs: u64, full: bool) -> (year: int, month: Mon day = yday - if _is_leap_year(year) { + if is_leap_year(year) { switch { case day > 31+29-1: day -= 1 @@ -360,57 +357,32 @@ _abs_date :: proc "contextless" (abs: u64, full: bool) -> (year: int, month: Mon return } -datetime_to_time :: proc "contextless" (year, month, day, hour, minute, second: int, nsec := int(0)) -> (t: Time, ok: bool) { - divmod :: proc "contextless" (year: int, divisor: int) -> (div: int, mod: int) { - if divisor <= 0 { - intrinsics.debug_trap() - } - div = int(year / divisor) - mod = year % divisor - return - } - _is_leap_year :: proc "contextless" (year: int) -> bool { - return year%4 == 0 && (year%100 != 0 || year%400 == 0) +components_to_time :: proc "contextless" (year, month, day, hour, minute, second: int, nsec := int(0)) -> (t: Time, ok: bool) { + this_date := dt.DateTime{date={year, month, day}, time={hour, minute, second, nsec}} + return compound_to_time(this_date) +} + +compound_to_time :: proc "contextless" (datetime: dt.DateTime) -> (t: Time, ok: bool) { + unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}} + delta, err := dt.sub(datetime, unix_epoch) + ok = err == .None + + seconds := delta.days * 86_400 + delta.seconds + nanoseconds := i128(seconds) * 1e9 + i128(delta.nanos) + + // Can this moment be represented in i64 worth of nanoseconds? + // min(Time): 1677-09-21 00:12:44.145224192 +0000 UTC + // max(Time): 2262-04-11 23:47:16.854775807 +0000 UTC + if nanoseconds < i128(min(i64)) || nanoseconds > i128(max(i64)) { + return {}, false } + return Time{_nsec=i64(nanoseconds)}, true +} +datetime_to_time :: proc{components_to_time, compound_to_time} - ok = true - - _y := year - 1970 - _m := month - 1 - _d := day - 1 - - if month < 1 || month > 12 { - _m %= 12; ok = false - } - if day < 1 || day > 31 { - _d %= 31; ok = false - } - - s := i64(0) - div, mod := divmod(_y, 400) - days := div * DAYS_PER_400_YEARS - - div, mod = divmod(mod, 100) - days += div * DAYS_PER_100_YEARS - - div, mod = divmod(mod, 4) - days += (div * DAYS_PER_4_YEARS) + (mod * 365) - - days += int(days_before[_m]) + _d - - if _is_leap_year(year) && _m >= 2 { - days += 1 - } - - s += i64(days) * SECONDS_PER_DAY - s += i64(hour) * SECONDS_PER_HOUR - s += i64(minute) * SECONDS_PER_MINUTE - s += i64(second) - - t._nsec = (s * 1e9) + i64(nsec) - - return +is_leap_year :: proc "contextless" (year: int) -> (leap: bool) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } days_before := [?]i32{ diff --git a/tests/core/Makefile b/tests/core/Makefile index ecb05d002..dcb3c9906 100644 --- a/tests/core/Makefile +++ b/tests/core/Makefile @@ -24,7 +24,8 @@ all: c_libc_test \ slice_test \ strings_test \ thread_test \ - runtime_test + runtime_test \ + time_test download_test_assets: $(PYTHON) download_assets.py @@ -94,3 +95,6 @@ thread_test: runtime_test: $(ODIN) run runtime $(COMMON) -out:test_core_runtime + +time_test: + $(ODIN) run time $(COMMON) -out:test_core_time diff --git a/tests/core/build.bat b/tests/core/build.bat index 210760d00..f94f13c19 100644 --- a/tests/core/build.bat +++ b/tests/core/build.bat @@ -100,3 +100,8 @@ echo --- echo Running core:runtime tests echo --- %PATH_TO_ODIN% run runtime %COMMON% %COLLECTION% -out:test_core_runtime.exe || exit /b + +echo --- +echo Running core:runtime tests +echo --- +%PATH_TO_ODIN% run time %COMMON% %COLLECTION% -out:test_core_time.exe || exit /b \ No newline at end of file diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin new file mode 100644 index 000000000..2d13ee326 --- /dev/null +++ b/tests/core/time/test_core_time.odin @@ -0,0 +1,177 @@ +package test_core_time + +import "core:fmt" +import "core:mem" +import "core:os" +import "core:testing" +import "core:time" +import dt "core:time/datetime" + +is_leap_year :: time.is_leap_year + +TEST_count := 0 +TEST_fail := 0 + +when ODIN_TEST { + expect :: testing.expect + expect_value :: testing.expect_value + log :: testing.log +} else { + expect :: proc(t: ^testing.T, condition: bool, message: string, loc := #caller_location) { + TEST_count += 1 + if !condition { + TEST_fail += 1 + fmt.printf("[%v] %v\n", loc, message) + return + } + } + log :: proc(t: ^testing.T, v: any, loc := #caller_location) { + fmt.printf("[%v] ", loc) + fmt.printf("log: %v\n", v) + } +} + +main :: proc() { + t := testing.T{} + + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.allocator) + defer mem.tracking_allocator_destroy(&track) + context.allocator = mem.tracking_allocator(&track) + + test_ordinal_date_roundtrip(&t) + test_component_to_time_roundtrip(&t) + test_parse_rfc3339_string(&t) + + for _, leak in track.allocation_map { + expect(&t, false, fmt.tprintf("%v leaked %m\n", leak.location, leak.size)) + } + for bad_free in track.bad_free_array { + expect(&t, false, fmt.tprintf("%v allocation %p was freed badly\n", bad_free.location, bad_free.memory)) + } + + fmt.printf("%v/%v tests successful.\n", TEST_count - TEST_fail, TEST_count) + if TEST_fail > 0 { + os.exit(1) + } +} + +@test +test_ordinal_date_roundtrip :: proc(t: ^testing.T) { + expect(t, dt.unsafe_ordinal_to_date(dt.unsafe_date_to_ordinal(dt.MIN_DATE)) == dt.MIN_DATE, "Roundtripping MIN_DATE failed.") + expect(t, dt.unsafe_date_to_ordinal(dt.unsafe_ordinal_to_date(dt.MIN_ORD)) == dt.MIN_ORD, "Roundtripping MIN_ORD failed.") + expect(t, dt.unsafe_ordinal_to_date(dt.unsafe_date_to_ordinal(dt.MAX_DATE)) == dt.MAX_DATE, "Roundtripping MAX_DATE failed.") + expect(t, dt.unsafe_date_to_ordinal(dt.unsafe_ordinal_to_date(dt.MAX_ORD)) == dt.MAX_ORD, "Roundtripping MAX_ORD failed.") +} + +/* + 1990-12-31T23:59:60Z + +This represents the leap second inserted at the end of 1990. + + 1990-12-31T15:59:60-08:00 + +This represents the same leap second in Pacific Standard Time, 8 hours behind UTC. + + 1937-01-01T12:00:27.87+00:20 + +This represents the same instant of time as noon, January 1, 1937, Netherlands time. +Standard time in the Netherlands was exactly 19 minutes and 32.13 seconds ahead of UTC by law from 1909-05-01 through 1937-06-30. +This time zone cannot be represented exactly using the HH:MM format, and this timestamp uses the closest representable UTC offset. +*/ +RFC3339_Test :: struct{ + rfc_3339: string, + datetime: time.Time, + apply_offset: bool, + utc_offset: int, + consumed: int, + is_leap: bool, +} + +// These are based on RFC 3339's examples, see https://www.rfc-editor.org/rfc/rfc3339#page-10 +rfc3339_tests :: []RFC3339_Test{ + // This represents 20 minutes and 50.52 seconds after the 23rd hour of April 12th, 1985 in UTC. + {"1985-04-12T23:20:50.52Z", {482196050520000000}, true, 0, 23, false}, + + // This represents 39 minutes and 57 seconds after the 16th hour of December 19th, 1996 with an offset of -08:00 from UTC (Pacific Standard Time). + // Note that this is equivalent to 1996-12-20T00:39:57Z in UTC. + {"1996-12-19T16:39:57-08:00", {851013597000000000}, false, -480, 25, false}, + {"1996-12-19T16:39:57-08:00", {851042397000000000}, true, 0, 25, false}, + {"1996-12-20T00:39:57Z", {851042397000000000}, false, 0, 20, false}, + + // This represents the leap second inserted at the end of 1990. + // It'll be represented as 1990-12-31 23:59:59 UTC after parsing, and `is_leap` will be set to `true`. + {"1990-12-31T23:59:60Z", {662687999000000000}, true, 0, 20, true}, + + // This represents the same leap second in Pacific Standard Time, 8 hours behind UTC. + {"1990-12-31T15:59:60-08:00", {662687999000000000}, true, 0, 25, true}, + + // This represents the same instant of time as noon, January 1, 1937, Netherlands time. + // Standard time in the Netherlands was exactly 19 minutes and 32.13 seconds ahead of UTC by law + // from 1909-05-01 through 1937-06-30. This time zone cannot be represented exactly using the + // HH:MM format, and this timestamp uses the closest representable UTC offset. + {"1937-01-01T12:00:27.87+00:20", {-1041335972130000000}, false, 20, 28, false}, + {"1937-01-01T12:00:27.87+00:20", {-1041337172130000000}, true, 0, 28, false}, +} + +@test +test_parse_rfc3339_string :: proc(t: ^testing.T) { + for test in rfc3339_tests { + is_leap := false + if test.apply_offset { + res, consumed := time.rfc3339_to_time_utc(test.rfc_3339, &is_leap) + msg := fmt.tprintf("[apply offet] Parsing failed: %v -> %v (nsec: %v). Expected %v consumed, got %v", test.rfc_3339, res, res._nsec, test.consumed, consumed) + expect(t, test.consumed == consumed, msg) + + if test.consumed == consumed { + expect(t, test.datetime == res, fmt.tprintf("Time didn't match. Expected %v (%v), got %v (%v)", test.datetime, test.datetime._nsec, res, res._nsec)) + expect(t, test.is_leap == is_leap, "Expected a leap second, got none.") + } + } else { + res, offset, consumed := time.rfc3339_to_time_and_offset(test.rfc_3339) + msg := fmt.tprintf("Parsing failed: %v -> %v (nsec: %v), offset: %v. Expected %v consumed, got %v", test.rfc_3339, res, res._nsec, offset, test.consumed, consumed) + expect(t, test.consumed == consumed, msg) + + if test.consumed == consumed { + expect(t, test.datetime == res, fmt.tprintf("Time didn't match. Expected %v (%v), got %v (%v)", test.datetime, test.datetime._nsec, res, res._nsec)) + expect(t, test.utc_offset == offset, fmt.tprintf("UTC offset didn't match. Expected %v, got %v", test.utc_offset, offset)) + expect(t, test.is_leap == is_leap, "Expected a leap second, got none.") + } + } + } +} + +MONTH_DAYS := []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} +YEAR_START :: 1900 +YEAR_END :: 2024 + +@test +test_component_to_time_roundtrip :: proc(t: ^testing.T) { + // Roundtrip a datetime through `datetime_to_time` to `Time` and back to its components. + for year in YEAR_START..=YEAR_END { + for month in 1..=12 { + days := MONTH_DAYS[month - 1] + if month == 2 && is_leap_year(year) { + days += 1 + } + for day in 1..=days { + date_component_roundtrip_test(t, {{year, month, day}, {0, 0, 0, 0}}) + } + } + } +} + +date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) { + res, ok := time.datetime_to_time(moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second) + expect(t, ok, "Couldn't convert date components into date") + + YYYY, MM, DD := time.date(res) + hh, mm, ss := time.clock(res) + + expected := fmt.tprintf("Expected %4d-%2d-%2d %2d:%2d:%2d, got %4d-%2d-%2d %2d:%2d:%2d", + moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss) + + ok = moment.year == YYYY && moment.month == int(MM) && moment.day == DD + ok &= moment.hour == hh && moment.minute == mm && moment.second == ss + expect(t, ok, expected) +} \ No newline at end of file From 07ef96954647dd80fb3e6a8df14b3280a2d99fa4 Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Mon, 18 Mar 2024 17:05:40 +0100 Subject: [PATCH 2/6] Fix test label. --- examples/all/all_main.odin | 2 ++ tests/core/build.bat | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/all/all_main.odin b/examples/all/all_main.odin index c89b93e3b..bc1aff607 100644 --- a/examples/all/all_main.odin +++ b/examples/all/all_main.odin @@ -117,6 +117,7 @@ import table "core:text/table" import edit "core:text/edit" import thread "core:thread" import time "core:time" +import datetime "core:time/datetime" import sysinfo "core:sys/info" @@ -225,6 +226,7 @@ _ :: table _ :: edit _ :: thread _ :: time +_ :: datetime _ :: sysinfo _ :: unicode _ :: utf8 diff --git a/tests/core/build.bat b/tests/core/build.bat index f94f13c19..ac7137fa1 100644 --- a/tests/core/build.bat +++ b/tests/core/build.bat @@ -102,6 +102,6 @@ echo --- %PATH_TO_ODIN% run runtime %COMMON% %COLLECTION% -out:test_core_runtime.exe || exit /b echo --- -echo Running core:runtime tests +echo Running core:time tests echo --- %PATH_TO_ODIN% run time %COMMON% %COLLECTION% -out:test_core_time.exe || exit /b \ No newline at end of file From 9c144dd24f4d6eb148f8ba1049d9a365a233c504 Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Wed, 20 Mar 2024 17:56:22 +0100 Subject: [PATCH 3/6] Change Ordinal from int to i64 --- core/time/datetime/constants.odin | 45 ++++++++++------------- core/time/datetime/datetime.odin | 44 +++++++++++++++-------- core/time/datetime/internal.odin | 18 +++++----- core/time/datetime/validation.odin | 6 ++-- core/time/rfc3339.odin | 56 ++++++++++++++--------------- core/time/time.odin | 7 ++-- tests/core/time/test_core_time.odin | 7 ++-- 7 files changed, 96 insertions(+), 87 deletions(-) diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin index 5b6c2d77c..8ae0565e0 100644 --- a/core/time/datetime/constants.odin +++ b/core/time/datetime/constants.odin @@ -2,23 +2,14 @@ package datetime // Ordinal 1 = Midnight Monday, January 1, 1 A.D. (Gregorian) // | Midnight Monday, January 3, 1 A.D. (Julian) -Ordinal :: int +Ordinal :: i64 EPOCH :: Ordinal(1) // Minimum and maximum dates and ordinals. Chosen for safe roundtripping. -when size_of(int) == 4 { - MIN_DATE :: Date{year = -5_879_608, month = 1, day = 1} - MAX_DATE :: Date{year = 5_879_608, month = 12, day = 31} - - MIN_ORD :: Ordinal(-2_147_483_090) - MAX_ORD :: Ordinal( 2_147_482_725) -} else { - MIN_DATE :: Date{year = -25_252_734_927_766_552, month = 1, day = 1} - MAX_DATE :: Date{year = 25_252_734_927_766_552, month = 12, day = 31} - - MIN_ORD :: Ordinal(-9_223_372_036_854_775_234) - MAX_ORD :: Ordinal( 9_223_372_036_854_774_869) -} +MIN_DATE :: Date{year = -25_252_734_927_766_552, month = 1, day = 1} +MAX_DATE :: Date{year = 25_252_734_927_766_552, month = 12, day = 31} +MIN_ORD :: Ordinal(-9_223_372_036_854_775_234) +MAX_ORD :: Ordinal( 9_223_372_036_854_774_869) Error :: enum { None, @@ -34,16 +25,16 @@ Error :: enum { } Date :: struct { - year: int, - month: int, - day: int, + year: i64, + month: i64, + day: i64, } Time :: struct { - hour: int, - minute: int, - second: int, - nano: int, + hour: i64, + minute: i64, + second: i64, + nano: i64, } DateTime :: struct { @@ -52,12 +43,12 @@ DateTime :: struct { } Delta :: struct { - days: int, - seconds: int, - nanos: int, + days: i64, // These are all i64 because we can also use it to add a number of seconds or nanos to a moment, + seconds: i64, // that are then normalized within their respective ranges. + nanos: i64, } -Month :: enum int { +Month :: enum i8 { January = 1, February, March, @@ -72,7 +63,7 @@ Month :: enum int { December, } -Weekday :: enum int { +Weekday :: enum i8 { Sunday = 0, Monday, Tuesday, @@ -83,4 +74,4 @@ Weekday :: enum int { } @(private) -MONTH_DAYS :: [?]int{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} \ No newline at end of file +MONTH_DAYS :: [?]i8{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} \ No newline at end of file diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin index 9998e0a76..823aa50a6 100644 --- a/core/time/datetime/datetime.odin +++ b/core/time/datetime/datetime.odin @@ -8,27 +8,41 @@ package datetime import "base:intrinsics" // Procedures that return an Ordinal + date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal, err: Error) { validate(date) or_return return unsafe_date_to_ordinal(date), .None } -components_to_ordinal :: proc "contextless" (year, month, day: int) -> (ordinal: Ordinal, err: Error) { +components_to_ordinal :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (ordinal: Ordinal, err: Error) { return date_to_ordinal(Date{year, month, day}) } // Procedures that return a Date + ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date, err: Error) { validate(ordinal) or_return return unsafe_ordinal_to_date(ordinal), .None } -components_to_date :: proc "contextless" (year, month, day: int) -> (date: Date, err: Error) { - date = Date{year, month, day} +components_to_date :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (date: Date, err: Error) { + date = Date{i64(year), i64(month), i64(day)} validate(date) or_return return date, .None } +components_to_time :: proc "contextless" (#any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (time: Time, err: Error) { + time = Time{i64(hour), i64(minute), i64(second), i64(nanos)} + validate(time) or_return + return time, .None +} + +components_to_datetime :: proc "contextless" (#any_int year, #any_int month, #any_int day, #any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (datetime: DateTime, err: Error) { + date := components_to_date(year, month, day) or_return + time := components_to_time(hour, minute, second, nanos) or_return + return {date, time}, .None +} + ordinal_to_datetime :: proc "contextless" (ordinal: Ordinal) -> (datetime: DateTime, err: Error) { d := ordinal_to_date(ordinal) or_return return {Date(d), {}}, .None @@ -67,7 +81,7 @@ subtract_deltas :: proc "contextless" (a, b: Delta) -> (delta: Delta, err: Error } sub :: proc{subtract_datetimes, subtract_dates, subtract_deltas} -add_days_to_date :: proc "contextless" (a: Date, days: int) -> (date: Date, err: Error) { +add_days_to_date :: proc "contextless" (a: Date, days: i64) -> (date: Date, err: Error) { ord := date_to_ordinal(a) or_return ord += days return ordinal_to_date(ord) @@ -91,7 +105,7 @@ add_delta_to_datetime :: proc "contextless" (a: DateTime, delta: Delta) -> (date datetime.date = ordinal_to_date(sum_delta.days) or_return - r: int + r: i64 datetime.hour, r = divmod(sum_delta.seconds, 3600) datetime.minute, datetime.second = divmod(r, 60) datetime.nano = sum_delta.nanos @@ -100,7 +114,7 @@ add_delta_to_datetime :: proc "contextless" (a: DateTime, delta: Delta) -> (date } add :: proc{add_days_to_date, add_delta_to_date, add_delta_to_datetime} -day_number :: proc "contextless" (date: Date) -> (day_number: int, err: Error) { +day_number :: proc "contextless" (date: Date) -> (day_number: i64, err: Error) { validate(date) or_return ord := unsafe_date_to_ordinal(date) @@ -108,39 +122,39 @@ day_number :: proc "contextless" (date: Date) -> (day_number: int, err: Error) { return } -days_remaining :: proc "contextless" (date: Date) -> (days_remaining: int, err: Error) { +days_remaining :: proc "contextless" (date: Date) -> (days_remaining: i64, err: Error) { // Alternative formulation `day_number` subtracted from 365 or 366 depending on leap year validate(date) or_return delta := sub(date, Date{date.year, 12, 31}) or_return return delta.days, .None } -last_day_of_month :: proc "contextless" (year, month: int) -> (day: int, err: Error) { +last_day_of_month :: proc "contextless" (year, month: i64) -> (day: i64, err: Error) { // Not using formula 2.27 from the book. This is far simpler and gives the same answer. validate(Date{year, month, 1}) or_return month_days := MONTH_DAYS - day = month_days[month] + day = i64(month_days[month]) if month == 2 && is_leap_year(year) { day += 1 } return } -new_year :: proc "contextless" (year: int) -> (new_year: Date, err: Error) { +new_year :: proc "contextless" (#any_int year: i64) -> (new_year: Date, err: Error) { new_year = {year, 1, 1} validate(new_year) or_return return } -year_end :: proc "contextless" (year: int) -> (year_end: Date, err: Error) { +year_end :: proc "contextless" (#any_int year: i64) -> (year_end: Date, err: Error) { year_end = {year, 12, 31} validate(year_end) or_return return } -year_range :: proc (year: int, allocator := context.allocator) -> (range: []Date) { +year_range :: proc (#any_int year: i64, allocator := context.allocator) -> (range: []Date) { is_leap := is_leap_year(year) days := 366 if is_leap else 365 @@ -154,7 +168,7 @@ year_range :: proc (year: int, allocator := context.allocator) -> (range: []Date i := 0 for month in 1..=len(month_days) { for day in 1..=month_days[month] { - range[i] = Date{year, month, day} + range[i], _ = components_to_date(year, month, day) i += 1 } } @@ -167,7 +181,7 @@ normalize_delta :: proc "contextless" (delta: Delta) -> (normalized: Delta, err: // Add original seconds to rolled over seconds. seconds += delta.seconds - days: int + days: i64 // Distribute seconds into number of days and remaining seconds. days, seconds = divmod(seconds, 24 * 3600) @@ -213,7 +227,7 @@ unsafe_date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal) return } -unsafe_ordinal_to_year :: proc "contextless" (ordinal: Ordinal) -> (year: int, day_ordinal: int) { +unsafe_ordinal_to_year :: proc "contextless" (ordinal: Ordinal) -> (year: i64, day_ordinal: i64) { // Days after epoch d0 := ordinal - EPOCH diff --git a/core/time/datetime/internal.odin b/core/time/datetime/internal.odin index 8a5efdb37..45c2b99ab 100644 --- a/core/time/datetime/internal.odin +++ b/core/time/datetime/internal.odin @@ -4,7 +4,7 @@ package datetime import "base:intrinsics" -sign :: proc "contextless" (v: int) -> (res: int) { +sign :: proc "contextless" (v: i64) -> (res: i64) { if v == 0 { return 0 } else if v > 0 { @@ -37,7 +37,7 @@ floor_div :: proc "contextless" (x, y: $T) -> (res: T) } // Half open: x mod [1..b] -interval_mod :: proc "contextless" (x, a, b: int) -> (res: int) { +interval_mod :: proc "contextless" (x, a, b: i64) -> (res: i64) { if a == b { return x } @@ -45,12 +45,12 @@ interval_mod :: proc "contextless" (x, a, b: int) -> (res: int) { } // x mod [1..b] -adjusted_remainder :: proc "contextless" (x, b: int) -> (res: int) { +adjusted_remainder :: proc "contextless" (x, b: i64) -> (res: i64) { m := x %% b return b if m == 0 else m } -gcd :: proc "contextless" (x, y: int) -> (res: int) { +gcd :: proc "contextless" (x, y: i64) -> (res: i64) { if y == 0 { return x } @@ -59,18 +59,18 @@ gcd :: proc "contextless" (x, y: int) -> (res: int) { return gcd(y, m) } -lcm :: proc "contextless" (x, y: int) -> (res: int) { +lcm :: proc "contextless" (x, y: i64) -> (res: i64) { return x * y / gcd(x, y) } -sum :: proc "contextless" (i: int, f: proc "contextless" (n: int) -> int, cond: proc "contextless" (n: int) -> bool) -> (res: int) { +sum :: proc "contextless" (i: i64, f: proc "contextless" (n: i64) -> i64, cond: proc "contextless" (n: i64) -> bool) -> (res: i64) { for idx := i; cond(idx); idx += 1 { res += f(idx) } return } -product :: proc "contextless" (i: int, f: proc "contextless" (n: int) -> int, cond: proc "contextless" (n: int) -> bool) -> (res: int) { +product :: proc "contextless" (i: i64, f: proc "contextless" (n: i64) -> i64, cond: proc "contextless" (n: i64) -> bool) -> (res: i64) { res = 1 for idx := i; cond(idx); idx += 1 { res *= f(idx) @@ -78,7 +78,7 @@ product :: proc "contextless" (i: int, f: proc "contextless" (n: int) -> int, co return } -smallest :: proc "contextless" (k: int, cond: proc "contextless" (n: int) -> bool) -> (d: int) { +smallest :: proc "contextless" (k: i64, cond: proc "contextless" (n: i64) -> bool) -> (d: i64) { k := k for !cond(k) { k += 1 @@ -86,7 +86,7 @@ smallest :: proc "contextless" (k: int, cond: proc "contextless" (n: int) -> boo return k } -biggest :: proc "contextless" (k: int, cond: proc "contextless" (n: int) -> bool) -> (d: int) { +biggest :: proc "contextless" (k: i64, cond: proc "contextless" (n: i64) -> bool) -> (d: i64) { k := k for !cond(k) { k -= 1 diff --git a/core/time/datetime/validation.odin b/core/time/datetime/validation.odin index 0bf2a2a25..38176269b 100644 --- a/core/time/datetime/validation.odin +++ b/core/time/datetime/validation.odin @@ -1,7 +1,7 @@ package datetime // Validation helpers -is_leap_year :: proc "contextless" (year: int) -> (leap: bool) { +is_leap_year :: proc "contextless" (#any_int year: i64) -> (leap: bool) { return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } @@ -9,7 +9,7 @@ validate_date :: proc "contextless" (date: Date) -> (err: Error) { return validate(date.year, date.month, date.day) } -validate_year_month_day :: proc "contextless" (year, month, day: int) -> (err: Error) { +validate_year_month_day :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (err: Error) { if year < MIN_DATE.year || year > MAX_DATE.year { return .Invalid_Year } @@ -23,7 +23,7 @@ validate_year_month_day :: proc "contextless" (year, month, day: int) -> (err: E days_this_month = 29 } - if day < 1 || day > days_this_month { + if day < 1 || day > i64(days_this_month) { return .Invalid_Day } return .None diff --git a/core/time/rfc3339.odin b/core/time/rfc3339.odin index 5a3ac77c3..30c255c79 100644 --- a/core/time/rfc3339.odin +++ b/core/time/rfc3339.odin @@ -22,17 +22,13 @@ rfc3339_to_time_utc :: proc(rfc_datetime: string, is_leap: ^bool = nil) -> (res: // Optional pointer to boolean `is_leap` will return `true` if the moment was a leap second. // Leap seconds are smeared into 23:59:59. rfc3339_to_time_and_offset :: proc(rfc_datetime: string, is_leap: ^bool = nil) -> (res: Time, utc_offset: int, consumed: int) { - moment, offset, count := rfc3339_to_components(rfc_datetime) + moment, offset, leap_second, count := rfc3339_to_components(rfc_datetime) if count == 0 { return } - // Leap second handling - if moment.minute == 59 && moment.second == 60 { - moment.second = 59 - if is_leap != nil { - is_leap^ = true - } + if is_leap != nil { + is_leap^ = leap_second } if _res, ok := datetime_to_time(moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, moment.nano); !ok { @@ -45,40 +41,48 @@ rfc3339_to_time_and_offset :: proc(rfc_datetime: string, is_leap: ^bool = nil) - // Parses an RFC 3339 string and returns Time and a UTC offset in minutes. // e.g. 1985-04-12T23:20:50.52Z // Performs no validation on whether components are valid, e.g. it'll return hour = 25 if that's what it's given -rfc3339_to_components :: proc(rfc_datetime: string) -> (res: dt.DateTime, utc_offset: int, consumed: int) { - count: int - moment, offset, ok := _rfc3339_to_components(rfc_datetime, &count) +rfc3339_to_components :: proc(rfc_datetime: string) -> (res: dt.DateTime, utc_offset: int, is_leap: bool, consumed: int) { + moment, offset, count, leap_second, ok := _rfc3339_to_components(rfc_datetime) if !ok { return } - return moment, offset, count + return moment, offset, leap_second, count } // Parses an RFC 3339 string and returns datetime.DateTime. // Performs no validation on whether components are valid, e.g. it'll return hour = 25 if that's what it's given @(private) -_rfc3339_to_components :: proc(rfc_datetime: string, consume_count: ^int = nil) -> (res: dt.DateTime, utc_offset: int, ok: bool) { +_rfc3339_to_components :: proc(rfc_datetime: string) -> (res: dt.DateTime, utc_offset: int, consumed: int, is_leap: bool, ok: bool) { // A compliant date is at minimum 20 characters long, e.g. YYYY-MM-DDThh:mm:ssZ (len(rfc_datetime) >= 20) or_return - // Scan and eat YYYY-MM-DD[Tt] - res.year = scan_digits(rfc_datetime[0:], "-", 4) or_return - res.month = scan_digits(rfc_datetime[5:], "-", 2) or_return - res.day = scan_digits(rfc_datetime[8:], "Tt", 2) or_return - - // Scan and eat HH:MM:SS, leave separator - res.hour = scan_digits(rfc_datetime[11:], ":", 2) or_return - res.minute = scan_digits(rfc_datetime[14:], ":", 2) or_return - res.second = scan_digits(rfc_datetime[17:], "", 2) or_return - count := 19 + // Scan and eat YYYY-MM-DD[Tt], then scan and eat HH:MM:SS, leave separator + year := scan_digits(rfc_datetime[0:], "-", 4) or_return + month := scan_digits(rfc_datetime[5:], "-", 2) or_return + day := scan_digits(rfc_datetime[8:], "Tt", 2) or_return + hour := scan_digits(rfc_datetime[11:], ":", 2) or_return + minute := scan_digits(rfc_datetime[14:], ":", 2) or_return + second := scan_digits(rfc_datetime[17:], "", 2) or_return + nanos := 0 + count := 19 if rfc_datetime[count] == '.' { // Scan hundredths. The string must be at least 4 bytes long (.hhZ) (len(rfc_datetime[count:]) >= 4) or_return hundredths := scan_digits(rfc_datetime[count+1:], "", 2) or_return count += 3 + nanos = 10_000_000 * hundredths + } - res.nano = 10_000_000 * hundredths + // Leap second handling + if minute == 59 && second == 60 { + second = 59 + is_leap = true + } + + err: dt.Error + if res, err = dt.components_to_datetime(year, month, day, hour, minute, second, nanos); err != .None { + return {}, 0, 0, false, false } // Scan UTC offset @@ -95,11 +99,7 @@ _rfc3339_to_components :: proc(rfc_datetime: string, consume_count: ^int = nil) utc_offset *= -1 if rfc_datetime[count] == '-' else 1 count += 6 } - - if consume_count != nil { - consume_count^ = count - } - return res, utc_offset, true + return res, utc_offset, count, is_leap, true } @(private) diff --git a/core/time/time.odin b/core/time/time.odin index 6716be35c..10b71ee0d 100644 --- a/core/time/time.odin +++ b/core/time/time.odin @@ -357,8 +357,11 @@ _abs_date :: proc "contextless" (abs: u64, full: bool) -> (year: int, month: Mon return } -components_to_time :: proc "contextless" (year, month, day, hour, minute, second: int, nsec := int(0)) -> (t: Time, ok: bool) { - this_date := dt.DateTime{date={year, month, day}, time={hour, minute, second, nsec}} +components_to_time :: proc "contextless" (#any_int year, #any_int month, #any_int day, #any_int hour, #any_int minute, #any_int second: i64, #any_int nsec := i64(0)) -> (t: Time, ok: bool) { + this_date, err := dt.components_to_datetime(year, month, day, hour, minute, second, nsec) + if err != .None { + return + } return compound_to_time(this_date) } diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin index 2d13ee326..0e324ffaf 100644 --- a/tests/core/time/test_core_time.odin +++ b/tests/core/time/test_core_time.odin @@ -155,7 +155,8 @@ test_component_to_time_roundtrip :: proc(t: ^testing.T) { days += 1 } for day in 1..=days { - date_component_roundtrip_test(t, {{year, month, day}, {0, 0, 0, 0}}) + d, _ := dt.components_to_datetime(year, month, day, 0, 0, 0, 0) + date_component_roundtrip_test(t, d) } } } @@ -171,7 +172,7 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) { expected := fmt.tprintf("Expected %4d-%2d-%2d %2d:%2d:%2d, got %4d-%2d-%2d %2d:%2d:%2d", moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss) - ok = moment.year == YYYY && moment.month == int(MM) && moment.day == DD - ok &= moment.hour == hh && moment.minute == mm && moment.second == ss + ok = moment.year == i64(YYYY) && moment.month == i64(MM) && moment.day == i64(DD) + ok &= moment.hour == i64(hh) && moment.minute == i64(mm) && moment.second == i64(ss) expect(t, ok, expected) } \ No newline at end of file From 89ca15014ca14c327f0044a2084ff5e1ed4cdea4 Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Wed, 20 Mar 2024 18:09:07 +0100 Subject: [PATCH 4/6] Ensmallen Time struct. --- core/time/datetime/constants.odin | 8 ++++---- core/time/datetime/datetime.odin | 19 +++++++++---------- tests/core/time/test_core_time.odin | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin index 8ae0565e0..039f93cfb 100644 --- a/core/time/datetime/constants.odin +++ b/core/time/datetime/constants.odin @@ -31,10 +31,10 @@ Date :: struct { } Time :: struct { - hour: i64, - minute: i64, - second: i64, - nano: i64, + hour: i8, + minute: i8, + second: i8, + nano: i32, } DateTime :: struct { diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin index 823aa50a6..1bc92da80 100644 --- a/core/time/datetime/datetime.odin +++ b/core/time/datetime/datetime.odin @@ -32,7 +32,7 @@ components_to_date :: proc "contextless" (#any_int year, #any_int month, #any_in } components_to_time :: proc "contextless" (#any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (time: Time, err: Error) { - time = Time{i64(hour), i64(minute), i64(second), i64(nanos)} + time = Time{i8(hour), i8(minute), i8(second), i32(nanos)} validate(time) or_return return time, .None } @@ -67,10 +67,10 @@ subtract_datetimes :: proc "contextless" (a, b: DateTime) -> (delta: Delta, err: validate(a.time) or_return validate(b.time) or_return - seconds_a := a.hour * 3600 + a.minute * 60 + a.second - seconds_b := b.hour * 3600 + b.minute * 60 + b.second + seconds_a := i64(a.hour) * 3600 + i64(a.minute) * 60 + i64(a.second) + seconds_b := i64(b.hour) * 3600 + i64(b.minute) * 60 + i64(b.second) - delta = Delta{ord_a - ord_b, seconds_a - seconds_b, a.nano - b.nano} + delta = Delta{ord_a - ord_b, seconds_a - seconds_b, i64(a.nano) - i64(b.nano)} return } @@ -97,19 +97,18 @@ add_delta_to_date :: proc "contextless" (a: Date, delta: Delta) -> (date: Date, add_delta_to_datetime :: proc "contextless" (a: DateTime, delta: Delta) -> (datetime: DateTime, err: Error) { days := date_to_ordinal(a) or_return - a_seconds := a.hour * 3600 + a.minute * 60 + a.second - a_delta := Delta{days=days, seconds=a_seconds, nanos=a.nano} + a_seconds := i64(a.hour) * 3600 + i64(a.minute) * 60 + i64(a.second) + a_delta := Delta{days=days, seconds=a_seconds, nanos=i64(a.nano)} sum_delta := Delta{days=a_delta.days + delta.days, seconds=a_delta.seconds + delta.seconds, nanos=a_delta.nanos + delta.nanos} sum_delta = normalize_delta(sum_delta) or_return datetime.date = ordinal_to_date(sum_delta.days) or_return - r: i64 - datetime.hour, r = divmod(sum_delta.seconds, 3600) - datetime.minute, datetime.second = divmod(r, 60) - datetime.nano = sum_delta.nanos + hour, rem := divmod(sum_delta.seconds, 3600) + minute, second := divmod(rem, 60) + datetime.time = components_to_time(hour, minute, second, sum_delta.nanos) or_return return } add :: proc{add_days_to_date, add_delta_to_date, add_delta_to_datetime} diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin index 0e324ffaf..bfbe22412 100644 --- a/tests/core/time/test_core_time.odin +++ b/tests/core/time/test_core_time.odin @@ -172,7 +172,7 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) { expected := fmt.tprintf("Expected %4d-%2d-%2d %2d:%2d:%2d, got %4d-%2d-%2d %2d:%2d:%2d", moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss) - ok = moment.year == i64(YYYY) && moment.month == i64(MM) && moment.day == i64(DD) - ok &= moment.hour == i64(hh) && moment.minute == i64(mm) && moment.second == i64(ss) + ok = moment.year == i64(YYYY) && moment.month == i64(MM) && moment.day == i64(DD) + ok &= moment.hour == i8(hh) && moment.minute == i8(mm) && moment.second == i8(ss) expect(t, ok, expected) } \ No newline at end of file From ed5fd15f6e567c41e1b9cb2c272461f685b9efd2 Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Wed, 20 Mar 2024 18:13:29 +0100 Subject: [PATCH 5/6] Ensmallen Date struct. --- core/time/datetime/constants.odin | 4 ++-- core/time/datetime/datetime.odin | 14 +++++++------- tests/core/time/test_core_time.odin | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin index 039f93cfb..a2a02838c 100644 --- a/core/time/datetime/constants.odin +++ b/core/time/datetime/constants.odin @@ -26,8 +26,8 @@ Error :: enum { Date :: struct { year: i64, - month: i64, - day: i64, + month: i8, + day: i8, } Time :: struct { diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin index 1bc92da80..156573315 100644 --- a/core/time/datetime/datetime.odin +++ b/core/time/datetime/datetime.odin @@ -15,7 +15,7 @@ date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal, err: Er } components_to_ordinal :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (ordinal: Ordinal, err: Error) { - return date_to_ordinal(Date{year, month, day}) + return date_to_ordinal(Date{year, i8(month), i8(day)}) } // Procedures that return a Date @@ -26,7 +26,7 @@ ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date, err: Er } components_to_date :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (date: Date, err: Error) { - date = Date{i64(year), i64(month), i64(day)} + date = Date{i64(year), i8(month), i8(day)} validate(date) or_return return date, .None } @@ -128,7 +128,7 @@ days_remaining :: proc "contextless" (date: Date) -> (days_remaining: i64, err: return delta.days, .None } -last_day_of_month :: proc "contextless" (year, month: i64) -> (day: i64, err: Error) { +last_day_of_month :: proc "contextless" (#any_int year: i64, #any_int month: i8) -> (day: i64, err: Error) { // Not using formula 2.27 from the book. This is far simpler and gives the same answer. validate(Date{year, month, 1}) or_return @@ -210,7 +210,7 @@ unsafe_date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal) ordinal += floor_div(year_minus_one, 4) // Julian-rule leap days ordinal -= floor_div(year_minus_one, 100) // Prior century years ordinal += floor_div(year_minus_one, 400) // Prior 400-multiple years - ordinal += floor_div(367 * date.month - 362, 12) // Prior days this year + ordinal += floor_div(367 * i64(date.month) - 362, 12) // Prior days this year // Apply correction if date.month <= 2 { @@ -222,7 +222,7 @@ unsafe_date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal) } // Add days - ordinal += date.day + ordinal += i64(date.day) return } @@ -268,8 +268,8 @@ unsafe_ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date) correction = 1 } - month := floor_div((12 * (prior_days + correction) + 373), 367) - day := ordinal - unsafe_date_to_ordinal(Date{year, month, 1}) + 1 + month := i8(floor_div((12 * (prior_days + correction) + 373), 367)) + day := i8(ordinal - unsafe_date_to_ordinal(Date{year, month, 1}) + 1) return {year, month, day} } \ No newline at end of file diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin index bfbe22412..2cea47680 100644 --- a/tests/core/time/test_core_time.odin +++ b/tests/core/time/test_core_time.odin @@ -172,7 +172,7 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) { expected := fmt.tprintf("Expected %4d-%2d-%2d %2d:%2d:%2d, got %4d-%2d-%2d %2d:%2d:%2d", moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss) - ok = moment.year == i64(YYYY) && moment.month == i64(MM) && moment.day == i64(DD) - ok &= moment.hour == i8(hh) && moment.minute == i8(mm) && moment.second == i8(ss) + ok = moment.year == i64(YYYY) && moment.month == i8(MM) && moment.day == i8(DD) + ok &= moment.hour == i8(hh) && moment.minute == i8(mm) && moment.second == i8(ss) expect(t, ok, expected) } \ No newline at end of file From fda283c55e1583604e34465e198d0d1f1faf2fff Mon Sep 17 00:00:00 2001 From: Jeroen van Rijn Date: Wed, 20 Mar 2024 23:09:09 +0100 Subject: [PATCH 6/6] More better validation. --- core/time/datetime/datetime.odin | 23 ++++++++++------------- core/time/datetime/validation.odin | 13 +++++++++---- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin index 156573315..e15ced5a5 100644 --- a/core/time/datetime/datetime.odin +++ b/core/time/datetime/datetime.odin @@ -15,7 +15,8 @@ date_to_ordinal :: proc "contextless" (date: Date) -> (ordinal: Ordinal, err: Er } components_to_ordinal :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (ordinal: Ordinal, err: Error) { - return date_to_ordinal(Date{year, i8(month), i8(day)}) + validate(year, month, day) or_return + return unsafe_date_to_ordinal({year, i8(month), i8(day)}), .None } // Procedures that return a Date @@ -26,15 +27,13 @@ ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date, err: Er } components_to_date :: proc "contextless" (#any_int year, #any_int month, #any_int day: i64) -> (date: Date, err: Error) { - date = Date{i64(year), i8(month), i8(day)} - validate(date) or_return - return date, .None + validate(year, month, day) or_return + return Date{i64(year), i8(month), i8(day)}, .None } components_to_time :: proc "contextless" (#any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (time: Time, err: Error) { - time = Time{i8(hour), i8(minute), i8(second), i32(nanos)} - validate(time) or_return - return time, .None + validate(hour, minute, second, nanos) or_return + return Time{i8(hour), i8(minute), i8(second), i32(nanos)}, .None } components_to_datetime :: proc "contextless" (#any_int year, #any_int month, #any_int day, #any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (datetime: DateTime, err: Error) { @@ -142,15 +141,13 @@ last_day_of_month :: proc "contextless" (#any_int year: i64, #any_int month: i8) } new_year :: proc "contextless" (#any_int year: i64) -> (new_year: Date, err: Error) { - new_year = {year, 1, 1} - validate(new_year) or_return - return + validate(year, 1, 1) or_return + return {year, 1, 1}, .None } year_end :: proc "contextless" (#any_int year: i64) -> (year_end: Date, err: Error) { - year_end = {year, 12, 31} - validate(year_end) or_return - return + validate(year, 12, 31) or_return + return {year, 12, 31}, .None } year_range :: proc (#any_int year: i64, allocator := context.allocator) -> (range: []Date) { diff --git a/core/time/datetime/validation.odin b/core/time/datetime/validation.odin index 38176269b..110a7e78e 100644 --- a/core/time/datetime/validation.odin +++ b/core/time/datetime/validation.odin @@ -37,16 +37,20 @@ validate_ordinal :: proc "contextless" (ordinal: Ordinal) -> (err: Error) { } validate_time :: proc "contextless" (time: Time) -> (err: Error) { - if time.hour < 0 || time.hour > 23 { + return validate(time.hour, time.minute, time.second, time.nano) +} + +validate_hour_minute_second :: proc "contextless" (#any_int hour, #any_int minute, #any_int second, #any_int nano: i64) -> (err: Error) { + if hour < 0 || hour > 23 { return .Invalid_Hour } - if time.minute < 0 || time.minute > 59 { + if minute < 0 || minute > 59 { return .Invalid_Minute } - if time.second < 0 || time.second > 59 { + if second < 0 || second > 59 { return .Invalid_Second } - if time.nano < 0 || time.nano > 1e9 { + if nano < 0 || nano > 1e9 { return .Invalid_Nano } return .None @@ -62,6 +66,7 @@ validate :: proc{ validate_date, validate_year_month_day, validate_ordinal, + validate_hour_minute_second, validate_time, validate_datetime, } \ No newline at end of file