Skip to content

Fix IANA time zone names for Windows system time zones #15914

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions scripts/generate_windows_zone_names.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,28 @@ entries = nodes.flat_map do |node|
end
end.sort!

iana_to_windows_items = entries.map do |tzdata_name, territory, windows_name|
{tzdata_name, windows_name}
ENV["ZONEINFO"] = ZONEINFO_ZIP
iana_to_windows_items = entries.compact_map do |tzdata_name, territory, windows_name|
location = Time::Location.load(tzdata_name)
next unless location

time = Time.local(location).at_beginning_of_year
zone1 = time.zone
zone2 = (time + 6.months).zone

# southern hemisphere
if zone1.offset > zone2.offset
zone1, zone2 = zone2, zone1
end

{tzdata_name, windows_name, zone1.name, zone2.name}
end.uniq!

ENV["ZONEINFO"] = ZONEINFO_ZIP
windows_to_iana_items = entries.compact_map do |tzdata_name, territory, windows_name|
{windows_name, tzdata_name} if territory == "001"
end.uniq!

# TODO: remove in 1.17
windows_zone_names_items = entries.compact_map do |tzdata_name, territory, windows_name|
next unless territory == "001"
location = Time::Location.load(tzdata_name)
Expand Down
23 changes: 18 additions & 5 deletions scripts/windows_zone_names.ecr
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,31 @@
# DO NOT EDIT

module Crystal::System::Time
# These mappings from IANA to Windows time zone names are based on
# These mappings from IANA to Windows time zone names and tzdata abbreviations
# are based on
# <%= WINDOWS_ZONE_NAMES_SOURCE %>
private class_getter iana_to_windows : Hash(String, String) do
data = Hash(String, String).new(initial_capacity: <%= iana_to_windows_items.size %>)
<%- iana_to_windows_items.each do |tzdata_name, windows_name| -%>
put(data, <%= tzdata_name.inspect %>, <%= windows_name.inspect %>)
private class_getter iana_to_windows : Hash(String, {String, String, String}) do
data = Hash(String, {String, String, String}).new(initial_capacity: <%= iana_to_windows_items.size %>)
<%- iana_to_windows_items.each do |tzdata_name, windows_name, zone1, zone2| -%>
put(data, <%= tzdata_name.inspect %>, <%= windows_name.inspect %>, <%= zone1.inspect %>, <%= zone2.inspect %>)
<%- end -%>
data
end

# These canonical mappings from Windows to IANA time zone names, used for the
# local time zone, are based on
# <%= WINDOWS_ZONE_NAMES_SOURCE %>
private class_getter windows_to_iana : Hash(String, String) do
data = Hash(String, String).new(initial_capacity: <%= windows_to_iana_items.size %>)
<%- windows_to_iana_items.each do |windows_name, tzdata_name| -%>
put(data, <%= windows_name.inspect %>, <%= tzdata_name.inspect %>)
<%- end -%>
data
end

# These mappings from Windows time zone names to tzdata abbreviations are based on
# <%= WINDOWS_ZONE_NAMES_SOURCE %>
# TODO: remove in 1.17
private class_getter windows_zone_names : Hash(String, {String, String}) do
data = Hash(String, {String, String}).new(initial_capacity: <%= windows_zone_names_items.size %>)
<%- windows_zone_names_items.each do |windows_name, zone1, zone2, tzdata_name| -%>
Expand Down
8 changes: 8 additions & 0 deletions spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ class Time::Location
location.utc?.should be_false
location.fixed?.should be_false
end

it "uses IANA zone names for Windows system time zones (#15911)" do
location = Location.load("Europe/Lisbon")
std_zone = location.zones.find(&.dst?.!).should_not be_nil
dst_zone = location.zones.find(&.dst?).should_not be_nil
std_zone.name.should eq("WET")
dst_zone.name.should eq("WEST")
end
{% end %}

it "invalid timezone identifier" do
Expand Down
37 changes: 10 additions & 27 deletions src/crystal/system/win32/time.cr
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ module Crystal::System::Time
def self.load_localtime : ::Time::Location?
if LibC.GetDynamicTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID
windows_name = String.from_utf16(info.timeZoneKeyName.to_slice, truncate_at_null: true)
initialize_location_from_TZI(pointerof(info).as(LibC::TIME_ZONE_INFORMATION*).value, "Local", windows_name)

return unless canonical_iana_name = windows_to_iana[windows_name]?
return unless windows_info = iana_to_windows[canonical_iana_name]?
_, stdname, dstname = windows_info

initialize_location_from_TZI(pointerof(info).as(LibC::TIME_ZONE_INFORMATION*).value, "Local", windows_name, stdname, dstname)
end
end

Expand All @@ -90,7 +95,8 @@ module Crystal::System::Time
daylightDate : LibC::SYSTEMTIME

def self.load_iana_zone(iana_name : String) : ::Time::Location?
return unless windows_name = iana_to_windows[iana_name]?
return unless windows_info = iana_to_windows[iana_name]?
windows_name, stdname, dstname = windows_info

WindowsRegistry.open?(LibC::HKEY_LOCAL_MACHINE, REGISTRY_TIME_ZONES) do |key_handle|
WindowsRegistry.open?(key_handle, windows_name.to_utf16) do |sub_handle|
Expand All @@ -106,14 +112,12 @@ module Crystal::System::Time
)
WindowsRegistry.get_raw(sub_handle, Std, tzi.standardName.to_slice.to_unsafe_bytes)
WindowsRegistry.get_raw(sub_handle, Dlt, tzi.daylightName.to_slice.to_unsafe_bytes)
initialize_location_from_TZI(tzi, iana_name, windows_name)
initialize_location_from_TZI(tzi, iana_name, windows_name, stdname, dstname)
end
end
end

private def self.initialize_location_from_TZI(info, name, windows_name)
stdname, dstname = normalize_zone_names(info)

private def self.initialize_location_from_TZI(info, name, windows_name, stdname, dstname)
if info.standardDate.wMonth == 0_u16 || info.daylightDate.wMonth == 0_u16
# No DST
zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false)
Expand All @@ -140,27 +144,6 @@ module Crystal::System::Time
::Time::TZ::MonthWeekDay.new(time.wMonth.to_i8, time.wDay.to_i8, time.wDayOfWeek.to_i8, seconds)
end

# Normalizes the names of the standard and dst zones.
private def self.normalize_zone_names(info : LibC::TIME_ZONE_INFORMATION) : Tuple(String, String)
stdname, _ = String.from_utf16(info.standardName.to_slice.to_unsafe)

if normalized_names = windows_zone_names[stdname]?
return normalized_names
end

dstname, _ = String.from_utf16(info.daylightName.to_slice.to_unsafe)

if english_name = translate_zone_name(stdname, dstname)
if normalized_names = windows_zone_names[english_name]?
return normalized_names
end
end

# As a last resort, return the raw names as provided by TIME_ZONE_INFORMATION.
# They are most probably localized and we couldn't find a translation.
return stdname, dstname
end

REGISTRY_TIME_ZONES = System.wstr_literal %q(SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones)
Std = System.wstr_literal "Std"
Dlt = System.wstr_literal "Dlt"
Expand Down
Loading