Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support "slim" zoneinfo files produced by default by zic >= 2020b.
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
Showing
11 changed files
with
2,609 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.