aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeroen van Rijn <Kelimion@users.noreply.github.com>2024-10-22 10:18:38 +0200
committerGitHub <noreply@github.com>2024-10-22 10:18:38 +0200
commit00ec862b63cb731928736e7c83a3c823b9aed24b (patch)
tree09381d36aeb502d0e56f7f456c4beb003e525b6c
parent7c1922b0a7ed210fe9ce2c1c70c320fde7858c83 (diff)
parentd8696badb6a40e8d1a8b337e8ad3d89f9e4ab5a3 (diff)
Merge pull request #4335 from colrdavidson/datetime_tz
Add Timezone Support to Odin
-rw-r--r--core/os/os_darwin.odin6
-rw-r--r--core/os/os_freebsd.odin7
-rw-r--r--core/os/os_haiku.odin6
-rw-r--r--core/os/os_linux.odin6
-rw-r--r--core/os/os_netbsd.odin6
-rw-r--r--core/os/os_openbsd.odin6
-rw-r--r--core/sys/windows/icu.odin14
-rw-r--r--core/time/datetime/constants.odin45
-rw-r--r--core/time/datetime/datetime.odin6
-rw-r--r--core/time/time.odin4
-rw-r--r--core/time/timezone/tz_unix.odin89
-rw-r--r--core/time/timezone/tz_windows.odin295
-rw-r--r--core/time/timezone/tzdate.odin339
-rw-r--r--core/time/timezone/tzif.odin652
-rw-r--r--tests/core/time/test_core_time.odin212
15 files changed, 1662 insertions, 31 deletions
diff --git a/core/os/os_darwin.odin b/core/os/os_darwin.odin
index c7e1750d6..616e167a1 100644
--- a/core/os/os_darwin.odin
+++ b/core/os/os_darwin.odin
@@ -1026,7 +1026,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (path: string, err: Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -1041,9 +1041,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
}
defer _unix_free(rawptr(path_ptr))
- path = strings.clone(string(path_ptr))
-
- return path, nil
+ return strings.clone(string(path_ptr), allocator)
}
access :: proc(path: string, mask: int) -> bool {
diff --git a/core/os/os_freebsd.odin b/core/os/os_freebsd.odin
index f617cf973..837e79f4d 100644
--- a/core/os/os_freebsd.odin
+++ b/core/os/os_freebsd.odin
@@ -789,7 +789,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -804,10 +804,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
}
defer _unix_free(rawptr(path_ptr))
-
- path = strings.clone(string(path_ptr))
-
- return path, nil
+ return strings.clone(string(path_ptr), allocator)
}
access :: proc(path: string, mask: int) -> (bool, Error) {
diff --git a/core/os/os_haiku.odin b/core/os/os_haiku.odin
index 0d2c334be..4ad370724 100644
--- a/core/os/os_haiku.odin
+++ b/core/os/os_haiku.odin
@@ -431,7 +431,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -447,9 +447,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
defer _unix_free(path_ptr)
path_cstr := cstring(path_ptr)
- path = strings.clone(string(path_cstr))
-
- return path, nil
+ return strings.clone(string(path_cstr), allocator)
}
access :: proc(path: string, mask: int) -> (bool, Error) {
diff --git a/core/os/os_linux.odin b/core/os/os_linux.odin
index e9039ba20..fe8215135 100644
--- a/core/os/os_linux.odin
+++ b/core/os/os_linux.odin
@@ -917,7 +917,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -932,9 +932,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
}
defer _unix_free(rawptr(path_ptr))
- path = strings.clone(string(path_ptr))
-
- return path, nil
+ return strings.clone(string(path_ptr), allocator)
}
access :: proc(path: string, mask: int) -> (bool, Error) {
diff --git a/core/os/os_netbsd.odin b/core/os/os_netbsd.odin
index 493527803..e3ba760a4 100644
--- a/core/os/os_netbsd.odin
+++ b/core/os/os_netbsd.odin
@@ -844,7 +844,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (path: string, err: Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -859,9 +859,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
}
defer _unix_free(rawptr(path_ptr))
- path = strings.clone(string(path_ptr))
-
- return path, nil
+ return strings.clone(string(path_ptr), allocator)
}
access :: proc(path: string, mask: int) -> (bool, Error) {
diff --git a/core/os/os_openbsd.odin b/core/os/os_openbsd.odin
index 62872d9dc..3c377968c 100644
--- a/core/os/os_openbsd.odin
+++ b/core/os/os_openbsd.odin
@@ -758,7 +758,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) {
}
@(require_results)
-absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
+absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) {
rel := rel
if rel == "" {
rel = "."
@@ -773,9 +773,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) {
}
defer _unix_free(rawptr(path_ptr))
- path = strings.clone(string(path_ptr))
-
- return path, nil
+ return strings.clone(string(path_ptr), allocator)
}
access :: proc(path: string, mask: int) -> (bool, Error) {
diff --git a/core/sys/windows/icu.odin b/core/sys/windows/icu.odin
new file mode 100644
index 000000000..6ed8c9b40
--- /dev/null
+++ b/core/sys/windows/icu.odin
@@ -0,0 +1,14 @@
+#+build windows
+package sys_windows
+
+foreign import "system:icu.lib"
+
+UError :: enum i32 {
+ U_ZERO_ERROR = 0,
+}
+
+@(default_calling_convention="system")
+foreign icu {
+ ucal_getWindowsTimeZoneID :: proc(id: wstring, len: i32, winid: wstring, winidCapacity: i32, status: ^UError) -> i32 ---
+ ucal_getDefaultTimeZone :: proc(result: wstring, cap: i32, status: ^UError) -> i32 ---
+}
diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin
index 5f336ef4a..e24709e49 100644
--- a/core/time/datetime/constants.odin
+++ b/core/time/datetime/constants.odin
@@ -77,12 +77,55 @@ Time :: struct {
nano: i32,
}
+TZ_Record :: struct {
+ time: i64,
+ utc_offset: i64,
+ shortname: string,
+ dst: bool,
+}
+
+TZ_Date_Kind :: enum {
+ No_Leap,
+ Leap,
+ Month_Week_Day,
+}
+
+TZ_Transition_Date :: struct {
+ type: TZ_Date_Kind,
+
+ month: u8,
+ week: u8,
+ day: u16,
+
+ time: i64,
+}
+
+TZ_RRule :: struct {
+ has_dst: bool,
+
+ std_name: string,
+ std_offset: i64,
+ std_date: TZ_Transition_Date,
+
+ dst_name: string,
+ dst_offset: i64,
+ dst_date: TZ_Transition_Date,
+}
+
+TZ_Region :: struct {
+ name: string,
+ records: []TZ_Record,
+ shortnames: []string,
+ rrule: TZ_RRule,
+}
+
/*
A type representing datetime.
*/
DateTime :: struct {
using date: Date,
using time: Time,
+ tz: ^TZ_Region,
}
/*
@@ -130,4 +173,4 @@ Weekday :: enum i8 {
}
@(private)
-MONTH_DAYS :: [?]i8{-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}
diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin
index fc9780e3b..2cd90b0e7 100644
--- a/core/time/datetime/datetime.odin
+++ b/core/time/datetime/datetime.odin
@@ -76,7 +76,7 @@ datetime, an error is returned.
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
+ return {date, time, nil}, .None
}
/*
@@ -88,7 +88,7 @@ object will always have the time equal to `00:00:00.000`.
*/
ordinal_to_datetime :: proc "contextless" (ordinal: Ordinal) -> (datetime: DateTime, err: Error) {
d := ordinal_to_date(ordinal) or_return
- return {Date(d), {}}, .None
+ return {Date(d), {}, nil}, .None
}
/*
@@ -433,4 +433,4 @@ unsafe_ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date)
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/time.odin b/core/time/time.odin
index 98639b36a..b488f951c 100644
--- a/core/time/time.odin
+++ b/core/time/time.odin
@@ -930,7 +930,7 @@ If the datetime represents a time outside of a valid range, `false` is returned
as the second return value. See `Time` for the representable range.
*/
compound_to_time :: proc "contextless" (datetime: dt.DateTime) -> (t: Time, ok: bool) {
- unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}}
+ unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil}
delta, err := dt.sub(datetime, unix_epoch)
if err != .None {
return
@@ -958,7 +958,7 @@ datetime_to_time :: proc{components_to_time, compound_to_time}
Convert time into datetime.
*/
time_to_datetime :: proc "contextless" (t: Time) -> (dt.DateTime, bool) {
- unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}}
+ unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil}
datetime, err := dt.add(unix_epoch, dt.Delta{ nanos = t._nsec })
if err != .None {
diff --git a/core/time/timezone/tz_unix.odin b/core/time/timezone/tz_unix.odin
new file mode 100644
index 000000000..990e78d41
--- /dev/null
+++ b/core/time/timezone/tz_unix.odin
@@ -0,0 +1,89 @@
+#+build darwin, linux, freebsd, openbsd, netbsd
+#+private
+package timezone
+
+import "core:os"
+import "core:strings"
+import "core:path/filepath"
+import "core:time/datetime"
+
+local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) {
+ local_str, ok := os.lookup_env("TZ", allocator)
+ if !ok {
+ orig_localtime_path := "/etc/localtime"
+ path, err := os.absolute_path_from_relative(orig_localtime_path, allocator)
+ if err != nil {
+ // If we can't find /etc/localtime, fallback to UTC
+ if err == .ENOENT {
+ str, err2 := strings.clone("UTC", allocator)
+ if err2 != nil { return }
+ return str, true
+ }
+
+ return
+ }
+ defer delete(path, allocator)
+
+ // FreeBSD makes me sad.
+ // This is a hackaround, because FreeBSD copies rather than softlinks their local timezone file,
+ // *sometimes* and then stores the original name of the timezone in /var/db/zoneinfo instead
+ if path == orig_localtime_path {
+ data := os.read_entire_file("/var/db/zoneinfo", allocator) or_return
+ return strings.trim_right_space(string(data)), true
+ }
+
+ // Looking for tz path (ex fmt: "UTC", "Etc/UTC" or "America/Los_Angeles")
+ path_dir, path_file := filepath.split(path)
+ if path_dir == "" {
+ return
+ }
+ upper_path_dir, upper_path_chunk := filepath.split(path_dir[:len(path_dir)-1])
+ if upper_path_dir == "" {
+ return
+ }
+
+ if strings.contains(upper_path_chunk, "zoneinfo") {
+ region_str, err := strings.clone(path_file, allocator)
+ if err != nil { return }
+ return region_str, true
+ } else {
+ region_str, err := filepath.join({upper_path_chunk, path_file}, allocator = allocator)
+ if err != nil { return }
+ return region_str, true
+ }
+ }
+
+ if local_str == "" {
+ delete(local_str, allocator)
+
+ str, err := strings.clone("UTC", allocator)
+ if err != nil { return }
+ return str, true
+ }
+
+ return local_str, true
+}
+
+_region_load :: proc(_reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) {
+ reg_str := _reg_str
+ if reg_str == "UTC" {
+ return nil, true
+ }
+
+ if reg_str == "local" {
+ local_name := local_tz_name(allocator) or_return
+ if local_name == "UTC" {
+ delete(local_name, allocator)
+ return nil, true
+ }
+
+ reg_str = local_name
+ }
+ defer if _reg_str == "local" { delete(reg_str, allocator) }
+
+ db_path := "/usr/share/zoneinfo"
+ region_path := filepath.join({db_path, reg_str}, allocator)
+ defer delete(region_path, allocator)
+
+ return load_tzif_file(region_path, reg_str, allocator)
+}
diff --git a/core/time/timezone/tz_windows.odin b/core/time/timezone/tz_windows.odin
new file mode 100644
index 000000000..f1604b939
--- /dev/null
+++ b/core/time/timezone/tz_windows.odin
@@ -0,0 +1,295 @@
+#+build windows
+#+private
+package timezone
+
+import "core:strings"
+import "core:sys/windows"
+import "core:time/datetime"
+
+TZ_Abbrev :: struct {
+ std: string,
+ dst: string,
+}
+
+tz_abbrevs := map[string]TZ_Abbrev {
+ "Egypt Standard Time" = {"EET", "EEST"}, // Africa/Cairo
+ "Morocco Standard Time" = {"+00", "+01"}, // Africa/Casablanca
+ "South Africa Standard Time" = {"SAST", "SAST"}, // Africa/Johannesburg
+ "South Sudan Standard Time" = {"CAT", "CAT"}, // Africa/Juba
+ "Sudan Standard Time" = {"CAT", "CAT"}, // Africa/Khartoum
+ "W. Central Africa Standard Time" = {"WAT", "WAT"}, // Africa/Lagos
+ "E. Africa Standard Time" = {"EAT", "EAT"}, // Africa/Nairobi
+ "Sao Tome Standard Time" = {"GMT", "GMT"}, // Africa/Sao_Tome
+ "Libya Standard Time" = {"EET", "EET"}, // Africa/Tripoli
+ "Namibia Standard Time" = {"CAT", "CAT"}, // Africa/Windhoek
+ "Aleutian Standard Time" = {"HST", "HDT"}, // America/Adak
+ "Alaskan Standard Time" = {"AKST", "AKDT"}, // America/Anchorage
+ "Tocantins Standard Time" = {"-03", "-03"}, // America/Araguaina
+ "Paraguay Standard Time" = {"-04", "-03"}, // America/Asuncion
+ "Bahia Standard Time" = {"-03", "-03"}, // America/Bahia
+ "SA Pacific Standard Time" = {"-05", "-05"}, // America/Bogota
+ "Argentina Standard Time" = {"-03", "-03"}, // America/Buenos_Aires
+ "Eastern Standard Time (Mexico)" = {"EST", "EST"}, // America/Cancun
+ "Venezuela Standard Time" = {"-04", "-04"}, // America/Caracas
+ "SA Eastern Standard Time" = {"-03", "-03"}, // America/Cayenne
+ "Central Standard Time" = {"CST", "CDT"}, // America/Chicago
+ "Central Brazilian Standard Time" = {"-04", "-04"}, // America/Cuiaba
+ "Mountain Standard Time" = {"MST", "MDT"}, // America/Denver
+ "Greenland Standard Time" = {"-03", "-02"}, // America/Godthab
+ "Turks And Caicos Standard Time" = {"EST", "EDT"}, // America/Grand_Turk
+ "Central America Standard Time" = {"CST", "CST"}, // America/Guatemala
+ "Atlantic Standard Time" = {"AST", "ADT"}, // America/Halifax
+ "Cuba Standard Time" = {"CST", "CDT"}, // America/Havana
+ "US Eastern Standard Time" = {"EST", "EDT"}, // America/Indianapolis
+ "SA Western Standard Time" = {"-04", "-04"}, // America/La_Paz
+ "Pacific Standard Time" = {"PST", "PDT"}, // America/Los_Angeles
+ "Mountain Standard Time (Mexico)" = {"MST", "MST"}, // America/Mazatlan
+ "Central Standard Time (Mexico)" = {"CST", "CST"}, // America/Mexico_City
+ "Saint Pierre Standard Time" = {"-03", "-02"}, // America/Miquelon
+ "Montevideo Standard Time" = {"-03", "-03"}, // America/Montevideo
+ "Eastern Standard Time" = {"EST", "EDT"}, // America/New_York
+ "US Mountain Standard Time" = {"MST", "MST"}, // America/Phoenix
+ "Haiti Standard Time" = {"EST", "EDT"}, // America/Port-au-Prince
+ "Magallanes Standard Time" = {"-03", "-03"}, // America/Punta_Arenas
+ "Canada Central Standard Time" = {"CST", "CST"}, // America/Regina
+ "Pacific SA Standard Time" = {"-04", "-03"}, // America/Santiago
+ "E. South America Standard Time" = {"-03", "-03"}, // America/Sao_Paulo
+ "Newfoundland Standard Time" = {"NST", "NDT"}, // America/St_Johns
+ "Pacific Standard Time (Mexico)" = {"PST", "PDT"}, // America/Tijuana
+ "Yukon Standard Time" = {"MST", "MST"}, // America/Whitehorse
+ "Central Asia Standard Time" = {"+06", "+06"}, // Asia/Almaty
+ "Jordan Standard Time" = {"+03", "+03"}, // Asia/Amman
+ "Arabic Standard Time" = {"+03", "+03"}, // Asia/Baghdad
+ "Azerbaijan Standard Time" = {"+04", "+04"}, // Asia/Baku
+ "SE Asia Standard Time" = {"+07", "+07"}, // Asia/Bangkok
+ "Altai Standard Time" = {"+07", "+07"}, // Asia/Barnaul
+ "Middle East Standard Time" = {"EET", "EEST"}, // Asia/Beirut
+ "India Standard Time" = {"IST", "IST"}, // Asia/Calcutta
+ "Transbaikal Standard Time" = {"+09", "+09"}, // Asia/Chita
+ "Sri Lanka Standard Time" = {"+0530", "+0530"}, // Asia/Colombo
+ "Syria Standard Time" = {"+03", "+03"}, // Asia/Damascus
+ "Bangladesh Standard Time" = {"+06", "+06"}, // Asia/Dhaka
+ "Arabian Standard Time" = {"+04", "+04"}, // Asia/Dubai
+ "West Bank Standard Time" = {"EET", "EEST"}, // Asia/Hebron
+ "W. Mongolia Standard Time" = {"+07", "+07"}, // Asia/Hovd
+ "North Asia East Standard Time" = {"+08", "+08"}, // Asia/Irkutsk
+ "Israel Standard Time" = {"IST", "IDT"}, // Asia/Jerusalem
+ "Afghanistan Standard Time" = {"+0430", "+0430"}, // Asia/Kabul
+ "Russia Time Zone 11" = {"+12", "+12"}, // Asia/Kamchatka
+ "Pakistan Standard Time" = {"PKT", "PKT"}, // Asia/Karachi
+ "Nepal Standard Time" = {"+0545", "+0545"}, // Asia/Katmandu
+ "North Asia Standard Time" = {"+07", "+07"}, // Asia/Krasnoyarsk
+ "Magadan Standard Time" = {"+11", "+11"}, // Asia/Magadan
+ "N. Central Asia Standard Time" = {"+07", "+07"}, // Asia/Novosibirsk
+ "Omsk Standard Time" = {"+06", "+06"}, // Asia/Omsk
+ "North Korea Standard Time" = {"KST", "KST"}, // Asia/Pyongyang
+ "Qyzylorda Standard Time" = {"+05", "+05"}, // Asia/Qyzylorda
+ "Myanmar Standard Time" = {"+0630", "+0630"}, // Asia/Rangoon
+ "Arab Standard Time" = {"+03", "+03"}, // Asia/Riyadh
+ "Sakhalin Standard Time" = {"+11", "+11"}, // Asia/Sakhalin
+ "Korea Standard Time" = {"KST", "KST"}, // Asia/Seoul
+ "China Standard Time" = {"CST", "CST"}, // Asia/Shanghai
+ "Singapore Standard Time" = {"+08", "+08"}, // Asia/Singapore
+ "Russia Time Zone 10" = {"+11", "+11"}, // Asia/Srednekolymsk
+ "Taipei Standard Time" = {"CST", "CST"}, // Asia/Taipei
+ "West Asia Standard Time" = {"+05", "+05"}, // Asia/Tashkent
+ "Georgian Standard Time" = {"+04", "+04"}, // Asia/Tbilisi
+ "Iran Standard Time" = {"+0330", "+0330"}, // Asia/Tehran
+ "Tokyo Standard Time" = {"JST", "JST"}, // Asia/Tokyo
+ "Tomsk Standard Time" = {"+07", "+07"}, // Asia/Tomsk
+ "Ulaanbaatar Standard Time" = {"+08", "+08"}, // Asia/Ulaanbaatar
+ "Vladivostok Standard Time" = {"+10", "+10"}, // Asia/Vladivostok
+ "Yakutsk Standard Time" = {"+09", "+09"}, // Asia/Yakutsk
+ "Ekaterinburg Standard Time" = {"+05", "+05"}, // Asia/Yekaterinburg
+ "Caucasus Standard Time" = {"+04", "+04"}, // Asia/Yerevan
+ "Azores Standard Time" = {"-01", "+00"}, // Atlantic/Azores
+ "Cape Verde Standard Time" = {"-01", "-01"}, // Atlantic/Cape_Verde
+ "Greenwich Standard Time" = {"GMT", "GMT"}, // Atlantic/Reykjavik
+ "Cen. Australia Standard Time" = {"ACST", "ACDT"}, // Australia/Adelaide
+ "E. Australia Standard Time" = {"AEST", "AEST"}, // Australia/Brisbane
+ "AUS Central Standard Time" = {"ACST", "ACST"}, // Australia/Darwin
+ "Aus Central W. Standard Time" = {"+0845", "+0845"}, // Australia/Eucla
+ "Tasmania Standard Time" = {"AEST", "AEDT"}, // Australia/Hobart
+ "Lord Howe Standard Time" = {"+1030", "+11"}, // Australia/Lord_Howe
+ "W. Australia Standard Time" = {"AWST", "AWST"}, // Australia/Perth
+ "AUS Eastern Standard Time" = {"AEST", "AEDT"}, // Australia/Sydney
+ "UTC-11" = {"-11", "-11"}, // Etc/GMT+11
+ "Dateline Standard Time" = {"-12", "-12"}, // Etc/GMT+12
+ "UTC-02" = {"-02", "-02"}, // Etc/GMT+2
+ "UTC-08" = {"-08", "-08"}, // Etc/GMT+8
+ "UTC-09" = {"-09", "-09"}, // Etc/GMT+9
+ "UTC+12" = {"+12", "+12"}, // Etc/GMT-12
+ "UTC+13" = {"+13", "+13"}, // Etc/GMT-13
+ "UTC" = {"UTC", "UTC"}, // Etc/UTC
+ "Astrakhan Standard Time" = {"+04", "+04"}, // Europe/Astrakhan
+ "W. Europe Standard Time" = {"CET", "CEST"}, // Europe/Berlin
+ "GTB Standard Time" = {"EET", "EEST"}, // Europe/Bucharest
+ "Central Europe Standard Time" = {"CET", "CEST"}, // Europe/Budapest
+ "E. Europe Standard Time" = {"EET", "EEST"}, // Europe/Chisinau
+ "Turkey Standard Time" = {"+03", "+03"}, // Europe/Istanbul
+ "Kaliningrad Standard Time" = {"EET", "EET"}, // Europe/Kaliningrad
+ "FLE Standard Time" = {"EET", "EEST"}, // Europe/Kiev
+ "GMT Standard Time" = {"GMT", "BST"}, // Europe/London
+ "Belarus Standard Time" = {"+03", "+03"}, // Europe/Minsk
+ "Russian Standard Time" = {"MSK", "MSK"}, // Europe/Moscow
+ "Romance Standard Time" = {"CET", "CEST"}, // Europe/Paris
+ "Russia Time Zone 3" = {"+04", "+04"}, // Europe/Samara
+ "Saratov Standard Time" = {"+04", "+04"}, // Europe/Saratov
+ "Volgograd Standard Time" = {"MSK", "MSK"}, // Europe/Volgograd
+ "Central European Standard Time" = {"CET", "CEST"}, // Europe/Warsaw
+ "Mauritius Standard Time" = {"+04", "+04"}, // Indian/Mauritius
+ "Samoa Standard Time" = {"+13", "+13"}, // Pacific/Apia
+ "New Zealand Standard Time" = {"NZST", "NZDT"}, // Pacific/Auckland
+ "Bougainville Standard Time" = {"+11", "+11"}, // Pacific/Bougainville
+ "Chatham Islands Standard Time" = {"+1245", "+1345"}, // Pacific/Chatham
+ "Easter Island Standard Time" = {"-06", "-05"}, // Pacific/Easter
+ "Fiji Standard Time" = {"+12", "+12"}, // Pacific/Fiji
+ "Central Pacific Standard Time" = {"+11", "+11"}, // Pacific/Guadalcanal
+ "Hawaiian Standard Time" = {"HST", "HST"}, // Pacific/Honolulu
+ "Line Islands Standard Time" = {"+14", "+14"}, // Pacific/Kiritimati
+ "Marquesas Standard Time" = {"-0930", "-0930"}, // Pacific/Marquesas
+ "Norfolk Standard Time" = {"+11", "+12"}, // Pacific/Norfolk
+ "West Pacific Standard Time" = {"+10", "+10"}, // Pacific/Port_Moresby
+ "Tonga Standard Time" = {"+13", "+13"}, // Pacific/Tongatapu
+}
+
+iana_to_windows_tz :: proc(iana_name: string, allocator := context.allocator) -> (name: string, success: bool) {
+ wintz_name_buffer: [128]u16
+ status: windows.UError
+
+ iana_name_wstr := windows.utf8_to_wstring(iana_name, allocator)
+ defer free(iana_name_wstr, allocator)
+
+ wintz_name_len := windows.ucal_getWindowsTimeZoneID(iana_name_wstr, -1, raw_data(wintz_name_buffer[:]), len(wintz_name_buffer), &status)
+ if status != .U_ZERO_ERROR {
+ return
+ }
+
+ wintz_name, err := windows.utf16_to_utf8(wintz_name_buffer[:wintz_name_len], allocator)
+ if err != nil {
+ return
+ }
+
+ return wintz_name, true
+}
+
+local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) {
+ iana_name_buffer: [128]u16
+ status: windows.UError
+
+ zone_str_len := windows.ucal_getDefaultTimeZone(raw_data(iana_name_buffer[:]), len(iana_name_buffer), &status)
+ if status != .U_ZERO_ERROR {
+ return
+ }
+
+ iana_name, err := windows.utf16_to_utf8(iana_name_buffer[:zone_str_len], allocator)
+ if err != nil {
+ return
+ }
+
+ return iana_name, true
+}
+
+REG_TZI_FORMAT :: struct #packed {
+ bias: windows.LONG,
+ std_bias: windows.LONG,
+ dst_bias: windows.LONG,
+ std_date: windows.SYSTEMTIME,
+ dst_date: windows.SYSTEMTIME,
+}
+
+generate_rrule_from_tzi :: proc(tzi: ^REG_TZI_FORMAT, abbrevs: TZ_Abbrev, allocator := context.allocator) -> (rrule: datetime.TZ_RRule, ok: bool) {
+ std_name, err := strings.clone(abbrevs.std, allocator)
+ if err != nil { return }
+ defer if err != nil { delete(std_name, allocator) }
+
+ dst_name: string
+ dst_name, err = strings.clone(abbrevs.dst, allocator)
+ if err != nil { return }
+ defer if err != nil { delete(dst_name, allocator) }
+
+ return datetime.TZ_RRule{
+ has_dst = true,
+
+ std_name = std_name,
+ std_offset = -(i64(tzi.bias) + i64(tzi.std_bias)) * 60,
+ dst_date = datetime.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = u8(tzi.std_date.month),
+ week = u8(tzi.std_date.day),
+ day = tzi.std_date.day_of_week,
+ time = (i64(tzi.std_date.hour) * 60 * 60) + (i64(tzi.std_date.minute) * 60) + i64(tzi.std_date.second),
+ },
+
+ dst_name = dst_name,
+ dst_offset = -(i64(tzi.bias) + i64(tzi.dst_bias)) * 60,
+ std_date = datetime.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = u8(tzi.dst_date.month),
+ week = u8(tzi.dst_date.day),
+ day = tzi.dst_date.day_of_week,
+ time = (i64(tzi.dst_date.hour) * 60 * 60) + (i64(tzi.dst_date.minute) * 60) + i64(tzi.dst_date.second),
+ },
+ }, true
+}
+
+_region_load :: proc(reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) {
+ wintz_name: string
+ iana_name: string
+
+ if reg_str == "local" {
+ ok := false
+
+ iana_name = local_tz_name(allocator) or_return
+ wintz_name, ok = iana_to_windows_tz(iana_name, allocator)
+ if !ok {
+ delete(iana_name, allocator)
+ return
+ }
+ } else {
+ wintz_name = iana_to_windows_tz(reg_str, allocator) or_return
+ iana_name = strings.clone(reg_str, allocator)
+ }
+ defer delete(wintz_name, allocator)
+ defer delete(iana_name, allocator)
+
+ abbrevs := tz_abbrevs[wintz_name] or_return
+ if abbrevs.std == "UTC" && abbrevs.dst == abbrevs.std {
+ return nil, true
+ }
+
+ key_base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones`
+ tz_key := strings.join({key_base, wintz_name}, "\\", allocator = allocator)
+ defer delete(tz_key, allocator)
+
+ tz_key_wstr := windows.utf8_to_wstring(tz_key, allocator)
+ defer free(tz_key_wstr, allocator)
+
+ key: windows.HKEY
+ res := windows.RegOpenKeyExW(windows.HKEY_LOCAL_MACHINE, tz_key_wstr, 0, windows.KEY_READ, &key)
+ if res != 0 { return }
+ defer windows.RegCloseKey(key)
+
+ tzi: REG_TZI_FORMAT
+ size := u32(size_of(REG_TZI_FORMAT))
+
+ res = windows.RegGetValueW(key, nil, windows.L("TZI"), windows.RRF_RT_ANY, nil, &tzi, &size)
+ if res != 0 {
+ return
+ }
+
+ rrule := generate_rrule_from_tzi(&tzi, abbrevs, allocator) or_return
+
+ region_name, err := strings.clone(iana_name, allocator)
+ if err != nil { return }
+ defer if err != nil { delete(region_name, allocator) }
+
+ region: ^datetime.TZ_Region
+ region, err = new_clone(datetime.TZ_Region{
+ name = region_name,
+ rrule = rrule,
+ }, allocator)
+ if err != nil { return }
+
+ return region, true
+}
diff --git a/core/time/timezone/tzdate.odin b/core/time/timezone/tzdate.odin
new file mode 100644
index 000000000..8f83d1bf4
--- /dev/null
+++ b/core/time/timezone/tzdate.odin
@@ -0,0 +1,339 @@
+package timezone
+
+import "core:fmt"
+import "core:slice"
+import "core:time"
+import "core:time/datetime"
+
+region_load :: proc(reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) {
+ return _region_load(reg, allocator)
+}
+
+region_load_from_file :: proc(file_path, reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) {
+ return load_tzif_file(file_path, reg, allocator)
+}
+
+region_load_from_buffer :: proc(buffer: []u8, reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) {
+ return parse_tzif(buffer, reg, allocator)
+}
+
+rrule_destroy :: proc(rrule: datetime.TZ_RRule, allocator := context.allocator) {
+ delete(rrule.std_name, allocator)
+ delete(rrule.dst_name, allocator)
+}
+
+region_destroy :: proc(region: ^datetime.TZ_Region, allocator := context.allocator) {
+ if region == nil {
+ return
+ }
+
+ for name in region.shortnames {
+ delete(name, allocator)
+ }
+ delete(region.shortnames, allocator)
+ delete(region.records, allocator)
+ delete(region.name, allocator)
+ rrule_destroy(region.rrule, allocator)
+ free(region, allocator)
+}
+
+
+@private
+region_get_nearest :: proc(region: ^datetime.TZ_Region, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) {
+ if len(region.records) == 0 {
+ return process_rrule(region.rrule, tm)
+ }
+
+ n := len(region.records)
+ left, right := 0, n
+
+ tm_sec := time.to_unix_seconds(tm)
+ last_time := region.records[len(region.records)-1].time
+ if tm_sec > last_time {
+ return process_rrule(region.rrule, tm)
+ }
+
+ for left < right {
+ mid := int(uint(left+right) >> 1)
+ if region.records[mid].time < tm_sec {
+ left = mid + 1
+ } else {
+ right = mid
+ }
+ }
+
+ idx := max(0, left-1)
+ return region.records[idx], true
+}
+
+@private
+month_to_seconds :: proc(month: int, is_leap: bool) -> i64 {
+ month_seconds := []i64{
+ 0, 31 * 86_400, 59 * 86_400, 90 * 86_400,
+ 120 * 86_400, 151 * 86_400, 181 * 86_400, 212 * 86_400,
+ 243 * 86_400, 273 * 86_400, 304 * 86_400, 334 * 86_400,
+ }
+
+ t := month_seconds[month]
+ if is_leap && month >= 2 {
+ t += 86_400
+ }
+ return t
+}
+
+@private
+trans_date_to_seconds :: proc(year: i64, td: datetime.TZ_Transition_Date) -> (secs: i64, ok: bool) {
+ is_leap := datetime.is_leap_year(year)
+ DAY_SEC :: 86_400
+
+ year_start := datetime.DateTime{{year, 1, 1}, {0, 0, 0, 0}, nil}
+ year_start_time := time.datetime_to_time(year_start) or_return
+
+ t := i64(time.to_unix_seconds(year_start_time))
+
+ switch td.type {
+ case .Month_Week_Day:
+ t += month_to_seconds(int(td.month) - 1, is_leap)
+
+ weekday := ((t + (4 * DAY_SEC)) %% (7 * DAY_SEC)) / DAY_SEC
+ days := i64(td.day) - weekday
+ if days < 0 { days += 7 }
+
+ month_daycount, err := datetime.last_day_of_month(year, td.month)
+ if err != nil { return }
+
+ week := td.week
+ if week == 5 && days + 28 >= i64(month_daycount) {
+ week = 4
+ }
+
+ t += DAY_SEC * (days + (7 * i64(week - 1)))
+ t += td.time
+
+ return t, true
+
+ // Both of these should result in 0 -> 365 days (in seconds)
+ case .No_Leap:
+ day := i64(td.day)
+
+ // if before Feb 29th || not a leap year
+ if day < 60 || !is_leap {
+ day -= 1
+ }
+ t += DAY_SEC * day
+
+ return t, true
+
+ case .Leap:
+ t += DAY_SEC * i64(td.day)
+
+ return t, true
+
+ case:
+ return
+ }
+
+ return
+}
+
+@private
+process_rrule :: proc(rrule: datetime.TZ_RRule, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) {
+ if !rrule.has_dst {
+ return datetime.TZ_Record{
+ time = time.to_unix_seconds(tm),
+ utc_offset = rrule.std_offset,
+ shortname = rrule.std_name,
+ dst = false,
+ }, true
+ }
+
+ y, _, _ := time.date(tm)
+ std_secs := trans_date_to_seconds(i64(y), rrule.std_date) or_return
+ dst_secs := trans_date_to_seconds(i64(y), rrule.dst_date) or_return
+
+ records := []datetime.TZ_Record{
+ {
+ time = std_secs,
+ utc_offset = rrule.std_offset,
+ shortname = rrule.std_name,
+ dst = false,
+ },
+ {
+ time = dst_secs,
+ utc_offset = rrule.dst_offset,
+ shortname = rrule.dst_name,
+ dst = true,
+ },
+ }
+ record_sort_proc :: proc(i, j: datetime.TZ_Record) -> bool {
+ return i.time > j.time
+ }
+ slice.sort_by(records, record_sort_proc)
+
+ tm_sec := time.to_unix_seconds(tm)
+ for record in records {
+ if tm_sec < record.time {
+ return record, true
+ }
+ }
+
+ return records[len(records)-1], true
+}
+
+datetime_to_utc :: proc(dt: datetime.DateTime) -> (out: datetime.DateTime, success: bool) #optional_ok {
+ if dt.tz == nil {
+ return dt, true
+ }
+
+ tm := time.datetime_to_time(dt) or_return
+ record := region_get_nearest(dt.tz, tm) or_return
+
+ secs := time.time_to_unix(tm)
+ adj_time := time.unix(secs - record.utc_offset, 0)
+ adj_dt := time.time_to_datetime(adj_time) or_return
+ return adj_dt, true
+}
+
+/*
+Converts a datetime on one timezone to another timezone
+
+Inputs:
+- dt: The input datetime
+- tz: The timezone to convert to
+
+NOTE: tz will be referenced in the result datetime, so it must stay alive/allocated as long as it is used
+Returns:
+- out: The converted datetime
+- success: `false` if the datetime was invalid
+*/
+datetime_to_tz :: proc(dt: datetime.DateTime, tz: ^datetime.TZ_Region) -> (out: datetime.DateTime, success: bool) #optional_ok {
+ dt := dt
+ if dt.tz == tz {
+ return dt, true
+ }
+ if dt.tz != nil {
+ dt = datetime_to_utc(dt)
+ }
+ if tz == nil {
+ return dt, true
+ }
+
+ tm := time.datetime_to_time(dt) or_return
+ record := region_get_nearest(tz, tm) or_return
+
+ secs := time.time_to_unix(tm)
+ adj_time := time.unix(secs + record.utc_offset, 0)
+ adj_dt := time.time_to_datetime(adj_time) or_return
+ adj_dt.tz = tz
+
+ return adj_dt, true
+}
+
+/*
+Gets the timezone abbreviation/shortname for a given date.
+(ex: "PDT")
+
+Inputs:
+- dt: The datetime containing the date, time, and timezone pointer for the lookup
+
+NOTE: The lifetime of name matches the timezone it was pulled from.
+Returns:
+- name: The timezone abbreviation
+- success: returns `false` if the passed datetime is invalid
+*/
+shortname :: proc(dt: datetime.DateTime) -> (name: string, success: bool) #optional_ok {
+ tm := time.datetime_to_time(dt) or_return
+ if dt.tz == nil { return "UTC", true }
+
+ record := region_get_nearest(dt.tz, tm) or_return
+ return record.shortname, true
+}
+
+/*
+Gets the timezone abbreviation/shortname for a given date.
+(ex: "PDT")
+
+WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record.
+
+Inputs:
+- dt: The input datetime
+
+NOTE: The lifetime of name matches the timezone it was pulled from.
+Returns:
+- name: The timezone abbreviation
+*/
+shortname_unsafe :: proc(dt: datetime.DateTime) -> string {
+ if dt.tz == nil { return "UTC" }
+
+ tm, _ := time.datetime_to_time(dt)
+ record, _ := region_get_nearest(dt.tz, tm)
+ return record.shortname
+}
+
+/*
+Checks DST for a given date.
+
+Inputs:
+- dt: The input datetime
+
+Returns:
+- is_dst: returns `true` if dt is in daylight savings time, `false` if not
+- success: returns `false` if the passed datetime is invalid
+*/
+dst :: proc(dt: datetime.DateTime) -> (is_dst: bool, success: bool) #optional_ok {
+ tm := time.datetime_to_time(dt) or_return
+ if dt.tz == nil { return false, true }
+
+ record := region_get_nearest(dt.tz, tm) or_return
+ return record.dst, true
+}
+
+/*
+Checks DST for a given date.
+
+WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record.
+
+Inputs:
+- dt: The input datetime
+
+Returns:
+- is_dst: returns `true` if dt is in daylight savings time, `false` if not
+*/
+dst_unsafe :: proc(dt: datetime.DateTime) -> bool {
+ if dt.tz == nil { return false }
+
+ tm, _ := time.datetime_to_time(dt)
+ record, _ := region_get_nearest(dt.tz, tm)
+ return record.dst
+}
+
+datetime_to_str :: proc(dt: datetime.DateTime, allocator := context.allocator) -> string {
+ if dt.tz == nil {
+ _, ok := time.datetime_to_time(dt)
+ if !ok {
+ return ""
+ }
+
+ return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d UTC", dt.month, dt.day, dt.year, dt.hour, dt.minute, dt.second, allocator = allocator)
+
+ } else {
+ tm, ok := time.datetime_to_time(dt)
+ if !ok {
+ return ""
+ }
+
+ record, ok2 := region_get_nearest(dt.tz, tm)
+ if !ok2 {
+ return ""
+ }
+
+ hour := dt.hour
+ am_pm_str := "AM"
+ if hour > 12 {
+ am_pm_str = "PM"
+ hour -= 12
+ }
+
+ return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d %s %s", dt.month, dt.day, dt.year, hour, dt.minute, dt.second, am_pm_str, record.shortname, allocator = allocator)
+ }
+}
diff --git a/core/time/timezone/tzif.odin b/core/time/timezone/tzif.odin
new file mode 100644
index 000000000..609cbda73
--- /dev/null
+++ b/core/time/timezone/tzif.odin
@@ -0,0 +1,652 @@
+package timezone
+
+import "base:intrinsics"
+
+import "core:slice"
+import "core:strings"
+import "core:os"
+import "core:strconv"
+import "core:time/datetime"
+
+// Implementing RFC8536 [https://datatracker.ietf.org/doc/html/rfc8536]
+
+TZIF_MAGIC :: u32be(0x545A6966) // 'TZif'
+TZif_Version :: enum u8 {
+ V1 = 0,
+ V2 = '2',
+ V3 = '3',
+ V4 = '4',
+}
+BIG_BANG_ISH :: -0x800000000000000
+
+TZif_Header :: struct #packed {
+ magic: u32be,
+ version: TZif_Version,
+ reserved: [15]u8,
+ isutcnt: u32be,
+ isstdcnt: u32be,
+ leapcnt: u32be,
+ timecnt: u32be,
+ typecnt: u32be,
+ charcnt: u32be,
+}
+
+Sun_Shift :: enum u8 {
+ Standard = 0,
+ DST = 1,
+}
+
+Local_Time_Type :: struct #packed {
+ utoff: i32be,
+ dst: Sun_Shift,
+ idx: u8,
+}
+
+Leapsecond_Record :: struct #packed {
+ occur: i64be,
+ corr: i32be,
+}
+
+@private
+tzif_data_block_size :: proc(hdr: ^TZif_Header, version: TZif_Version) -> (block_size: int, ok: bool) {
+ time_size : int
+
+ if version == .V1 {
+ time_size = 4
+ } else if version == .V2 || version == .V3 || version == .V4 {
+ time_size = 8
+ } else {
+ return
+ }
+
+ return (int(hdr.timecnt) * time_size) +
+ int(hdr.timecnt) +
+ int(hdr.typecnt * size_of(Local_Time_Type)) +
+ int(hdr.charcnt) +
+ (int(hdr.leapcnt) * (time_size + 4)) +
+ int(hdr.isstdcnt) +
+ int(hdr.isutcnt), true
+}
+
+
+load_tzif_file :: proc(filename: string, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) {
+ tzif_data := os.read_entire_file_from_filename(filename, allocator) or_return
+ defer delete(tzif_data, allocator)
+ return parse_tzif(tzif_data, region_name, allocator)
+}
+
+@private
+is_alphabetic :: proc(ch: u8) -> bool {
+ // ('A' -> 'Z') || ('a' -> 'z')
+ return (ch > 0x40 && ch < 0x5B) || (ch > 0x60 && ch < 0x7B)
+}
+
+@private
+is_numeric :: proc(ch: u8) -> bool {
+ // ('0' -> '9')
+ return (ch > 0x2F && ch < 0x3A)
+}
+
+@private
+is_alphanumeric :: proc(ch: u8) -> bool {
+ return is_alphabetic(ch) || is_numeric(ch)
+}
+
+@private
+is_valid_quoted_char :: proc(ch: u8) -> bool {
+ return is_alphabetic(ch) || is_numeric(ch) || ch == '+' || ch == '-'
+}
+
+@private
+parse_posix_tz_shortname :: proc(str: string) -> (out: string, idx: int, ok: bool) {
+ was_quoted := false
+ quoted := false
+ i := 0
+
+ for ; i < len(str); i += 1 {
+ ch := str[i]
+
+ if !quoted && ch == '<' {
+ quoted = true
+ was_quoted = true
+ continue
+ }
+
+ if quoted && ch == '>' {
+ quoted = false
+ break
+ }
+
+ if !is_valid_quoted_char(ch) && ch != ',' {
+ return
+ }
+
+ if !quoted && !is_alphabetic(ch) {
+ break
+ }
+ }
+
+ // If we didn't see the trailing quote
+ if was_quoted && quoted {
+ return
+ }
+
+ out_str: string
+ end_idx := i
+ if was_quoted {
+ end_idx += 1
+ out_str = str[1:i]
+ } else {
+ out_str = str[:i]
+ }
+
+ return out_str, end_idx, true
+}
+
+@private
+parse_posix_tz_offset :: proc(str: string) -> (out_sec: i64, idx: int, ok: bool) {
+ str := str
+
+ sign : i64 = 1
+ start_idx := 0
+ i := 0
+ if str[i] == '+' {
+ i += 1
+ sign = 1
+ start_idx = 1
+ } else if str[i] == '-' {
+ i += 1
+ sign = -1
+ start_idx = 1
+ }
+
+ got_more_time := false
+ for ; i < len(str); i += 1 {
+ if is_numeric(str[i]) {
+ continue
+ }
+
+ if str[i] == ':' {
+ got_more_time = true
+ break
+ }
+
+ break
+ }
+
+ ret_sec : i64 = 0
+ hours := strconv.parse_int(str[start_idx:i], 10) or_return
+ if hours > 167 || hours < -167 {
+ return
+ }
+ ret_sec += i64(hours) * (60 * 60)
+ if !got_more_time {
+ return ret_sec * sign, i, true
+ }
+
+ i += 1
+ start_idx = i
+
+ got_more_time = false
+ for ; i < len(str); i += 1 {
+ if is_numeric(str[i]) {
+ continue
+ }
+
+ if str[i] == ':' {
+ got_more_time = true
+ break
+ }
+
+ break
+ }
+
+ mins_str := str[start_idx:i]
+ if len(mins_str) != 2 {
+ return
+ }
+
+ mins := strconv.parse_int(mins_str, 10) or_return
+ if mins > 59 || mins < 0 {
+ return
+ }
+ ret_sec += i64(mins) * 60
+ if !got_more_time {
+ return ret_sec * sign, i, true
+ }
+
+ i += 1
+ start_idx = i
+
+ for ; i < len(str); i += 1 {
+ if !is_numeric(str[i]) {
+ break
+ }
+ }
+ secs_str := str[start_idx:i]
+ if len(secs_str) != 2 {
+ return
+ }
+
+ secs := strconv.parse_int(secs_str, 10) or_return
+ if secs > 59 || secs < 0 {
+ return
+ }
+ ret_sec += i64(secs)
+ return ret_sec * sign, i, true
+}
+
+@private
+skim_digits :: proc(str: string) -> (out: string, idx: int, ok: bool) {
+ i := 0
+ for ; i < len(str); i += 1 {
+ ch := str[i]
+ if ch == '.' || ch == '/' || ch == ',' {
+ break
+ }
+
+ if !is_numeric(ch) {
+ return
+ }
+ }
+
+ return str[:i], i, true
+}
+
+TWO_AM :: 2 * 60 * 60
+parse_posix_rrule :: proc(str: string) -> (out: datetime.TZ_Transition_Date, idx: int, ok: bool) {
+ str := str
+ if len(str) < 2 { return }
+
+ i := 0
+ // No leap
+ if str[i] == 'J' {
+ i += 1
+
+ day_str, off := skim_digits(str[i:]) or_return
+ i += off
+
+ day := strconv.parse_int(day_str, 10) or_return
+ if day < 1 || day > 365 { return }
+
+ offset : i64 = TWO_AM
+ if len(str) != i && str[i] == '/' {
+ i += 1
+
+ offset, off = parse_posix_tz_offset(str[i:]) or_return
+ i += off
+ }
+
+ if len(str) != i && str[i] == ',' {
+ i += 1
+ }
+
+ return datetime.TZ_Transition_Date{
+ type = .No_Leap,
+ day = u16(day),
+ time = offset,
+ }, i, true
+
+ // Leap
+ } else if is_numeric(str[i]) {
+ day_str, off := skim_digits(str[i:]) or_return
+ i += off
+
+ day := strconv.parse_int(day_str, 10) or_return
+ if day < 0 || day > 365 { return }
+
+ offset : i64 = TWO_AM
+ if len(str) != i && str[i] == '/' {
+ i += 1
+
+ offset, off = parse_posix_tz_offset(str[i:]) or_return
+ i += off
+ }
+
+ if len(str) != i && str[i] == ',' {
+ i += 1
+ }
+
+ return datetime.TZ_Transition_Date{
+ type = .Leap,
+ day = u16(day),
+ time = offset,
+ }, i, true
+
+ } else if str[i] == 'M' {
+ i += 1
+
+ month_str, week_str, day_str: string
+ off := 0
+
+ month_str, off = skim_digits(str[i:]) or_return
+ i += off + 1
+
+ week_str, off = skim_digits(str[i:]) or_return
+ i += off + 1
+
+ day_str, off = skim_digits(str[i:]) or_return
+ i += off
+
+ month := strconv.parse_int(month_str, 10) or_return
+ if month < 1 || month > 12 { return }
+
+ week := strconv.parse_int(week_str, 10) or_return
+ if week < 1 || week > 5 { return }
+
+ day := strconv.parse_int(day_str, 10) or_return
+ if day < 0 || day > 6 { return }
+
+ offset : i64 = TWO_AM
+ if len(str) != i && str[i] == '/' {
+ i += 1
+
+ offset, off = parse_posix_tz_offset(str[i:]) or_return
+ i += off
+ }
+
+ if len(str) != i && str[i] == ',' {
+ i += 1
+ }
+
+ return datetime.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = u8(month),
+ week = u8(week),
+ day = u16(day),
+ time = offset,
+ }, i, true
+ }
+
+ return
+}
+
+parse_posix_tz :: proc(posix_tz: string, allocator := context.allocator) -> (out: datetime.TZ_RRule, ok: bool) {
+ // TZ string contain at least 3 characters for the STD name, and 1 for the offset
+ if len(posix_tz) < 4 {
+ return
+ }
+
+ str := posix_tz
+
+ std_name, idx := parse_posix_tz_shortname(str) or_return
+ str = str[idx:]
+
+ std_offset, idx2 := parse_posix_tz_offset(str) or_return
+ std_offset *= -1
+ str = str[idx2:]
+
+ std_name_str, err := strings.clone(std_name, allocator)
+ if err != nil { return }
+ defer if !ok { delete(std_name_str, allocator) }
+
+ if len(str) == 0 {
+ return datetime.TZ_RRule{
+ has_dst = false,
+ std_name = std_name_str,
+ std_offset = std_offset,
+ std_date = datetime.TZ_Transition_Date{
+ type = .Leap,
+ day = 0,
+ time = TWO_AM,
+ },
+ }, true
+ }
+
+ dst_name: string
+ dst_offset := std_offset + (1 * 60 * 60)
+ if str[0] != ',' {
+ dst_name, idx = parse_posix_tz_shortname(str) or_return
+ str = str[idx:]
+
+ if str[0] != ',' {
+ dst_offset, idx = parse_posix_tz_offset(str) or_return
+ dst_offset *= -1
+ str = str[idx:]
+ }
+ }
+ if str[0] != ',' { return }
+ str = str[1:]
+
+ std_td, idx3 := parse_posix_rrule(str) or_return
+ str = str[idx3:]
+
+ dst_td, idx4 := parse_posix_rrule(str) or_return
+ str = str[idx4:]
+
+ dst_name_str: string
+ dst_name_str, err = strings.clone(dst_name, allocator)
+ if err != nil { return }
+
+ return datetime.TZ_RRule{
+ has_dst = true,
+
+ std_name = std_name_str,
+ std_offset = std_offset,
+ std_date = std_td,
+
+ dst_name = dst_name_str,
+ dst_offset = dst_offset,
+ dst_date = dst_td,
+ }, true
+}
+
+parse_tzif :: proc(_buffer: []u8, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) {
+ context.allocator = allocator
+
+ buffer := _buffer
+
+ // TZif is crufty. Skip the initial header.
+
+ v1_hdr := slice.to_type(buffer, TZif_Header) or_return
+ if v1_hdr.magic != TZIF_MAGIC {
+ return
+ }
+ if v1_hdr.typecnt == 0 || v1_hdr.charcnt == 0 {
+ return
+ }
+ if v1_hdr.isutcnt != 0 && v1_hdr.isutcnt != v1_hdr.typecnt {
+ return
+ }
+ if v1_hdr.isstdcnt != 0 && v1_hdr.isstdcnt != v1_hdr.typecnt {
+ return
+ }
+
+ // We don't bother supporting v1, it uses u32 timestamps
+ if v1_hdr.version == .V1 {
+ return
+ }
+ // We only support v2 and v3
+ if v1_hdr.version != .V2 && v1_hdr.version != .V3 {
+ return
+ }
+
+ // Skip the initial v1 block too.
+ first_block_size, _ := tzif_data_block_size(&v1_hdr, .V1)
+ if len(buffer) <= size_of(v1_hdr) + first_block_size {
+ return
+ }
+ buffer = buffer[size_of(v1_hdr)+first_block_size:]
+
+ // Ok, time to parse real things
+ real_hdr := slice.to_type(buffer, TZif_Header) or_return
+ if real_hdr.magic != TZIF_MAGIC {
+ return
+ }
+ if real_hdr.typecnt == 0 || real_hdr.charcnt == 0 {
+ return
+ }
+ if real_hdr.isutcnt != 0 && real_hdr.isutcnt != real_hdr.typecnt {
+ return
+ }
+ if real_hdr.isstdcnt != 0 && real_hdr.isstdcnt != real_hdr.typecnt {
+ return
+ }
+
+ // Grab the real data block
+ real_block_size, _ := tzif_data_block_size(&real_hdr, v1_hdr.version)
+ if len(buffer) <= size_of(real_hdr) + real_block_size {
+ return
+ }
+ buffer = buffer[size_of(real_hdr):]
+
+ time_size := 8
+ transition_times := slice.reinterpret([]i64be, buffer[:int(real_hdr.timecnt)*size_of(i64be)])
+ for time in transition_times {
+ if time < BIG_BANG_ISH {
+ return
+ }
+ }
+ buffer = buffer[int(real_hdr.timecnt)*time_size:]
+
+ transition_types := buffer[:int(real_hdr.timecnt)]
+ for type in transition_types {
+ if int(type) > int(real_hdr.typecnt - 1) {
+ return
+ }
+ }
+ buffer = buffer[int(real_hdr.timecnt):]
+
+ local_time_types := slice.reinterpret([]Local_Time_Type, buffer[:int(real_hdr.typecnt)*size_of(Local_Time_Type)])
+ for ltt in local_time_types {
+ // UT offset should be > -25 hours and < 26 hours
+ if int(ltt.utoff) < -89999 || int(ltt.utoff) > 93599 {
+ return
+ }
+
+ if ltt.dst != .DST && ltt.dst != .Standard {
+ return
+ }
+
+ if int(ltt.idx) > int(real_hdr.charcnt - 1) {
+ return
+ }
+ }
+
+ buffer = buffer[int(real_hdr.typecnt) * size_of(Local_Time_Type):]
+ timezone_string_table := buffer[:real_hdr.charcnt]
+ buffer = buffer[real_hdr.charcnt:]
+
+ leapsecond_records := slice.reinterpret([]Leapsecond_Record, buffer[:int(real_hdr.leapcnt)*size_of(Leapsecond_Record)])
+ if len(leapsecond_records) > 0 {
+ if leapsecond_records[0].occur < 0 {
+ return
+ }
+ }
+ buffer = buffer[(int(real_hdr.leapcnt) * size_of(Leapsecond_Record)):]
+
+ standard_wall_tags := buffer[:int(real_hdr.isstdcnt)]
+ buffer = buffer[int(real_hdr.isstdcnt):]
+
+ ut_tags := buffer[:int(real_hdr.isutcnt)]
+
+ for stdwall_tag, idx in standard_wall_tags {
+ ut_tag := ut_tags[idx]
+
+ if (stdwall_tag != 0 && stdwall_tag != 1) {
+ return
+ }
+ if (ut_tag != 0 && ut_tag != 1) {
+ return
+ }
+
+ if ut_tag == 1 && stdwall_tag != 1 {
+ return
+ }
+ }
+ buffer = buffer[int(real_hdr.isutcnt):]
+
+ // Start of footer
+ if buffer[0] != '\n' {
+ return
+ }
+ buffer = buffer[1:]
+
+ if buffer[0] == ':' {
+ return
+ }
+
+ end_idx := 0
+ for ch in buffer {
+ if ch == '\n' {
+ break
+ }
+
+ if ch == 0 {
+ return
+ }
+ end_idx += 1
+ }
+ footer_str := string(buffer[:end_idx])
+
+ // UTC is a special case, we don't need to alloc
+ if len(local_time_types) == 1 {
+ name := cstring(raw_data(timezone_string_table[local_time_types[0].idx:]))
+ if name != "UTC" {
+ return
+ }
+
+ return nil, true
+ }
+
+ ltt_names, err := make([dynamic]string, 0, len(local_time_types), allocator)
+ if err != nil { return }
+ defer if err != nil {
+ for name in ltt_names {
+ delete(name, allocator)
+ }
+ delete(ltt_names)
+ }
+
+ for ltt in local_time_types {
+ name := cstring(raw_data(timezone_string_table[ltt.idx:]))
+ ltt_name: string
+
+ ltt_name, err = strings.clone_from_cstring_bounded(name, len(timezone_string_table), allocator)
+ if err != nil { return }
+
+ append(&ltt_names, ltt_name)
+ }
+
+ records: []datetime.TZ_Record
+ records, err = make([]datetime.TZ_Record, len(transition_times), allocator)
+ if err != nil { return }
+ defer if err != nil { delete(records, allocator) }
+
+ for trans_time, idx in transition_times {
+ trans_idx := transition_types[idx]
+ ltt := local_time_types[trans_idx]
+
+ records[idx] = datetime.TZ_Record{
+ time = i64(trans_time),
+ utc_offset = i64(ltt.utoff),
+ shortname = ltt_names[trans_idx],
+ dst = bool(ltt.dst),
+ }
+ }
+
+ rrule, ok2 := parse_posix_tz(footer_str, allocator)
+ if !ok2 { return }
+ defer if err != nil {
+ delete(rrule.std_name, allocator)
+ delete(rrule.dst_name, allocator)
+ }
+
+ region_name_out: string
+ region_name_out, err = strings.clone(region_name, allocator)
+ if err != nil { return }
+ defer if err != nil { delete(region_name_out, allocator) }
+
+ region: ^datetime.TZ_Region
+ region, err = new_clone(datetime.TZ_Region{
+ records = records,
+ shortnames = ltt_names[:],
+ name = region_name_out,
+ rrule = rrule,
+ }, allocator)
+ if err != nil {
+ return
+ }
+
+ return region, true
+}
diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin
index 424111aa3..93bc73789 100644
--- a/tests/core/time/test_core_time.odin
+++ b/tests/core/time/test_core_time.odin
@@ -3,6 +3,7 @@ package test_core_time
import "core:testing"
import "core:time"
import dt "core:time/datetime"
+import tz "core:time/timezone"
is_leap_year :: time.is_leap_year
@@ -349,3 +350,214 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) {
moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss,
)
}
+
+datetime_eq :: proc(dt1: dt.DateTime, dt2: dt.DateTime) -> bool {
+ return (
+ dt1.year == dt2.year && dt1.month == dt2.month && dt1.day == dt2.day &&
+ dt1.hour == dt2.hour && dt1.minute == dt2.minute && dt1.second == dt2.second
+ )
+}
+
+@test
+test_convert_timezone_roundtrip :: proc(t: ^testing.T) {
+ dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0)
+ std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0)
+
+ local_tz, local_load_ok := tz.region_load("local")
+ testing.expectf(t, local_load_ok, "Failed to load local timezone")
+ defer tz.region_destroy(local_tz)
+
+ edm_tz, edm_load_ok := tz.region_load("America/Edmonton")
+ testing.expectf(t, edm_load_ok, "Failed to load America/Edmonton timezone")
+ defer tz.region_destroy(edm_tz)
+
+ shuffle_tz :: proc(start_dt: dt.DateTime, test_tz: ^dt.TZ_Region) -> dt.DateTime {
+ tz_dt := tz.datetime_to_tz(start_dt, test_tz)
+ utc_dt := tz.datetime_to_utc(tz_dt)
+ return utc_dt
+ }
+
+ testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, local_tz)), "Failed to convert to/from local dst timezone")
+ testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, local_tz)), "Failed to convert to/from local std timezone")
+ testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, edm_tz)), "Failed to convert to/from Edmonton dst timezone")
+ testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, edm_tz)), "Failed to convert to/from Edmonton std timezone")
+}
+
+@test
+test_check_timezone_metadata :: proc(t: ^testing.T) {
+ dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0)
+ std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0)
+
+ pac_tz, pac_load_ok := tz.region_load("America/Los_Angeles")
+ testing.expectf(t, pac_load_ok, "Failed to load America/Los_Angeles timezone")
+ defer tz.region_destroy(pac_tz)
+
+ pac_dst_dt := tz.datetime_to_tz(dst_dt, pac_tz)
+ pac_std_dt := tz.datetime_to_tz(std_dt, pac_tz)
+ testing.expectf(t, tz.shortname_unsafe(pac_dst_dt) == "PDT", "Invalid timezone shortname")
+ testing.expectf(t, tz.shortname_unsafe(pac_std_dt) == "PST", "Invalid timezone shortname")
+ testing.expectf(t, tz.dst_unsafe(pac_std_dt) == false, "Expected daylight savings == false, got true")
+ testing.expectf(t, tz.dst_unsafe(pac_dst_dt) == true, "Expected daylight savings == true, got false")
+
+ pac_dst_name, ok := tz.shortname(pac_dst_dt)
+ testing.expectf(t, ok == true, "Invalid datetime")
+ testing.expectf(t, pac_dst_name == "PDT", "Invalid timezone shortname")
+
+ pac_std_name, ok2 := tz.shortname(pac_std_dt)
+ testing.expectf(t, ok2 == true, "Invalid datetime")
+ testing.expectf(t, pac_std_name == "PST", "Invalid timezone shortname")
+
+ pac_is_dst, ok3 := tz.dst(pac_dst_dt)
+ testing.expectf(t, ok3 == true, "Invalid datetime")
+ testing.expectf(t, pac_is_dst == true, "Expected daylight savings == false, got true")
+
+ pac_is_dst, ok3 = tz.dst(pac_std_dt)
+ testing.expectf(t, ok3 == true, "Invalid datetime")
+ testing.expectf(t, pac_is_dst == false, "Expected daylight savings == false, got true")
+}
+
+rrule_eq :: proc(r1, r2: dt.TZ_RRule) -> (eq: bool) {
+ if r1.has_dst != r2.has_dst { return }
+
+ if r1.std_name != r2.std_name { return }
+ if r1.std_offset != r2.std_offset { return }
+ if r1.std_date != r2.std_date { return }
+
+ if r1.dst_name != r2.dst_name { return }
+ if r1.dst_offset != r2.dst_offset { return }
+ if r1.dst_date != r2.dst_date { return }
+
+ return true
+}
+
+@test
+test_check_timezone_posix_tz :: proc(t: ^testing.T) {
+ correct_simple_rrule := dt.TZ_RRule{
+ has_dst = false,
+
+ std_name = "UTC",
+ std_offset = -(5 * 60 * 60),
+ std_date = dt.TZ_Transition_Date{
+ type = .Leap,
+ day = 0,
+ time = 2 * 60 * 60,
+ },
+ }
+
+ simple_rrule, simple_rrule_ok := tz.parse_posix_tz("UTC+5")
+ testing.expectf(t, simple_rrule_ok, "Failed to parse posix tz")
+ defer tz.rrule_destroy(simple_rrule)
+ testing.expectf(t, rrule_eq(simple_rrule, correct_simple_rrule), "POSIX TZ parsed incorrectly")
+
+ correct_est_rrule := dt.TZ_RRule{
+ has_dst = true,
+
+ std_name = "EST",
+ std_offset = -(5 * 60 * 60),
+ std_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 3,
+ week = 2,
+ day = 0,
+ time = 2 * 60 * 60,
+ },
+
+ dst_name = "EDT",
+ dst_offset = -(4 * 60 * 60),
+ dst_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 11,
+ week = 1,
+ day = 0,
+ time = 2 * 60 * 60,
+ },
+ }
+
+ est_rrule, est_rrule_ok := tz.parse_posix_tz("EST+5EDT,M3.2.0/2,M11.1.0/2")
+ testing.expectf(t, est_rrule_ok, "Failed to parse posix tz")
+ defer tz.rrule_destroy(est_rrule)
+ testing.expectf(t, rrule_eq(est_rrule, correct_est_rrule), "POSIX TZ parsed incorrectly")
+
+ correct_ist_rrule := dt.TZ_RRule{
+ has_dst = true,
+
+ std_name = "IST",
+ std_offset = (2 * 60 * 60),
+ std_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 3,
+ week = 4,
+ day = 4,
+ time = 26 * 60 * 60,
+ },
+
+ dst_name = "IDT",
+ dst_offset = (3 * 60 * 60),
+ dst_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 10,
+ week = 5,
+ day = 0,
+ time = 2 * 60 * 60,
+ },
+ }
+
+ ist_rrule, ist_rrule_ok := tz.parse_posix_tz("IST-2IDT,M3.4.4/26,M10.5.0")
+ testing.expectf(t, ist_rrule_ok, "Failed to parse posix tz")
+ defer tz.rrule_destroy(ist_rrule)
+ testing.expectf(t, rrule_eq(ist_rrule, correct_ist_rrule), "POSIX TZ parsed incorrectly")
+
+ correct_warst_rrule := dt.TZ_RRule{
+ has_dst = true,
+
+ std_name = "WART",
+ std_offset = -(4 * 60 * 60),
+ std_date = dt.TZ_Transition_Date{
+ type = .No_Leap,
+ day = 1,
+ time = 0 * 60 * 60,
+ },
+
+ dst_name = "WARST",
+ dst_offset = -(3 * 60 * 60),
+ dst_date = dt.TZ_Transition_Date{
+ type = .No_Leap,
+ day = 365,
+ time = 25 * 60 * 60,
+ },
+ }
+
+ warst_rrule, warst_rrule_ok := tz.parse_posix_tz("WART4WARST,J1/0,J365/25")
+ testing.expectf(t, warst_rrule_ok, "Failed to parse posix tz")
+ defer tz.rrule_destroy(warst_rrule)
+ testing.expectf(t, rrule_eq(warst_rrule, correct_warst_rrule), "POSIX TZ parsed incorrectly")
+
+ correct_wgt_rrule := dt.TZ_RRule{
+ has_dst = true,
+
+ std_name = "WGT",
+ std_offset = -(3 * 60 * 60),
+ std_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 3,
+ week = 5,
+ day = 0,
+ time = -2 * 60 * 60,
+ },
+
+ dst_name = "WGST",
+ dst_offset = -(2 * 60 * 60),
+ dst_date = dt.TZ_Transition_Date{
+ type = .Month_Week_Day,
+ month = 10,
+ week = 5,
+ day = 0,
+ time = -1 * 60 * 60,
+ },
+ }
+
+ wgt_rrule, wgt_rrule_ok := tz.parse_posix_tz("WGT3WGST,M3.5.0/-2,M10.5.0/-1")
+ testing.expectf(t, wgt_rrule_ok, "Failed to parse posix tz")
+ defer tz.rrule_destroy(wgt_rrule)
+ testing.expectf(t, rrule_eq(wgt_rrule, correct_wgt_rrule), "POSIX TZ parsed incorrectly")
+}