Skip to content
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

Map IANA time zone identifiers to Windows time zones #13517

Merged
Merged
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
60 changes: 21 additions & 39 deletions scripts/generate_windows_zone_names.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,63 +7,45 @@
require "http/client"
require "xml"
require "../src/compiler/crystal/formatter"
require "ecr"

WINDOWS_ZONE_NAMES_SOURCE = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/supplemental/windowsZones.xml"
WINDOWS_ZONE_NAMES_SOURCE = "https://raw.githubusercontent.com/unicode-org/cldr/817409270794bb1538fe6b4aa3e9c79aff2c34d2/common/supplemental/windowsZones.xml"
TARGET_FILE = File.join(__DIR__, "..", "src", "crystal", "system", "win32", "zone_names.cr")

response = HTTP::Client.get(WINDOWS_ZONE_NAMES_SOURCE)
xml = XML.parse(response.body)
nodes = xml.xpath_nodes("/supplementalData/windowsZones/mapTimezones/mapZone[@territory=001]")
nodes = xml.xpath_nodes("/supplementalData/windowsZones/mapTimezones/mapZone")
entries = nodes.flat_map do |node|
windows_name = node["other"]
territory = node["territory"]
node["type"].split(' ', remove_empty: true).map do |tzdata_name|
{tzdata_name, territory, windows_name}
end
end.sort!

iana_to_windows_items = entries.map do |tzdata_name, territory, windows_name|
{tzdata_name, windows_name}
end.uniq!

entries = nodes.compact_map do |node|
location = Time::Location.load(node["type"])
windows_zone_names_items = entries.compact_map do |tzdata_name, territory, windows_name|
next unless territory == "001"
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
# southern hemisphere
zones = {zone2.name, zone1.name}
else
# northern hemisphere
zones = {zone1.name, zone2.name}
zone1, zone2 = zone2, zone1
end

{key: node["other"], zones: zones, tzdata_name: location.name}
{windows_name, zone1.name, zone2.name, location.name}
rescue err : Time::Location::InvalidLocationNameError
pp err
nil
end

# sort by IANA database identifier
entries.sort_by! &.[:tzdata_name]

hash_items = String.build do |io|
entries.join(io, '\n') do |entry|
entry[:key].inspect(io)
io << " => "
entry[:zones].inspect(io)
io << ", # " << entry[:tzdata_name]
end
end

source = <<-CRYSTAL
# This file was automatically generated by running:
#
# scripts/generate_windows_zone_names.cr
#
# DO NOT EDIT

module Crystal::System::Time
# These mappings for windows time zone names are based on
# #{WINDOWS_ZONE_NAMES_SOURCE}
WINDOWS_ZONE_NAMES = {
#{hash_items}
}
end
CRYSTAL

source = ECR.render "#{__DIR__}/windows_zone_names.ecr"
source = Crystal.format(source)

File.write(TARGET_FILE, source)
39 changes: 39 additions & 0 deletions scripts/windows_zone_names.ecr
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This file was automatically generated by running:
#
# scripts/generate_windows_zone_names.cr
#
# DO NOT EDIT

module Crystal::System::Time
# These mappings from IANA to Windows time zone names 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 %>)
<%- end -%>
data
end

# These mappings from Windows time zone names to tzdata abbreviations are based on
# <%= WINDOWS_ZONE_NAMES_SOURCE %>
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| -%>
put(data, <%= windows_name.inspect %>, <%= zone1.inspect %>, <%= zone2.inspect %>) # <%= tzdata_name %>
<%- end -%>
data
end

# TODO: this is needed to avoid generating lots of allocas
# in LLVM, which makes LLVM really slow. The compiler should
# try to avoid/reuse temporary allocas.
# Explanation: https://github.com/crystal-lang/crystal/issues/4516#issuecomment-306226171
private def self.put(hash : Hash, key, value) : Nil
hash[key] = value
end

private def self.put(hash : Hash, key, *values) : Nil
hash[key] = values
end
end
9 changes: 9 additions & 0 deletions spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ class Time::Location
end
end

