Skip to content
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
4 changes: 2 additions & 2 deletions src/time/location.cr
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ class Time::Location
# ```
def self.posix_tz(name : String, str : String) : TZLocation
zones = Array(Location::Zone).new(initial_capacity: 2)
tz_args = TZLocation.parse_tz(str, zones, true) || raise ArgumentError.new("Invalid TZ string: #{str}")
tz_args = TZ.parse(str, zones, true) || raise ArgumentError.new("Invalid TZ string: #{str}")
TZLocation.new(name, zones, str, *tz_args)
end

Expand Down Expand Up @@ -395,7 +395,7 @@ class Time::Location
# > special timezone from an implementation-defined timezone database.
else
zones = Array(Location::Zone).new(initial_capacity: 2)
if tz_args = TZLocation.parse_tz(tz_string, zones, true)
if tz_args = TZ.parse(tz_string, zones, true)
return TZLocation.new("Local", zones, tz_string, *tz_args)
end

Expand Down
141 changes: 77 additions & 64 deletions src/time/tz.cr
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
# A time location capable of computing recurring time zone transitions using
# POSIX TZ strings, as defined in [POSIX.1-2024 Section 8.3](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html),
# or in [IETF RFC 9636](https://datatracker.ietf.org/doc/html/rfc9636).
#
# These locations are returned by `Time::Location.posix_tz`.
class Time::TZLocation < Time::Location
# :nodoc:
# Facilities for time zone lookup based on POSIX TZ strings
module Time::TZ
# `J*`: one-based ordinal day, excludes leap day
private record Julian1, ordinal : Int16, time : Int32 do
record Julian1, ordinal : Int16, time : Int32 do
def always_jan1? : Bool
ordinal == 1
end
Expand All @@ -20,7 +17,7 @@ class Time::TZLocation < Time::Location
end

# `*`: zero-based ordinal day, includes leap day
private record Julian0, ordinal : Int16, time : Int32 do
record Julian0, ordinal : Int16, time : Int32 do
def always_jan1? : Bool
ordinal == 0
end
Expand All @@ -36,7 +33,7 @@ class Time::TZLocation < Time::Location
end

# `M*.*.*`: month-week-day, week 5 is last week
private record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do
record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do
def always_jan1? : Bool
false
end
Expand All @@ -50,52 +47,21 @@ class Time::TZLocation < Time::Location
end
end

private alias Transition = Julian1 | Julian0 | MonthWeekDay

# Indices into this location's zones array. Identical if all-year standard
# time or DST is in effect.
@std_index : Int32
@dst_index : Int32

# The first and second transition times defined in the TZ string. Not
# meaningful when `std_index == dst_index`.
@transition1 : Transition
@transition2 : Transition
alias POSIXTransition = Julian1 | Julian0 | MonthWeekDay

# The original TZ string that produced this location.
@tz_string : String

protected def initialize(name : String, zones : Array(Zone), @tz_string, @std_index, @dst_index, @transition1, @transition2, transitions = [] of ZoneTransition)
super(name, zones, transitions)
end

def_equals_and_hash name, zones, transitions, @tz_string

# :nodoc:
def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
case
when zones.empty?
{Zone::UTC, {Int64::MIN, Int64::MAX}}
when transitions.empty?
lookup_posix_tz(unix_seconds)
when unix_seconds < transitions.first.when
{lookup_first_zone, {Int64::MIN, transitions.first.when}}
when unix_seconds >= transitions.last.when
lookup_posix_tz(unix_seconds)
else
lookup_within_fixed_transitions(unix_seconds)
end
end

private def lookup_posix_tz(unix_seconds : Int) : {Zone, {Int64, Int64}}
if @std_index == @dst_index
def self.lookup(
unix_seconds : Int, zones : Array(Location::Zone),
std_index : Int, dst_index : Int,
transition1 : POSIXTransition, transition2 : POSIXTransition,
) : {Location::Zone, {Int64, Int64}}
if std_index == dst_index
# all-year standard time or DST time
is_dst = false
range_begin = Int64::MIN
range_end = Int64::MAX
else
std_offset = -@zones[@std_index].offset
dst_offset = -@zones[@dst_index].offset
std_offset = -zones[std_index].offset
dst_offset = -zones[dst_index].offset

