Skip to content

Commit

Permalink
Support "slim" zoneinfo files produced by default by zic >= 2020b.
Browse files Browse the repository at this point in the history
Use the POSIX-style TZ string to calculate transitions after the last
defined transition contained within the file. At the moment these
transitions are generated when the file is loaded. In a later release
this will likely be changed to calculate transitions on demand.

Resolves #120.
  • Loading branch information
philr committed Nov 4, 2020
1 parent 9094a82 commit 08eba71
Show file tree
Hide file tree
Showing 11 changed files with 2,609 additions and 24 deletions.
3 changes: 3 additions & 0 deletions lib/tzinfo.rb
Expand Up @@ -25,6 +25,8 @@ module TZInfo

require_relative 'tzinfo/timezone_offset'
require_relative 'tzinfo/timezone_transition'
require_relative 'tzinfo/transition_rule'
require_relative 'tzinfo/annual_rules'

require_relative 'tzinfo/data_sources'
require_relative 'tzinfo/data_sources/timezone_info'
Expand All @@ -35,6 +37,7 @@ module TZInfo

require_relative 'tzinfo/data_sources/country_info'

require_relative 'tzinfo/data_sources/posix_time_zone_parser'
require_relative 'tzinfo/data_sources/zoneinfo_reader'

require_relative 'tzinfo/data_source'
Expand Down
71 changes: 71 additions & 0 deletions lib/tzinfo/annual_rules.rb
@@ -0,0 +1,71 @@
# encoding: UTF-8
# frozen_string_literal: true

module TZInfo
# A set of rules that define when transitions occur in time zones with
# annually occurring daylight savings time.
#
# @private
class AnnualRules #:nodoc:
# @return [TimezoneOffset] the standard offset that applies when daylight
# savings time is not in force.
attr_reader :std_offset

# @return [TimezoneOffset] the offset that applies when daylight savings
# time is in force.
attr_reader :dst_offset

# @return [TransitionRule] the rule that determines when daylight savings
# time starts.
attr_reader :dst_start_rule

# @return [TransitionRule] the rule that determines when daylight savings
# time ends.
attr_reader :dst_end_rule

# Initializes a new {AnnualRules} instance.
#
# @param std_offset [TimezoneOffset] the standard offset that applies when
# daylight savings time is not in force.
# @param dst_offset [TimezoneOffset] the offset that applies when daylight
# savings time is in force.
# @param dst_start_rule [TransitionRule] the rule that determines when
# daylight savings time starts.
# @param dst_end_rule [TransitionRule] the rule that determines when daylight
# savings time ends.
def initialize(std_offset, dst_offset, dst_start_rule, dst_end_rule)
@std_offset = std_offset
@dst_offset = dst_offset
@dst_start_rule = dst_start_rule
@dst_end_rule = dst_end_rule
end

# Returns the transitions between standard and daylight savings time for a
# given year. The results are ordered by time of occurrence (earliest to
# latest).
#
# @param year [Integer] the year to calculate transitions for.
# @return [Array<TimezoneTransition>] the transitions for the year.
def transitions(year)
start_dst = apply_rule(@dst_start_rule, @std_offset, @dst_offset, year)
end_dst = apply_rule(@dst_end_rule, @dst_offset, @std_offset, year)

end_dst.timestamp_value < start_dst.timestamp_value ? [end_dst, start_dst] : [start_dst, end_dst]
end

private

# Applies a given rule between offsets on a year.
#
# @param rule [TransitionRule] the rule to apply.
# @param from_offset [TimezoneOffset] the offset the rule transitions from.
# @param to_offset [TimezoneOffset] the offset the rule transitions to.
# @param year [Integer] the year when the transition occurs.
# @return [TimezoneTransition] the transition determined by the rule.
def apply_rule(rule, from_offset, to_offset, year)
at = rule.at(from_offset, year)
TimezoneTransition.new(to_offset, from_offset, at.value)
end
end
private_constant :AnnualRules
end
181 changes: 181 additions & 0 deletions lib/tzinfo/data_sources/posix_time_zone_parser.rb
@@ -0,0 +1,181 @@
# encoding: UTF-8
# frozen_string_literal: true

require 'strscan'

module TZInfo
# Use send as a workaround for erroneous 'wrong number of arguments' errors
# with JRuby 9.0.5.0 when calling methods with Java implementations. See #114.
send(:using, UntaintExt) if TZInfo.const_defined?(:UntaintExt)

module DataSources
# An {InvalidPosixTimeZone} exception is raised if an invalid POSIX-style
# time zone string is encountered.
#
# @private
class InvalidPosixTimeZone < StandardError #:nodoc:
end
private_constant :InvalidPosixTimeZone

# A parser for POSIX-style TZ strings used in zoneinfo files and specified
# by tzfile.5 and tzset.3.
#
# @private
class PosixTimeZoneParser #:nodoc:
# Initializes a new {PosixTimeZoneParser}.
#
# @param string_deduper [StringDeduper] a {StringDeduper} instance to use
# to dedupe abbreviations.
def initialize(string_deduper)
@string_deduper = string_deduper
end

# Parses a POSIX-style TZ string.
#
# @param tz_string [String] the string to parse.
# @return [Object] either a {TimezoneOffset} for a constantly applied
# offset or an {AnnualRules} instance representing the rules.
# @raise [InvalidPosixTimeZone] if `tz_string` is not a `String`.
# @raise [InvalidPosixTimeZone] if `tz_string` is is not valid.
def parse(tz_string)
raise InvalidPosixTimeZone unless tz_string.kind_of?(String)
return nil if tz_string.empty?