{% if flag?(:win32) %}
it "maps IANA timezone identifier to Windows name (#13166)" do
location = Location.load("Europe/Berlin")
location.name.should eq "Europe/Berlin"
location.utc?.should be_false
location.fixed?.should be_false
end
{% end %}

it "invalid timezone identifier" do
with_zoneinfo(datapath("zoneinfo")) do
expect_raises(InvalidLocationNameError, "Foobar/Baz") do
Expand Down
4 changes: 4 additions & 0 deletions src/crystal/system/time.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ module Crystal::System::Time
# Returns a list of paths where time zone data should be looked up.
# def self.zone_sources : Enumerable(String)

# Loads a time zone by its IANA zone identifier directly. May return `nil` on
# systems where tzdata is assumed to be available.
# def self.load_iana_zone(iana_name : String) : ::Time::Location?

# Returns the system's current local time zone
# def self.load_localtime : ::Time::Location?
end
Expand Down
4 changes: 4 additions & 0 deletions src/crystal/system/unix/time.cr
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ module Crystal::System::Time
ZONE_SOURCES
end

def self.load_iana_zone(iana_name : String) : ::Time::Location?
nil
end

def self.load_localtime : ::Time::Location?
if ::File.file?(LOCALTIME) && ::File.readable?(LOCALTIME)
::File.open(LOCALTIME) do |file|
Expand Down
44 changes: 38 additions & 6 deletions src/crystal/system/win32/time.cr
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,52 @@ module Crystal::System::Time

def self.load_localtime : ::Time::Location?
if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_UNKNOWN
initialize_location_from_TZI(info)
initialize_location_from_TZI(info, "Local")
end
end

def self.zone_sources : Enumerable(String)
[] of String
end

private def self.initialize_location_from_TZI(info)
# https://learn.microsoft.com/en-us/windows/win32/api/timezoneapi/ns-timezoneapi-time_zone_information#remarks
@[Extern]
private record REG_TZI_FORMAT,
bias : LibC::LONG,
standardBias : LibC::LONG,
daylightBias : LibC::LONG,
standardDate : LibC::SYSTEMTIME,
daylightDate : LibC::SYSTEMTIME

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

WindowsRegistry.open?(LibC::HKEY_LOCAL_MACHINE, REGISTRY_TIME_ZONES) do |key_handle|
WindowsRegistry.open?(key_handle, windows_name.to_utf16) do |sub_handle|
reg_tzi = uninitialized REG_TZI_FORMAT
WindowsRegistry.get_raw(sub_handle, TZI, Slice.new(pointerof(reg_tzi), 1).to_unsafe_bytes)

tzi = LibC::TIME_ZONE_INFORMATION.new(
bias: reg_tzi.bias,
standardDate: reg_tzi.standardDate,
standardBias: reg_tzi.standardBias,
daylightDate: reg_tzi.daylightDate,
daylightBias: reg_tzi.daylightBias,
)
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)
end
end
end

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

if info.standardDate.wMonth == 0_u16
# No DST
zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false)
return ::Time::Location.new("Local", [zone])
return ::Time::Location.new(name, [zone])
end

zones = [
Expand Down Expand Up @@ -116,7 +147,7 @@ module Crystal::System::Time
transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false)
end

::Time::Location.new("Local", zones, transitions)
::Time::Location.new(name, zones, transitions)
end

# Calculates the day of a DST switch in year *year* by extrapolating the date given in
Expand Down Expand Up @@ -161,14 +192,14 @@ module Crystal::System::Time
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]?
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]?
if normalized_names = windows_zone_names[english_name]?
return normalized_names
end
end
Expand All @@ -181,6 +212,7 @@ module Crystal::System::Time
REGISTRY_TIME_ZONES = %q(SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones).to_utf16
Std = "Std".to_utf16
Dlt = "Dlt".to_utf16
TZI = "TZI".to_utf16

# Searches the registry for an English name of a time zone named *stdname* or *dstname*
# and returns the English name.
Expand Down
Loading