# Find the local year corresponding to `unix_seconds`, except we cannot
# rely on `Time`'s timezone facilities since that is exactly what this
Expand All @@ -105,8 +71,8 @@ class Time::TZLocation < Time::Location
utc_year = local_year = utc_time.year

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
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)
Expand All @@ -130,12 +96,12 @@ class Time::TZLocation < Time::Location
if new_year_is_dst
if unix_seconds < datetime2
is_dst = true
range_begin = @transition1.unix_date_in_year(local_year - 1) + @transition1.time + std_offset
range_begin = transition1.unix_date_in_year(local_year - 1) + transition1.time + std_offset
range_end = datetime2
elsif unix_seconds >= datetime1
is_dst = true
range_begin = datetime1
range_end = @transition2.unix_date_in_year(local_year + 1) + @transition2.time + dst_offset
range_end = transition2.unix_date_in_year(local_year + 1) + transition2.time + dst_offset
else
is_dst = false
range_begin = datetime2
Expand All @@ -144,12 +110,12 @@ class Time::TZLocation < Time::Location
else
if unix_seconds < datetime1
is_dst = false
range_begin = @transition2.unix_date_in_year(local_year - 1) + @transition2.time + dst_offset
range_begin = transition2.unix_date_in_year(local_year - 1) + transition2.time + dst_offset
range_end = datetime1
elsif unix_seconds >= datetime2
is_dst = false
range_begin = datetime2
range_end = @transition1.unix_date_in_year(local_year + 1) + @transition1.time + std_offset
range_end = transition1.unix_date_in_year(local_year + 1) + transition1.time + std_offset
else
is_dst = true
range_begin = datetime1
Expand All @@ -158,15 +124,9 @@ class Time::TZLocation < Time::Location
end
end

if last_transition = @transitions.last?
range_begin = {range_begin, last_transition.when}.max
end

{@zones[is_dst ? @dst_index : @std_index], {range_begin, range_end}}
{zones[is_dst ? dst_index : std_index], {range_begin, range_end}}
end

# :nodoc:
#
# Parses the given *tz* string. Returns the `std_index`, `dst_index`,
# `transition1`, and `transition2` fields for a yet to be constructed
# `TZLocation`, or `nil` if *tz* is invalid.
Expand All @@ -185,7 +145,7 @@ class Time::TZLocation < Time::Location
# * musl https://git.musl-libc.org/cgit/musl/tree/src/time/__tz.c?id=ef7d0ae21240eac9fc1e8088112bfb0fac507578#n239
# * bionic https://android.googlesource.com/platform/bionic/+/31fc69f67fc49b1a08f5561ae62d098106da6565/libc/tzcode/localtime.c#1148
# * wine msvcrt https://gitlab.winehq.org/wine/wine/-/blob/7f833db11ffea4f3f4fa07be31d30559aff9c5fb/dlls/msvcrt/time.c#L127
def self.parse_tz(tz : String, zones : Array(Location::Zone), hours_extension : Bool) : {Int32, Int32, Transition, Transition}?
def self.parse(tz : String, zones : Array(Location::Zone), hours_extension : Bool) : {Int32, Int32, POSIXTransition, POSIXTransition}?
reader = Char::Reader.new(tz)

# colon prefix: implementation-defined (not supported in Crystal)
Expand Down Expand Up @@ -250,7 +210,7 @@ class Time::TZLocation < Time::Location
end
end

private def self.parse_transition(reader : Char::Reader, hours_extension : Bool) : {Char::Reader, Transition}?
private def self.parse_transition(reader : Char::Reader, hours_extension : Bool) : {Char::Reader, POSIXTransition}?
case reader.current_char
when 'J'
reader.next_char
Expand Down Expand Up @@ -363,3 +323,56 @@ class Time::TZLocation < Time::Location
{reader, name}
end
end

# A time location capable of computing recurring time zone transitions in the
# future using POSIX TZ strings, as defined in [POSIX.1-2024 Section 8.3](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html),
# or in [IETF RFC 9636](https://datatracker.ietf.org/doc/html/rfc9636).
#
# These locations are returned by `Time::Location.posix_tz`.
class Time::TZLocation < Time::Location
# Indices into this location's zones array. Identical if all-year standard
# time or DST is in effect.
@std_index : Int32
@dst_index : Int32

# The first and second transition times defined in the TZ string. Not
# meaningful when `std_index == dst_index`.
@transition1 : TZ::POSIXTransition
@transition2 : TZ::POSIXTransition

# The original TZ string that produced this location.
@tz_string : String

protected def initialize(name : String, zones : Array(Zone), @tz_string, @std_index, @dst_index, @transition1, @transition2, transitions = [] of ZoneTransition)
super(name, zones, transitions)
end

def_equals_and_hash name, zones, transitions, @tz_string

# :nodoc:
def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
case
when zones.empty?
{Zone::UTC, {Int64::MIN, Int64::MAX}}
when transitions.empty?
lookup_posix_tz(unix_seconds)
when unix_seconds < transitions.first.when
{lookup_first_zone, {Int64::MIN, transitions.first.when}}
when unix_seconds >= transitions.last.when
lookup_posix_tz(unix_seconds)
else
lookup_within_fixed_transitions(unix_seconds)
end
end

private def lookup_posix_tz(unix_seconds : Int) : {Zone, {Int64, Int64}}
zone, range = TZ.lookup(unix_seconds, @zones, @std_index, @dst_index, @transition1, @transition2)
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