Skip to content

Support POSIX TZ strings in TZif databases #15863

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

Merged
merged 5 commits into from
Jun 15, 2025
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
43 changes: 42 additions & 1 deletion spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([email protected]).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

Expand Down
9 changes: 9 additions & 0 deletions spec/std/time/time_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions src/time/location/loader.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 49 additions & 7 deletions src/time/tz.cr
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down