From 83063a1ee100cd8c210fb9fd26cdb3b3bcf3bca4 Mon Sep 17 00:00:00 2001 From: Quinton Miller Date: Mon, 2 Jun 2025 04:15:08 +0800 Subject: [PATCH 1/3] Support POSIX TZ strings in TZif databases --- spec/std/time/location_spec.cr | 42 +++++++++++++++++++++++++++++++++- src/time/location/loader.cr | 18 +++++++++++---- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/spec/std/time/location_spec.cr b/spec/std/time/location_spec.cr index 9a4766c67e34..029d5de5bad1 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 @@ -749,7 +756,40 @@ 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") + + 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/src/time/location/loader.cr b/src/time/location/loader.cr index 1699e40d3030..98f446d87239 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 = TZLocation.parse_tz(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) From 77051a2fbb9ba64a23dd7acd76a1baa28e2f9090 Mon Sep 17 00:00:00 2001 From: Quinton Miller Date: Tue, 3 Jun 2025 01:54:52 +0800 Subject: [PATCH 2/3] handle instants near end of year 9999 --- spec/std/time/time_spec.cr | 9 +++++++ src/time/tz.cr | 55 +++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) 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/tz.cr b/src/time/tz.cr index 80a672dff2f1..4411d515d237 100644 --- a/src/time/tz.cr +++ b/src/time/tz.cr @@ -4,6 +4,41 @@ # # These locations are returned by `Time::Location.posix_tz`. class Time::TZLocation < Time::Location + # 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 private record Julian1, ordinal : Int16, time : Int32 do def always_jan1? : Bool @@ -15,7 +50,7 @@ class Time::TZLocation < Time::Location 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) + TZLocation.jan1_to_unix(year) + 86400_i64 * (Time.leap_year?((year - 1) % 400 + 1) && @ordinal >= 60 ? @ordinal : @ordinal - 1) end end @@ -31,7 +66,7 @@ class Time::TZLocation < Time::Location end def unix_date_in_year(year : Int) : Int64 - Time.utc(year, 1, 1).to_unix + 86400_i64 * @ordinal + TZLocation.jan1_to_unix(year) + 86400_i64 * @ordinal end end @@ -46,8 +81,15 @@ class Time::TZLocation < Time::Location 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; we could reduce `year` + # modulo 400 since the number of days in 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 end private alias Transition = Julian1 | Julian0 | MonthWeekDay @@ -101,16 +143,15 @@ class Time::TZLocation < Time::Location # 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 = TZLocation.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 = TZLocation.jan1_to_unix(local_year) + (new_year_is_dst ? dst_offset : std_offset) + local_new_year_next = TZLocation.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 From f0c00a5958047cfeeee889f17d850d0969b0d29b Mon Sep 17 00:00:00 2001 From: Quinton Miller Date: Wed, 4 Jun 2025 16:08:43 +0800 Subject: [PATCH 3/3] fixup --- spec/std/time/location_spec.cr | 1 + src/time/location/loader.cr | 2 +- src/time/tz.cr | 15 ++++++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/spec/std/time/location_spec.cr b/spec/std/time/location_spec.cr index 029d5de5bad1..49cef9756a32 100644 --- a/spec/std/time/location_spec.cr +++ b/spec/std/time/location_spec.cr @@ -762,6 +762,7 @@ class Time::Location # "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), diff --git a/src/time/location/loader.cr b/src/time/location/loader.cr index 98f446d87239..f1f3b25ae222 100644 --- a/src/time/location/loader.cr +++ b/src/time/location/loader.cr @@ -201,7 +201,7 @@ class Time::Location unless tz_string.empty? hours_extension = version != '2'.ord # version 3+ - if tz_args = TZLocation.parse_tz(tz_string, zones, hours_extension) + 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}") diff --git a/src/time/tz.cr b/src/time/tz.cr index 363faa8175da..9e7987a8eeb8 100644 --- a/src/time/tz.cr +++ b/src/time/tz.cr @@ -47,7 +47,7 @@ module Time::TZ end def unix_date_in_year(year : Int) : Int64 - TZLocation.jan1_to_unix(year) + 86400_i64 * (Time.leap_year?((year - 1) % 400 + 1) && @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 @@ -63,7 +63,7 @@ module Time::TZ end def unix_date_in_year(year : Int) : Int64 - TZLocation.jan1_to_unix(year) + 86400_i64 * @ordinal + TZ.jan1_to_unix(year) + 86400_i64 * @ordinal end end @@ -78,8 +78,9 @@ module Time::TZ end def unix_date_in_year(year : Int) : Int64 - # this needs to handle years outside 1..9999; we could reduce `year` - # modulo 400 since the number of days in 400 years is divisible by 7 + # 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 @@ -109,15 +110,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_year = local_year = TZLocation.unix_to_year(unix_seconds) + 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 = TZLocation.jan1_to_unix(local_year) + (new_year_is_dst ? dst_offset : std_offset) - local_new_year_next = TZLocation.jan1_to_unix(local_year + 1) + (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