diff --git a/spec/std/time/location_spec.cr b/spec/std/time/location_spec.cr index 6c87bb733232..01366c27e49e 100644 --- a/spec/std/time/location_spec.cr +++ b/spec/std/time/location_spec.cr @@ -5,6 +5,13 @@ private def assert_tz_boundaries(tz : String, t0 : Time, t1 : Time, t2 : Time, t location = Time::Location.posix_tz("Local", tz) std_zone = location.zones.find(&.dst?.!).should_not be_nil, file: file, line: line dst_zone = location.zones.find(&.dst?).should_not be_nil, file: file, line: line + assert_tz_boundaries(location, std_zone, dst_zone, t0, t1, t2, t3, file: file, line: line) +end + +private def assert_tz_boundaries( + location : Time::Location, std_zone : Time::Location::Zone, dst_zone : Time::Location::Zone, + t0 : Time, t1 : Time, t2 : Time, t3 : Time, *, file = __FILE__, line = __LINE__, +) t0, t1, t2, t3 = t0.to_unix, t1.to_unix, t2.to_unix, t3.to_unix location.lookup_with_boundaries(t1 - 1).should eq({std_zone, {t0, t1}}), file: file, line: line @@ -761,7 +768,41 @@ class Time::Location end end - pending "zoneinfo + POSIX TZ string" + context "zoneinfo + POSIX TZ string" do + it "looks up location beyond last transition time" do + with_zoneinfo do + # "CET-1CEST,M3.5.0,M10.5.0/3" + # last transition is in year 2037 + location = Location.load("Europe/Berlin") + Time.unix(location.@transitions.last.when).year.should eq(2037) + + assert_tz_boundaries location, + Zone.new("CET", 3600, false), Zone.new("CEST", 7200, true), + Time.utc(2037, 10, 25, 1, 0, 0), Time.utc(2038, 3, 28, 1, 0, 0), + Time.utc(2038, 10, 31, 1, 0, 0), Time.utc(2039, 3, 27, 1, 0, 0) + + assert_tz_boundaries location, + Zone.new("CET", 3600, false), Zone.new("CEST", 7200, true), + Time.utc(3003, 10, 30, 1, 0, 0), Time.utc(3004, 3, 25, 1, 0, 0), + Time.utc(3004, 10, 28, 1, 0, 0), Time.utc(3005, 3, 31, 1, 0, 0) + end + end + + it "looks up location if TZ string has no transitions" do + with_zoneinfo do + # Paraguay stopped observing DST since 2024 + location = Location.load("America/Asuncion") + + zone, range = location.lookup_with_boundaries(Time.utc(2024, 10, 15, 2, 59, 59).to_unix) + zone.should eq(Zone.new("-03", -10800, true)) + range.should eq({Time.utc(2024, 10, 6, 4, 0, 0).to_unix, Time.utc(2024, 10, 15, 3, 0, 0).to_unix}) + + zone, range = location.lookup_with_boundaries(Time.utc(2024, 10, 15, 3, 0, 0).to_unix) + zone.should eq(Zone.new("-03", -10800, false)) + range.should eq({Time.utc(2024, 10, 15, 3, 0, 0).to_unix, Int64::MAX}) + end + end + end end end diff --git a/spec/std/time/time_spec.cr b/spec/std/time/time_spec.cr index fc282087e5be..75c6d9d925ba 100644 --- a/spec/std/time/time_spec.cr +++ b/spec/std/time/time_spec.cr @@ -138,6 +138,15 @@ describe Time do time.minute.should eq(59) time.second.should eq(59) time.nanosecond.should eq(999_999_999) + + time = Time.local(9999, 12, 31, 23, 59, 59, nanosecond: 999_999_999, location: Time::Location.posix_tz("Local", "EST5EDT,M3.2.0,M11.1.0")) + time.year.should eq(9999) + time.month.should eq(12) + time.day.should eq(31) + time.hour.should eq(23) + time.minute.should eq(59) + time.second.should eq(59) + time.nanosecond.should eq(999_999_999) end it "fails with negative nanosecond" do diff --git a/src/time/location/loader.cr b/src/time/location/loader.cr index 1699e40d3030..f1f3b25ae222 100644 --- a/src/time/location/loader.cr +++ b/src/time/location/loader.cr @@ -191,11 +191,21 @@ class Time::Location ZoneTransition.new(time, zone_idx, isstd, isutc) end - # TODO: parse the POSIX TZ string (#15792) - # note that some extensions are only available for version 3+ if version != 0 - raise InvalidTZDataError.new("Missing TZ footer") unless io.read_byte === '\n' - tz_string = io.gets + unless io.read_byte === '\n' + raise InvalidTZDataError.new("Missing TZ footer") + end + unless tz_string = io.gets + raise InvalidTZDataError.new("Missing TZ string") + end + + unless tz_string.empty? + hours_extension = version != '2'.ord # version 3+ + if tz_args = TZ.parse(tz_string, zones, hours_extension) + return TZLocation.new(location_name, zones, tz_string, *tz_args, transitions) + end + raise InvalidTZDataError.new("Invalid TZ string: #{tz_string}") + end end new(location_name, zones, transitions) diff --git a/src/time/tz.cr b/src/time/tz.cr index 6e43f26e734f..1b8ce556dbd9 100644 --- a/src/time/tz.cr +++ b/src/time/tz.cr @@ -1,6 +1,41 @@ # :nodoc: # Facilities for time zone lookup based on POSIX TZ strings module Time::TZ + # same as `Time.utc(year, 1, 1).to_unix`, except *year* is allowed to be + # outside its normal range + def self.jan1_to_unix(year : Int) : Int64 + # assume leap years have the same pattern beyond year 9999 + year -= 1 + days = year * 365 + year // 4 - year // 100 + year // 400 + SECONDS_PER_DAY.to_i64 * days - UNIX_EPOCH.total_seconds + end + + # same as `Time.unix(unix_seconds).year`, except *unix_seconds* is allowed to + # be outside its normal range + def self.unix_to_year(unix_seconds : Int) : Int32 + total_days = ((UNIX_EPOCH.total_seconds + unix_seconds) // SECONDS_PER_DAY).to_i + + num400 = total_days // DAYS_PER_400_YEARS + total_days -= num400 * DAYS_PER_400_YEARS + + num100 = total_days // DAYS_PER_100_YEARS + if num100 == 4 # leap + num100 = 3 + end + total_days -= num100 * DAYS_PER_100_YEARS + + num4 = total_days // DAYS_PER_4_YEARS + total_days -= num4 * DAYS_PER_4_YEARS + + numyears = total_days // 365 + if numyears == 4 # leap + numyears = 3 + end + total_days -= numyears * 365 + + num400 * 400 + num100 * 100 + num4 * 4 + numyears + 1 + end + # `J*`: one-based ordinal day, excludes leap day record Julian1, ordinal : Int16, time : Int32 do def always_jan1? : Bool @@ -12,7 +47,7 @@ module Time::TZ end def unix_date_in_year(year : Int) : Int64 - Time.utc(year, 1, 1).to_unix + 86400_i64 * (Time.leap_year?(year) && @ordinal >= 60 ? @ordinal : @ordinal - 1) + TZ.jan1_to_unix(year) + 86400_i64 * (Time.leap_year?((year - 1) % 400 + 1) && @ordinal >= 60 ? @ordinal : @ordinal - 1) end end @@ -28,7 +63,7 @@ module Time::TZ end def unix_date_in_year(year : Int) : Int64 - Time.utc(year, 1, 1).to_unix + 86400_i64 * @ordinal + TZ.jan1_to_unix(year) + 86400_i64 * @ordinal end end @@ -44,9 +79,17 @@ module Time::TZ end 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 + # this needs to handle years outside 1..9999; reduce `year` modulo 400 so + # that it fits into 1..2000, since the number of days per 400 years is + # divisible by 7 + cycles = (year - 1) // 400 + year = (year - 1) % 400 + 1 + Time.month_week_date(year, @month.to_i32, @week.to_i32, @day.to_i32, location: Time::Location::UTC).to_unix + SECONDS_PER_400_YEARS * cycles end + # 24 * 60 * 60 * (365 * 400 + 100 - 25 + 1) + SECONDS_PER_400_YEARS = 12622780800_i64 + def self.default : self new(0, 0, 0, 0) end @@ -72,16 +115,15 @@ module Time::TZ # rely on `Time`'s timezone facilities since that is exactly what this # method implements. It may differ from the UTC year by 0 or 1. musl uses # a similar loop. - utc_time = Time.unix(unix_seconds) - utc_year = local_year = utc_time.year + utc_year = local_year = TZ.unix_to_year(unix_seconds) while true datetime1 = transition1.unix_date_in_year(local_year) + transition1.time + std_offset datetime2 = transition2.unix_date_in_year(local_year) + transition2.time + dst_offset new_year_is_dst = datetime2 < datetime1 - local_new_year = Time.utc(local_year, 1, 1).to_unix + (new_year_is_dst ? dst_offset : std_offset) - local_new_year_next = Time.utc(local_year + 1, 1, 1).to_unix + (new_year_is_dst ? dst_offset : std_offset) + local_new_year = TZ.jan1_to_unix(local_year) + (new_year_is_dst ? dst_offset : std_offset) + local_new_year_next = TZ.jan1_to_unix(local_year + 1) + (new_year_is_dst ? dst_offset : std_offset) break if local_new_year <= unix_seconds < local_new_year_next if local_year == utc_year