diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin new file mode 100644 index 000000000..a2a02838c --- /dev/null +++ b/core/time/datetime/constants.odin @@ -0,0 +1,77 @@ +package datetime + +// Ordinal 1 = Midnight Monday, January 1, 1 A.D. (Gregorian) +// | Midnight Monday, January 3, 1 A.D. (Julian) +Ordinal :: i64 +EPOCH :: Ordinal(1) + +// Minimum and maximum dates and ordinals. Chosen for safe roundtripping. +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: i64, + month: i8, + day: i8, +} + +Time :: struct { + hour: i8, + minute: i8, + second: i8, + nano: i32, +} + +DateTime :: struct { + using date: Date, + using time: Time, +} + +Delta :: struct { + 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 i8 { + January = 1, + February, + March, + April, + May, + June, + July, + August, + September, + October, + November, + December, +} + +Weekday :: enum i8 { + Sunday = 0, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, +} + +@(private) +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 new file mode 100644 index 000000000..e15ced5a5 --- /dev/null +++ b/core/time/datetime/datetime.odin @@ -0,0 +1,272 @@ +/* + 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" (#any_int year, #any_int month, #any_int day: i64) -> (ordinal: Ordinal, err: Error) { + validate(year, month, day) or_return + return unsafe_date_to_ordinal({year, i8(month), i8(day)}), .None +} + +// 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" (#any_int year, #any_int month, #any_int day: i64) -> (date: Date, err: Error) { + 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) { + 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) { + 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 +} + +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 := 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, i64(a.nano) - i64(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: i64) -> (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 := 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 + + 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} + +day_number :: proc "contextless" (date: Date) -> (day_number: i64, 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: 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" (#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 + month_days := MONTH_DAYS + + day = i64(month_days[month]) + if month == 2 && is_leap_year(year) { + day += 1 + } + return +} + +new_year :: proc "contextless" (#any_int year: i64) -> (new_year: Date, err: Error) { + validate(year, 1, 1) or_return + return {year, 1, 1}, .None +} + +year_end :: proc "contextless" (#any_int year: i64) -> (year_end: Date, err: Error) { + validate(year, 12, 31) or_return + return {year, 12, 31}, .None +} + +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 + 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], _ = components_to_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: i64 + + // 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 * i64(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 += i64(date.day) + return +} + +unsafe_ordinal_to_year :: proc "contextless" (ordinal: Ordinal) -> (year: i64, day_ordinal: i64) { + // 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 := 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/core/time/datetime/internal.odin b/core/time/datetime/internal.odin new file mode 100644 index 000000000..45c2b99ab --- /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: i64) -> (res: i64) { + 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: i64) -> (res: i64) { + if a == b { + return x + } + return a + ((x - a) %% (b - a)) +} + +// x mod [1..b] +adjusted_remainder :: proc "contextless" (x, b: i64) -> (res: i64) { + m := x %% b + return b if m == 0 else m +} + +gcd :: proc "contextless" (x, y: i64) -> (res: i64) { + if y == 0 { + return x + } + + m := x %% y + return gcd(y, m) +} + +lcm :: proc "contextless" (x, y: i64) -> (res: i64) { + return x * y / gcd(x, y) +} + +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: 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) + } + return +} + +smallest :: proc "contextless" (k: i64, cond: proc "contextless" (n: i64) -> bool) -> (d: i64) { + k := k + for !cond(k) { + k += 1 + } + return k +} + +biggest :: proc "contextless" (k: i64, cond: proc "contextless" (n: i64) -> bool) -> (d: i64) { + 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..110a7e78e --- /dev/null +++ b/core/time/datetime/validation.odin @@ -0,0 +1,72 @@ +package datetime + +// Validation helpers +is_leap_year :: proc "contextless" (#any_int year: i64) -> (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" (#any_int year, #any_int month, #any_int day: i64) -> (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 > i64(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) { + 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 minute < 0 || minute > 59 { + return .Invalid_Minute + } + if second < 0 || second > 59 { + return .Invalid_Second + } + if nano < 0 || 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_hour_minute_second, + 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..30c255c79 --- /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, leap_second, count := rfc3339_to_components(rfc_datetime) + if count == 0 { + return + } + + 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 { + 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, is_leap: bool, consumed: int) { + moment, offset, count, leap_second, ok := _rfc3339_to_components(rfc_datetime) + if !ok { + return + } + 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) -> (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], 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 + } + + // 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 + 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 + } + return res, utc_offset, count, is_leap, 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..10b71ee0d 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,35 @@ _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 +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 } - _is_leap_year :: proc "contextless" (year: int) -> bool { - return year%4 == 0 && (year%100 != 0 || year%400 == 0) + 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/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/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..ac7137fa1 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:time 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..2cea47680 --- /dev/null +++ b/tests/core/time/test_core_time.odin @@ -0,0 +1,178 @@ +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 { + d, _ := dt.components_to_datetime(year, month, day, 0, 0, 0, 0) + date_component_roundtrip_test(t, d) + } + } + } +} + +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 == 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