s = StringScanner.new(tz_string)
check_scan(s, /([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
std_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint)
check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
std_offset = get_offset_from_hms(s[1], s[2], s[3])

if s.scan(/([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
dst_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint)

if s.scan(/([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
dst_offset = get_offset_from_hms(s[1], s[2], s[3])
else
# POSIX is negative for ahead of UTC.
dst_offset = std_offset - 3600
end

dst_difference = std_offset - dst_offset

start_rule = parse_rule(s, 'start')
end_rule = parse_rule(s, 'end')

raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." if s.rest?

if start_rule.is_always_first_day_of_year? && start_rule.transition_at == 0 &&
end_rule.is_always_last_day_of_year? && end_rule.transition_at == 86400 + dst_difference
# Constant daylight savings time.
# POSIX is negative for ahead of UTC.
TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev)
else
AnnualRules.new(
TimezoneOffset.new(-std_offset, 0, std_abbrev),
TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev),
start_rule,
end_rule)
end
elsif !s.rest?
# Constant standard time.
# POSIX is negative for ahead of UTC.
TimezoneOffset.new(-std_offset, 0, std_abbrev)
else
raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'."
end
end

private

# Parses a rule.
#
# @param s [StringScanner] the `StringScanner` to read the rule from.
# @param type [String] the type of rule (either `'start'` or `'end'`).
# @raise [InvalidPosixTimeZone] if the rule is not valid.
# @return [TransitionRule] the parsed rule.
def parse_rule(s, type)
check_scan(s, /,(?: (?: J(\d+) ) | (\d+) | (?: M(\d+)\.(\d)\.(\d) ) )/x)
julian_day_of_year = s[1]
absolute_day_of_year = s[2]
month = s[3]
week = s[4]
day_of_week = s[5]

if s.scan(/\//)
check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
transition_at = get_seconds_after_midnight_from_hms(s[1], s[2], s[3])
else
transition_at = 7200
end

begin
if julian_day_of_year
JulianDayOfYearTransitionRule.new(julian_day_of_year.to_i, transition_at)
elsif absolute_day_of_year
AbsoluteDayOfYearTransitionRule.new(absolute_day_of_year.to_i, transition_at)
elsif week == '5'
LastDayOfMonthTransitionRule.new(month.to_i, day_of_week.to_i, transition_at)
else
DayOfMonthTransitionRule.new(month.to_i, week.to_i, day_of_week.to_i, transition_at)
end
rescue ArgumentError => e
raise InvalidPosixTimeZone, "Invalid #{type} rule in POSIX-style time zone string: #{e}"
end
end

# Returns an offset in seconds from hh:mm:ss values. The value can be
# negative. -02:33:12 would represent 2 hours, 33 minutes and 12 seconds
# ahead of UTC.
#
# @param h [String] the hours.
# @param m [String] the minutes.
# @param s [String] the seconds.
# @return [Integer] the offset.
# @raise [InvalidPosixTimeZone] if the mm and ss values are greater than
# 59.
def get_offset_from_hms(h, m, s)
h = h.to_i
m = m.to_i
s = s.to_i
raise InvalidPosixTimeZone, "Invalid minute #{m} in offset for POSIX-style time zone string." if m > 59
raise InvalidPosixTimeZone, "Invalid second #{s} in offset for POSIX-style time zone string." if s > 59
magnitude = (h.abs * 60 + m) * 60 + s
h < 0 ? -magnitude : magnitude
end

# Returns the seconds from midnight from hh:mm:ss values. Hours can exceed
# 24 for a time on the following day. Hours can be negative to subtract
# hours from midnight on the given day. -02:33:12 represents 22:33:12 on
# the prior day.
#
# @param h [String] the hour.
# @param m [String] the minutes past the hour.
# @param s [String] the seconds past the minute.
# @return [Integer] the number of seconds after midnight.
# @raise [InvalidPosixTimeZone] if the mm and ss values are greater than
# 59.
def get_seconds_after_midnight_from_hms(h, m, s)
h = h.to_i
m = m.to_i
s = s.to_i
raise InvalidPosixTimeZone, "Invalid minute #{m} in time for POSIX-style time zone string." if m > 59
raise InvalidPosixTimeZone, "Invalid second #{s} in time for POSIX-style time zone string." if s > 59
(h * 3600) + m * 60 + s
end

# Scans for a pattern and raises an exception if the pattern does not
# match the input.
#
# @param s [StringScanner] the `StringScanner` to scan.
# @param pattern [Regexp] the pattern to match.
# @return [String] the result of the scan.
# @raise [InvalidPosixTimeZone] if the pattern does not match the input.
def check_scan(s, pattern)
result = s.scan(pattern)
raise InvalidPosixTimeZone, "Expected '#{s.rest}' to match #{pattern} in POSIX-style time zone string." unless result
result
end
end
private_constant :PosixTimeZoneParser
end
end
5 changes: 4 additions & 1 deletion lib/tzinfo/data_sources/zoneinfo_data_source.rb
Expand Up @@ -237,7 +237,10 @@ def initialize(zoneinfo_dir = nil, alternate_iso3166_tab_path = nil)
@timezone_identifiers = load_timezone_identifiers.freeze
@countries = load_countries(iso3166_tab_path, zone_tab_path).freeze
@country_codes = @countries.keys.sort!.freeze
@zoneinfo_reader = ZoneinfoReader.new(ConcurrentStringDeduper.new)

string_deduper = ConcurrentStringDeduper.new
posix_tz_parser = PosixTimeZoneParser.new(string_deduper)
@zoneinfo_reader = ZoneinfoReader.new(posix_tz_parser, string_deduper)
end

# Returns a frozen `Array` of all the available time zone identifiers. The
Expand Down

0 comments on commit 08eba71

Please sign in to comment.