diff --git a/spec/std/time/location_spec.cr b/spec/std/time/location_spec.cr index 9a4766c67e34..6c87bb733232 100644 --- a/spec/std/time/location_spec.cr +++ b/spec/std/time/location_spec.cr @@ -300,7 +300,19 @@ class Time::Location with_system_time_zone(info) do location = Location.load_local - location.zones.should eq [Time::Location::Zone.new("CET", 3600, false), Time::Location::Zone.new("CEST", 7200, true)] + std_zone = Time::Location::Zone.new("CET", 3600, false) + dst_zone = Time::Location::Zone.new("CEST", 7200, true) + location.zones.should eq [std_zone, dst_zone] + + location.lookup(Time.utc(2000, 10, 29, 0, 59, 59)).should eq(dst_zone) + location.lookup(Time.utc(2000, 10, 29, 1, 0, 0)).should eq(std_zone) + location.lookup(Time.utc(2001, 3, 25, 0, 59, 59)).should eq(std_zone) + location.lookup(Time.utc(2001, 3, 25, 1, 0, 0)).should eq(dst_zone) + + location.lookup(Time.utc(3000, 10, 26, 0, 59, 59)).should eq(dst_zone) + location.lookup(Time.utc(3000, 10, 26, 1, 0, 0)).should eq(std_zone) + location.lookup(Time.utc(3001, 3, 29, 0, 59, 59)).should eq(std_zone) + location.lookup(Time.utc(3001, 3, 29, 1, 0, 0)).should eq(dst_zone) end end diff --git a/src/crystal/system/win32/time.cr b/src/crystal/system/win32/time.cr index df53e95d70df..f7a1d8eae5cb 100644 --- a/src/crystal/system/win32/time.cr +++ b/src/crystal/system/win32/time.cr @@ -70,8 +70,9 @@ module Crystal::System::Time end def self.load_localtime : ::Time::Location? - if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID - initialize_location_from_TZI(info, "Local") + 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) end end @@ -105,18 +106,19 @@ 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) + initialize_location_from_TZI(tzi, iana_name, windows_name) end end end - private def self.initialize_location_from_TZI(info, name) + private def self.initialize_location_from_TZI(info, name, windows_name) stdname, dstname = normalize_zone_names(info) - if info.standardDate.wMonth == 0_u16 + 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) - return ::Time::Location.new(name, [zone]) + default_tz_args = {0, 0, ::Time::TZ::MonthWeekDay.default, ::Time::TZ::MonthWeekDay.default} + return ::Time::WindowsLocation.new(name, [zone], windows_name, default_tz_args) end zones = [ @@ -124,51 +126,18 @@ module Crystal::System::Time ::Time::Location::Zone.new(dstname, (info.bias + info.daylightBias) * BIAS_TO_OFFSET_FACTOR, true), ] - first_date = info.standardDate - second_date = info.daylightDate - first_index = 0_u8 - second_index = 1_u8 + std_index = 0 + dst_index = 1 + transition1 = systemtime_to_mwd(info.daylightDate) + transition2 = systemtime_to_mwd(info.standardDate) + tz_args = {std_index, dst_index, transition1, transition2} - if info.standardDate.wMonth > info.daylightDate.wMonth - first_date, second_date = second_date, first_date - first_index, second_index = second_index, first_index - end - - transitions = [] of ::Time::Location::ZoneTransition - - current_year = ::Time.utc.year - - (current_year - 100).upto(current_year + 100) do |year| - tstamp = calculate_switchdate_in_year(year, first_date) - (zones[second_index].offset) - transitions << ::Time::Location::ZoneTransition.new(tstamp, first_index, first_index == 0, false) - - tstamp = calculate_switchdate_in_year(year, second_date) - (zones[first_index].offset) - transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false) - end - - ::Time::Location.new(name, zones, transitions) + ::Time::WindowsLocation.new(name, zones, windows_name, tz_args) end - # Calculates the day of a DST switch in year *year* by extrapolating the date given in - # *systemtime* (for the current year). - # - # Returns the number of seconds since UNIX epoch (Jan 1 1970) in the local time zone. - private def self.calculate_switchdate_in_year(year, systemtime) - # Windows specifies daylight savings information in "day in month" format: - # wMonth is month number (1-12) - # wDayOfWeek is appropriate weekday (Sunday=0 to Saturday=6) - # wDay is week within the month (1 to 5, where 5 is last week of the month) - # wHour, wMinute and wSecond are absolute time - ::Time.month_week_date( - year, - systemtime.wMonth.to_i32, - systemtime.wDay.to_i32, - systemtime.wDayOfWeek.to_i32, - systemtime.wHour.to_i32, - systemtime.wMinute.to_i32, - systemtime.wSecond.to_i32, - location: ::Time::Location::UTC, - ).to_unix + private def self.systemtime_to_mwd(time) + seconds = 3600 * time.wHour + 60 * time.wMinute + time.wSecond + ::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. diff --git a/src/time/tz.cr b/src/time/tz.cr index b49f38c4689f..6e43f26e734f 100644 --- a/src/time/tz.cr +++ b/src/time/tz.cr @@ -33,6 +33,7 @@ module Time::TZ end # `M*.*.*`: month-week-day, week 5 is last week + # also used for Windows system time zones (ignoring the millisecond component) record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do def always_jan1? : Bool false @@ -45,6 +46,10 @@ module Time::TZ def unix_date_in_year(year : Int) : Int64 Time.month_week_date(year, @month.to_i32, @week.to_i32, @day.to_i32, location: Time::Location::UTC).to_unix end + + def self.default : self + new(0, 0, 0, 0) + end end alias POSIXTransition = Julian1 | Julian0 | MonthWeekDay @@ -376,3 +381,61 @@ class Time::TZLocation < Time::Location {zone, {range_begin, range_end}} end end + +# A time location capable of computing recurring time zone transitions in the +# past or future using definitions from the Windows Registry. +# +# These locations are returned by `Time::Location.load`. +class Time::WindowsLocation < Time::Location + # Two sets of transition rules for times before the first transition or after + # the last transition. Each corresponds to a `TZLocation`'s `@std_index`, + # `@dst_index`, `@transition1`, and `@transition2` fields. If there are no + # fixed transitions then the two sets are equal. + @past_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay} + @future_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay} + + # The original Windows Registry key name for this location. + @key_name : String + + def initialize(name : String, zones : Array(Zone), @key_name, @past_tz_args, @future_tz_args = past_tz_args, transitions = [] of ZoneTransition) + super(name, zones, transitions) + end + + def_equals_and_hash name, zones, transitions, @key_name + + # :nodoc: + def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}} + case + when zones.empty? + {Zone::UTC, {Int64::MIN, Int64::MAX}} + when transitions.empty?, unix_seconds < transitions.first.when + lookup_past(unix_seconds) + when unix_seconds >= transitions.last.when + lookup_future(unix_seconds) + else + lookup_within_fixed_transitions(unix_seconds) + end + end + + private def lookup_past(unix_seconds : Int) : {Zone, {Int64, Int64}} + zone, range = TZ.lookup(unix_seconds, @zones, *@past_tz_args) + range_begin, range_end = range + + if first_transition = @transitions.first? + range_end = {range_end, first_transition.when}.min + end + + {zone, {range_begin, range_end}} + end + + private def lookup_future(unix_seconds : Int) : {Zone, {Int64, Int64}} + zone, range = TZ.lookup(unix_seconds, @zones, *@future_tz_args) + range_begin, range_end = range + + if last_transition = @transitions.last? + range_begin = {range_begin, last_transition.when}.max + end + + {zone, {range_begin, range_end}} + end +end