tzinfo 2.0.2 → 2.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28818edc06842caea3c6a7ee9fc63174498a3fd9f5d065324b3923ba20702ff6
4
- data.tar.gz: 1691bcf9786a63c21777f22326f79d27fd2cd6a19dd6cfa269e00bcc58ca394c
3
+ metadata.gz: 31e0ea9f4896e07430d20f4411903cad49ceb7bbde32bd156e3646376041c0d7
4
+ data.tar.gz: c91451850421ca0b8cf3b59b5b318a8e8e9b73ff9b87acf546fa28b907665f82
5
5
  SHA512:
6
- metadata.gz: f070f1ae1e08386d00a0825a035d7b7b598b2c9d47cf822be81c5778bcaca47c5541d602cf676aea6d476b49500b13a667a013b80f6bcd69a5acd9691dbba38a
7
- data.tar.gz: 2e263e61fa7178427178b6109a31e627b899b17d69c41ecfbb9978e98be7a520ba68b148cf903107ff244947b8b8e43b782daef70e103d2e1b81a416663771a7
6
+ metadata.gz: ed6bd3db29c977cb0d92e01551711e6b1285e3db54d4670893dc7716d54ce869ac5115faca62c7c6497eb656af1eecad71a6eedfcc8900eaf324ce2441ba0d01
7
+ data.tar.gz: 2675aef510ec569141e29c202d5a353590bd52e9fb9aa41489c467c204a6e203b43274f7035acf18d1bafe37765282dbbd0301f4dbf8e94aef01bcc623cb0fd9
Binary file
data.tar.gz.sig CHANGED
Binary file
data/CHANGES.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changes
2
2
 
3
+ ## Version 2.0.3 - 8-Nov-2020
4
+
5
+ * Added support for handling "slim" format zoneinfo files that are produced by
6
+ default by zic version 2020b and later. The POSIX-style TZ string is now used
7
+ calculate DST transition times after the final defined transition in the file.
8
+ #120.
9
+ * Fixed `TimeWithOffset#getlocal` returning a `TimeWithOffset` with the
10
+ `timezone_offset` still assigned when called with an offset argument on JRuby
11
+ 9.3.
12
+ * Rubinius is no longer supported.
13
+
14
+
3
15
  ## Version 2.0.2 - 2-Apr-2020
4
16
 
5
17
  * Fixed 'wrong number of arguments' errors when running on JRuby 9.0. #114.
@@ -162,6 +174,16 @@
162
174
  `TZInfo::Country.get('US').zone_identifiers` should be used instead.
163
175
 
164
176
 
177
+ ## Version 1.2.8 - 8-Nov-2020
178
+
179
+ * Added support for handling "slim" format zoneinfo files that are produced by
180
+ default by zic version 2020b and later. The POSIX-style TZ string is now used
181
+ calculate DST transition times after the final defined transition in the file.
182
+ The 64-bit section is now always used regardless of whether Time has support
183
+ for 64-bit times. #120.
184
+ * Rubinius is no longer supported.
185
+
186
+
165
187
  ## Version 1.2.7 - 2-Apr-2020
166
188
 
167
189
  * Fixed 'wrong number of arguments' errors when running on JRuby 9.0. #114.
@@ -302,6 +324,30 @@
302
324
  use other `TimezonePeriod` instance methods instead (issue #7655).
303
325
 
304
326
 
327
+ ## Version 0.3.58 (tzdata v2020d) - 8-Nov-2020
328
+
329
+ * Updated to tzdata version 2020d
330
+ (https://mm.icann.org/pipermail/tz-announce/2020-October/000062.html).
331
+
332
+
333
+ ## Version 0.3.57 (tzdata v2020a) - 17-May-2020
334
+
335
+ * Updated to tzdata version 2020a
336
+ (<https://mm.icann.org/pipermail/tz-announce/2020-April/000058.html>).
337
+
338
+
339
+ ## Version 0.3.56 (tzdata v2019c) - 1-Nov-2019
340
+
341
+ * Updated to tzdata version 2019c
342
+ (<https://mm.icann.org/pipermail/tz-announce/2019-September/000057.html>).
343
+
344
+
345
+ ## Version 0.3.55 (tzdata v2018g) - 27-Oct-2018
346
+
347
+ * Updated to tzdata version 2018g
348
+ (<https://mm.icann.org/pipermail/tz-announce/2018-October/000052.html>).
349
+
350
+
305
351
  ## Version 0.3.54 (tzdata v2018d) - 25-Mar-2018
306
352
 
307
353
  * Updated to tzdata version 2018d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TZInfo - Ruby Time Zone Library
2
2
 
3
- [![RubyGems](https://img.shields.io/gem/v/tzinfo)](https://rubygems.org/gems/tzinfo) [![Travis CI Build](https://img.shields.io/travis/tzinfo/tzinfo?logo=travis)](https://travis-ci.org/tzinfo/tzinfo) [![AppVeyor Build](https://img.shields.io/appveyor/build/philr/tzinfo?logo=appveyor)](https://ci.appveyor.com/project/philr/tzinfo)
3
+ [![RubyGems](https://img.shields.io/gem/v/tzinfo)](https://rubygems.org/gems/tzinfo) [![Travis CI Build](https://img.shields.io/travis/com/tzinfo/tzinfo?logo=travis)](https://travis-ci.com/github/tzinfo/tzinfo) [![AppVeyor Build](https://img.shields.io/appveyor/build/philr/tzinfo?logo=appveyor)](https://ci.appveyor.com/project/philr/tzinfo)
4
4
 
5
5
  [TZInfo](https://tzinfo.github.io) is a Ruby library that provides access to
6
6
  time zone data and allows times to be converted using time zone rules.
@@ -368,8 +368,8 @@ claims.
368
368
 
369
369
  ## Compatibility
370
370
 
371
- TZInfo v2.0.0 requires a minimum of Ruby MRI 1.9.3, JRuby 1.7 (in 1.9 mode or
372
- later) or Rubinius 3.
371
+ TZInfo v2.0.0 requires a minimum of Ruby MRI 1.9.3 or JRuby 1.7 (in 1.9 mode or
372
+ later).
373
373
 
374
374
 
375
375
  ## Thread-Safety
@@ -25,6 +25,8 @@ require_relative 'tzinfo/timestamp_with_offset'
25
25
 
26
26
  require_relative 'tzinfo/timezone_offset'
27
27
  require_relative 'tzinfo/timezone_transition'
28
+ require_relative 'tzinfo/transition_rule'
29
+ require_relative 'tzinfo/annual_rules'
28
30
 
29
31
  require_relative 'tzinfo/data_sources'
30
32
  require_relative 'tzinfo/data_sources/timezone_info'
@@ -35,6 +37,7 @@ require_relative 'tzinfo/data_sources/transitions_data_timezone_info'
35
37
 
36
38
  require_relative 'tzinfo/data_sources/country_info'
37
39
 
40
+ require_relative 'tzinfo/data_sources/posix_time_zone_parser'
38
41
  require_relative 'tzinfo/data_sources/zoneinfo_reader'
39
42
 
40
43
  require_relative 'tzinfo/data_source'
@@ -0,0 +1,71 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module TZInfo
5
+ # A set of rules that define when transitions occur in time zones with
6
+ # annually occurring daylight savings time.
7
+ #
8
+ # @private
9
+ class AnnualRules #:nodoc:
10
+ # @return [TimezoneOffset] the standard offset that applies when daylight
11
+ # savings time is not in force.
12
+ attr_reader :std_offset
13
+
14
+ # @return [TimezoneOffset] the offset that applies when daylight savings
15
+ # time is in force.
16
+ attr_reader :dst_offset
17
+
18
+ # @return [TransitionRule] the rule that determines when daylight savings
19
+ # time starts.
20
+ attr_reader :dst_start_rule
21
+
22
+ # @return [TransitionRule] the rule that determines when daylight savings
23
+ # time ends.
24
+ attr_reader :dst_end_rule
25
+
26
+ # Initializes a new {AnnualRules} instance.
27
+ #
28
+ # @param std_offset [TimezoneOffset] the standard offset that applies when
29
+ # daylight savings time is not in force.
30
+ # @param dst_offset [TimezoneOffset] the offset that applies when daylight
31
+ # savings time is in force.
32
+ # @param dst_start_rule [TransitionRule] the rule that determines when
33
+ # daylight savings time starts.
34
+ # @param dst_end_rule [TransitionRule] the rule that determines when daylight
35
+ # savings time ends.
36
+ def initialize(std_offset, dst_offset, dst_start_rule, dst_end_rule)
37
+ @std_offset = std_offset
38
+ @dst_offset = dst_offset
39
+ @dst_start_rule = dst_start_rule
40
+ @dst_end_rule = dst_end_rule
41
+ end
42
+
43
+ # Returns the transitions between standard and daylight savings time for a
44
+ # given year. The results are ordered by time of occurrence (earliest to
45
+ # latest).
46
+ #
47
+ # @param year [Integer] the year to calculate transitions for.
48
+ # @return [Array<TimezoneTransition>] the transitions for the year.
49
+ def transitions(year)
50
+ start_dst = apply_rule(@dst_start_rule, @std_offset, @dst_offset, year)
51
+ end_dst = apply_rule(@dst_end_rule, @dst_offset, @std_offset, year)
52
+
53
+ end_dst.timestamp_value < start_dst.timestamp_value ? [end_dst, start_dst] : [start_dst, end_dst]
54
+ end
55
+
56
+ private
57
+
58
+ # Applies a given rule between offsets on a year.
59
+ #
60
+ # @param rule [TransitionRule] the rule to apply.
61
+ # @param from_offset [TimezoneOffset] the offset the rule transitions from.
62
+ # @param to_offset [TimezoneOffset] the offset the rule transitions to.
63
+ # @param year [Integer] the year when the transition occurs.
64
+ # @return [TimezoneTransition] the transition determined by the rule.
65
+ def apply_rule(rule, from_offset, to_offset, year)
66
+ at = rule.at(from_offset, year)
67
+ TimezoneTransition.new(to_offset, from_offset, at.value)
68
+ end
69
+ end
70
+ private_constant :AnnualRules
71
+ end
@@ -0,0 +1,181 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'strscan'
5
+
6
+ module TZInfo
7
+ # Use send as a workaround for erroneous 'wrong number of arguments' errors
8
+ # with JRuby 9.0.5.0 when calling methods with Java implementations. See #114.
9
+ send(:using, UntaintExt) if TZInfo.const_defined?(:UntaintExt)
10
+
11
+ module DataSources
12
+ # An {InvalidPosixTimeZone} exception is raised if an invalid POSIX-style
13
+ # time zone string is encountered.
14
+ #
15
+ # @private
16
+ class InvalidPosixTimeZone < StandardError #:nodoc:
17
+ end
18
+ private_constant :InvalidPosixTimeZone
19
+
20
+ # A parser for POSIX-style TZ strings used in zoneinfo files and specified
21
+ # by tzfile.5 and tzset.3.
22
+ #
23
+ # @private
24
+ class PosixTimeZoneParser #:nodoc:
25
+ # Initializes a new {PosixTimeZoneParser}.
26
+ #
27
+ # @param string_deduper [StringDeduper] a {StringDeduper} instance to use
28
+ # to dedupe abbreviations.
29
+ def initialize(string_deduper)
30
+ @string_deduper = string_deduper
31
+ end
32
+
33
+ # Parses a POSIX-style TZ string.
34
+ #
35
+ # @param tz_string [String] the string to parse.
36
+ # @return [Object] either a {TimezoneOffset} for a constantly applied
37
+ # offset or an {AnnualRules} instance representing the rules.
38
+ # @raise [InvalidPosixTimeZone] if `tz_string` is not a `String`.
39
+ # @raise [InvalidPosixTimeZone] if `tz_string` is is not valid.
40
+ def parse(tz_string)
41
+ raise InvalidPosixTimeZone unless tz_string.kind_of?(String)
42
+ return nil if tz_string.empty?
43
+
44
+ s = StringScanner.new(tz_string)
45
+ check_scan(s, /([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
46
+ std_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint)
47
+ check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
48
+ std_offset = get_offset_from_hms(s[1], s[2], s[3])
49
+
50
+ if s.scan(/([^-+,\d<][^-+,\d]*) | <([^>]+)>/x)
51
+ dst_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint)
52
+
53
+ if s.scan(/([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
54
+ dst_offset = get_offset_from_hms(s[1], s[2], s[3])
55
+ else
56
+ # POSIX is negative for ahead of UTC.
57
+ dst_offset = std_offset - 3600
58
+ end
59
+
60
+ dst_difference = std_offset - dst_offset
61
+
62
+ start_rule = parse_rule(s, 'start')
63
+ end_rule = parse_rule(s, 'end')
64
+
65
+ raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." if s.rest?
66
+
67
+ if start_rule.is_always_first_day_of_year? && start_rule.transition_at == 0 &&
68
+ end_rule.is_always_last_day_of_year? && end_rule.transition_at == 86400 + dst_difference
69
+ # Constant daylight savings time.
70
+ # POSIX is negative for ahead of UTC.
71
+ TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev)
72
+ else
73
+ AnnualRules.new(
74
+ TimezoneOffset.new(-std_offset, 0, std_abbrev),
75
+ TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev),
76
+ start_rule,
77
+ end_rule)
78
+ end
79
+ elsif !s.rest?
80
+ # Constant standard time.
81
+ # POSIX is negative for ahead of UTC.
82
+ TimezoneOffset.new(-std_offset, 0, std_abbrev)
83
+ else
84
+ raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'."
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ # Parses a rule.
91
+ #
92
+ # @param s [StringScanner] the `StringScanner` to read the rule from.
93
+ # @param type [String] the type of rule (either `'start'` or `'end'`).
94
+ # @raise [InvalidPosixTimeZone] if the rule is not valid.
95
+ # @return [TransitionRule] the parsed rule.
96
+ def parse_rule(s, type)
97
+ check_scan(s, /,(?: (?: J(\d+) ) | (\d+) | (?: M(\d+)\.(\d)\.(\d) ) )/x)
98
+ julian_day_of_year = s[1]
99
+ absolute_day_of_year = s[2]
100
+ month = s[3]
101
+ week = s[4]
102
+ day_of_week = s[5]
103
+
104
+ if s.scan(/\//)
105
+ check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/)
106
+ transition_at = get_seconds_after_midnight_from_hms(s[1], s[2], s[3])
107
+ else
108
+ transition_at = 7200
109
+ end
110
+
111
+ begin
112
+ if julian_day_of_year
113
+ JulianDayOfYearTransitionRule.new(julian_day_of_year.to_i, transition_at)
114
+ elsif absolute_day_of_year
115
+ AbsoluteDayOfYearTransitionRule.new(absolute_day_of_year.to_i, transition_at)
116
+ elsif week == '5'
117
+ LastDayOfMonthTransitionRule.new(month.to_i, day_of_week.to_i, transition_at)
118
+ else
119
+ DayOfMonthTransitionRule.new(month.to_i, week.to_i, day_of_week.to_i, transition_at)
120
+ end
121
+ rescue ArgumentError => e
122
+ raise InvalidPosixTimeZone, "Invalid #{type} rule in POSIX-style time zone string: #{e}"
123
+ end
124
+ end
125
+
126
+ # Returns an offset in seconds from hh:mm:ss values. The value can be
127
+ # negative. -02:33:12 would represent 2 hours, 33 minutes and 12 seconds
128
+ # ahead of UTC.
129
+ #
130
+ # @param h [String] the hours.
131
+ # @param m [String] the minutes.
132
+ # @param s [String] the seconds.
133
+ # @return [Integer] the offset.
134
+ # @raise [InvalidPosixTimeZone] if the mm and ss values are greater than
135
+ # 59.
136
+ def get_offset_from_hms(h, m, s)
137
+ h = h.to_i
138
+ m = m.to_i
139
+ s = s.to_i
140
+ raise InvalidPosixTimeZone, "Invalid minute #{m} in offset for POSIX-style time zone string." if m > 59
141
+ raise InvalidPosixTimeZone, "Invalid second #{s} in offset for POSIX-style time zone string." if s > 59
142
+ magnitude = (h.abs * 60 + m) * 60 + s
143
+ h < 0 ? -magnitude : magnitude
144
+ end
145
+
146
+ # Returns the seconds from midnight from hh:mm:ss values. Hours can exceed
147
+ # 24 for a time on the following day. Hours can be negative to subtract
148
+ # hours from midnight on the given day. -02:33:12 represents 22:33:12 on
149
+ # the prior day.
150
+ #
151
+ # @param h [String] the hour.
152
+ # @param m [String] the minutes past the hour.
153
+ # @param s [String] the seconds past the minute.
154
+ # @return [Integer] the number of seconds after midnight.
155
+ # @raise [InvalidPosixTimeZone] if the mm and ss values are greater than
156
+ # 59.
157
+ def get_seconds_after_midnight_from_hms(h, m, s)
158
+ h = h.to_i
159
+ m = m.to_i
160
+ s = s.to_i
161
+ raise InvalidPosixTimeZone, "Invalid minute #{m} in time for POSIX-style time zone string." if m > 59
162
+ raise InvalidPosixTimeZone, "Invalid second #{s} in time for POSIX-style time zone string." if s > 59
163
+ (h * 3600) + m * 60 + s
164
+ end
165
+
166
+ # Scans for a pattern and raises an exception if the pattern does not
167
+ # match the input.
168
+ #
169
+ # @param s [StringScanner] the `StringScanner` to scan.
170
+ # @param pattern [Regexp] the pattern to match.
171
+ # @return [String] the result of the scan.
172
+ # @raise [InvalidPosixTimeZone] if the pattern does not match the input.
173
+ def check_scan(s, pattern)
174
+ result = s.scan(pattern)
175
+ raise InvalidPosixTimeZone, "Expected '#{s.rest}' to match #{pattern} in POSIX-style time zone string." unless result
176
+ result
177
+ end
178
+ end
179
+ private_constant :PosixTimeZoneParser
180
+ end
181
+ end
@@ -237,7 +237,10 @@ module TZInfo
237
237
  @timezone_identifiers = load_timezone_identifiers.freeze
238
238
  @countries = load_countries(iso3166_tab_path, zone_tab_path).freeze
239
239
  @country_codes = @countries.keys.sort!.freeze
240
- @zoneinfo_reader = ZoneinfoReader.new(ConcurrentStringDeduper.new)
240
+
241
+ string_deduper = ConcurrentStringDeduper.new
242
+ posix_tz_parser = PosixTimeZoneParser.new(string_deduper)
243
+ @zoneinfo_reader = ZoneinfoReader.new(posix_tz_parser, string_deduper)
241
244
  end
242
245
 
243
246
  # Returns a frozen `Array` of all the available time zone identifiers. The
@@ -14,11 +14,20 @@ module TZInfo
14
14
 
15
15
  # Reads compiled zoneinfo TZif (\0, 2 or 3) files.
16
16
  class ZoneinfoReader #:nodoc:
17
+ # The year to generate transitions up to.
18
+ #
19
+ # @private
20
+ GENERATE_UP_TO = Time.now.utc.year + 100
21
+ private_constant :GENERATE_UP_TO
22
+
17
23
  # Initializes a new {ZoneinfoReader}.
18
24
  #
25
+ # @param posix_tz_parser [PosixTimeZoneParser] a {PosixTimeZoneParser}
26
+ # instance to use to parse POSIX-style TZ strings.
19
27
  # @param string_deduper [StringDeduper] a {StringDeduper} instance to use
20
- # when deduping abbreviations.
21
- def initialize(string_deduper)
28
+ # to dedupe abbreviations.
29
+ def initialize(posix_tz_parser, string_deduper)
30
+ @posix_tz_parser = posix_tz_parser
22
31
  @string_deduper = string_deduper
23
32
  end
24
33
 
@@ -163,6 +172,168 @@ module TZInfo
163
172
  first_offset_index
164
173
  end
165
174
 
175
+ # Determines if the offset from a transition matches the offset from a
176
+ # rule. This is a looser match than equality, not requiring that the
177
+ # base_utc_offset and std_offset both match (which have to be derived for
178
+ # transitions, but are known for rules.
179
+ #
180
+ # @param offset [TimezoneOffset] an offset from a transition.
181
+ # @param rule_offset [TimezoneOffset] an offset from a rule.
182
+ # @return [Boolean] whether the offsets match.
183
+ def offset_matches_rule?(offset, rule_offset)
184
+ offset.observed_utc_offset == rule_offset.observed_utc_offset &&
185
+ offset.dst? == rule_offset.dst? &&
186
+ offset.abbreviation == rule_offset.abbreviation
187
+ end
188
+
189
+ # Apply the rules from the TZ string when there were no defined
190
+ # transitions. Checks for a matching offset. Returns the rules-based
191
+ # constant offset or generates transitions from 1970 until 100 years into
192
+ # the future (at the time of loading zoneinfo_reader.rb).
193
+ #
194
+ # @param file [IO] the file being processed.
195
+ # @param first_offset [TimezoneOffset] the first offset included in the
196
+ # file that would normally apply without the rules.
197
+ # @param rules [Object] a {TimezoneOffset} specifying a constant offset or
198
+ # {AnnualRules} instance specfying transitions.
199
+ # @return [Object] either a {TimezoneOffset} or an `Array` of
200
+ # {TimezoneTransition}s.
201
+ # @raise [InvalidZoneinfoFile] if the first offset does not match the
202
+ # rules.
203
+ def apply_rules_without_transitions(file, first_offset, rules)
204
+ if rules.kind_of?(TimezoneOffset)
205
+ unless offset_matches_rule?(first_offset, rules)
206
+ raise InvalidZoneinfoFile, "Constant offset POSIX-style TZ string does not match constant offset in file '#{file.path}'."
207
+ end
208
+ rules
209
+ else
210
+ transitions = 1970.upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) }
211
+ first_transition = transitions[0]
212
+
213
+ unless offset_matches_rule?(first_offset, first_transition.previous_offset)
214
+ # Not transitioning from the designated first offset.
215
+
216
+ if offset_matches_rule?(first_offset, first_transition.offset)
217
+ # Skip an unnecessary transition to the first offset.
218
+ transitions.shift
219
+ else
220
+ # The initial offset doesn't match the ongoing rules. Replace the
221
+ # previous offset of the first transition.
222
+ transitions[0] = TimezoneTransition.new(first_transition.offset, first_offset, first_transition.timestamp_value)
223
+ end
224
+ end
225
+
226
+ transitions
227
+ end
228
+ end
229
+
230
+ # Finds an offset that is equivalent to the one specified in the given
231
+ # `Array`. Matching is performed with {TimezoneOffset#==}.
232
+ #
233
+ # @param offsets [Array<TimezoneOffset>] an `Array` to search.
234
+ # @param offset [TimezoneOffset] the offset to search for.
235
+ # @return [TimezoneOffset] the matching offset from `offsets` or `nil`
236
+ # if not found.
237
+ def find_existing_offset(offsets, offset)
238
+ offsets.find {|o| o == offset }
239
+ end
240
+
241
+ # Returns a new AnnualRules instance with standard and daylight savings
242
+ # offsets replaced with equivalents from an array. This reduces the memory
243
+ # requirement for loaded time zones by reusing offsets for rule-generated
244
+ # transitions.
245
+ #
246
+ # @param offsets [Array<TimezoneOffset>] an `Array` to search for
247
+ # equivalent offsets.
248
+ # @param annual_rules [AnnualRules] the {AnnualRules} instance to check.
249
+ # @return [AnnualRules] either a new {AnnualRules} instance with either
250
+ # the {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset
251
+ # dst_offset} replaced, or the original instance if no equivalent for
252
+ # either {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset
253
+ # dst_offset} could be found.
254
+ def replace_with_existing_offsets(offsets, annual_rules)
255
+ existing_std_offset = find_existing_offset(offsets, annual_rules.std_offset)
256
+ existing_dst_offset = find_existing_offset(offsets, annual_rules.dst_offset)
257
+ if existing_std_offset || existing_dst_offset
258
+ AnnualRules.new(existing_std_offset || annual_rules.std_offset, existing_dst_offset || annual_rules.dst_offset,
259
+ annual_rules.dst_start_rule, annual_rules.dst_end_rule)
260
+ else
261
+ annual_rules
262
+ end
263
+ end
264
+
265
+ # Validates the offset indicated to be observed by the rules before the
266
+ # first generated transition against the offset of the last defined
267
+ # transition.
268
+ #
269
+ # Fix the last defined transition if it differ on just base/std offsets
270
+ # (which are derived). Raise an error if the observed UTC offset or
271
+ # abbreviations differ.
272
+ #
273
+ # @param file [IO] the file being processed.
274
+ # @param last_defined [TimezoneTransition] the last defined transition in
275
+ # the file.
276
+ # @param first_rule_offset [TimezoneOffset] the offset the rules indicate
277
+ # is observed prior to the first rules generated transition.
278
+ # @return [TimezoneTransition] the last defined transition (either the
279
+ # original instance or a replacement).
280
+ # @raise [InvalidZoneinfoFile] if the offset of {last_defined} and
281
+ # {first_rule_offset} do not match.
282
+ def validate_and_fix_last_defined_transition_offset(file, last_defined, first_rule_offset)
283
+ offset_of_last_defined = last_defined.offset
284
+
285
+ if offset_of_last_defined == first_rule_offset
286
+ last_defined
287
+ else
288
+ if offset_matches_rule?(offset_of_last_defined, first_rule_offset)
289
+ # The same overall offset, but differing in the base or std
290
+ # offset (which are derived). Correct by using the rule.
291
+ TimezoneTransition.new(first_rule_offset, last_defined.previous_offset, last_defined.timestamp_value)
292
+ else
293
+ raise InvalidZoneinfoFile, "The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{file.path}'."
294
+ end
295
+ end
296
+ end
297
+
298
+ # Apply the rules from the TZ string when there were defined
299
+ # transitions. Checks for a matching offset with the last transition.
300
+ # Redefines the last transition if required and if the rules don't
301
+ # specific a constant offset, generates transitions until 100 years into
302
+ # the future (at the time of loading zoneinfo_reader.rb).
303
+ #
304
+ # @param file [IO] the file being processed.
305
+ # @param transitions [Array<TimezoneTransition>] the defined transitions.
306
+ # @param offsets [Array<TimezoneOffset>] the offsets used by the defined
307
+ # transitions.
308
+ # @param rules [Object] a {TimezoneOffset} specifying a constant offset or
309
+ # {AnnualRules} instance specfying transitions.
310
+ # @raise [InvalidZoneinfoFile] if the first offset does not match the
311
+ # rules.
312
+ # @raise [InvalidZoneinfoFile] if the previous offset of the first
313
+ # generated transition does not match the offset of the last defined
314
+ # transition.
315
+ def apply_rules_with_transitions(file, transitions, offsets, rules)
316
+ last_defined = transitions[-1]
317
+
318
+ if rules.kind_of?(TimezoneOffset)
319
+ transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, rules)
320
+ else
321
+ last_year = last_defined.local_end_at.to_time.year
322
+
323
+ if last_year <= GENERATE_UP_TO
324
+ rules = replace_with_existing_offsets(offsets, rules)
325
+
326
+ generated = rules.transitions(last_year).find_all {|t| t.timestamp_value > last_defined.timestamp_value } +
327
+ (last_year + 1).upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) }
328
+
329
+ unless generated.empty?
330
+ transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, generated[0].previous_offset)
331
+ transitions.concat(generated)
332
+ end
333
+ end
334
+ end
335
+ end
336
+
166
337
  # Parses a zoneinfo file and returns either a {TimezoneOffset} that is
167
338
  # constantly observed or an `Array` of {TimezoneTransition}s.
168
339
  #
@@ -171,7 +342,7 @@ module TZInfo
171
342
  # {TimezoneTransition}s.
172
343
  # @raise [InvalidZoneinfoFile] if the file is not a valid zoneinfo file.
173
344
  def parse(file)
174
- magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
345
+ magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
175
346
  check_read(file, 44).unpack('a4 a x15 NNNNNN')
176
347
 
177
348
  if magic != 'TZif'
@@ -180,11 +351,11 @@ module TZInfo
180
351
 
181
352
  if version == '2' || version == '3'
182
353
  # Skip the first 32-bit section and read the header of the second 64-bit section
183
- file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisgmtcnt + ttisstdcnt, IO::SEEK_CUR)
354
+ file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisstdcnt + ttisutccnt, IO::SEEK_CUR)
184
355
 
185
356
  prev_version = version
186
357
 
187
- magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
358
+ magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
188
359
  check_read(file, 44).unpack('a4 a x15 NNNNNN')
189
360
 
190
361
  unless magic == 'TZif' && (version == prev_version)
@@ -229,6 +400,23 @@ module TZInfo
229
400
 
230
401
  abbrev = check_read(file, charcnt)
231
402
 
403
+ if using_64bit
404
+ # Skip to the POSIX-style TZ string.
405
+ file.seek(ttisstdcnt + ttisutccnt, IO::SEEK_CUR) # + leapcnt * 8, but leapcnt is checked above and guaranteed to be 0.
406
+ tz_string_start = check_read(file, 1)
407
+ raise InvalidZoneinfoFile, "Expected newline starting POSIX-style TZ string in file '#{file.path}'." unless tz_string_start == "\n"
408
+ tz_string = file.readline("\n").force_encoding(Encoding::UTF_8)
409
+ raise InvalidZoneinfoFile, "Expected newline ending POSIX-style TZ string in file '#{file.path}'." unless tz_string.chomp!("\n")
410
+
411
+ begin
412
+ rules = @posix_tz_parser.parse(tz_string)
413
+ rescue InvalidPosixTimeZone => e
414
+ raise InvalidZoneinfoFile, "Failed to parse POSIX-style TZ string in file '#{file.path}': #{e}"
415
+ end
416
+ else
417
+ rules = nil
418
+ end
419
+
232
420
  # Derive the offsets from standard time (std_offset).
233
421
  first_offset_index = derive_offsets(transitions, offsets)
234
422
 
@@ -266,12 +454,16 @@ module TZInfo
266
454
 
267
455
 
268
456
  if transitions.empty?
269
- first_offset
457
+ if rules
458
+ apply_rules_without_transitions(file, first_offset, rules)
459
+ else
460
+ first_offset
461
+ end
270
462
  else
271
463
  previous_offset = first_offset
272
464
  previous_at = nil
273
465
 
274
- transitions.map do |t|
466
+ transitions = transitions.map do |t|
275
467
  offset = offsets[t[:offset]]
276
468
  at = t[:at]
277
469
  raise InvalidZoneinfoFile, "Transition at #{at} is not later than the previous transition at #{previous_at} in file '#{file.path}'." if previous_at && previous_at >= at
@@ -280,6 +472,9 @@ module TZInfo
280
472
  previous_at = at
281
473
  tt
282
474
  end
475
+
476
+ apply_rules_with_transitions(file, transitions, offsets, rules) if rules
477
+ transitions
283
478
  end
284
479
  end
285
480
  end
@@ -46,6 +46,22 @@ module TZInfo
46
46
  end
47
47
  alias isdst dst?
48
48
 
49
+ # An overridden version of `Time#getlocal` that clears the associated
50
+ # {TimezoneOffset} if the base implementation of `getlocal` returns a
51
+ # {TimeWithOffset}.
52
+ #
53
+ # @return [Time] a representation of the {TimeWithOffset} using either the
54
+ # local time zone or the given offset.
55
+ def getlocal(*args)
56
+ # JRuby < 9.3 returns a Time in all cases.
57
+ # JRuby >= 9.3 returns a Time when called with no arguments and a
58
+ # TimeWithOffset with a timezone_offset assigned when called with an
59
+ # offset argument.
60
+ result = super
61
+ result.clear_timezone_offset if result.kind_of?(TimeWithOffset)
62
+ result
63
+ end
64
+
49
65
  # An overridden version of `Time#gmtime` that clears the associated
50
66
  # {TimezoneOffset}.
51
67
  #
@@ -124,5 +140,15 @@ module TZInfo
124
140
  result.set_timezone_offset(o)
125
141
  end
126
142
  end
143
+
144
+ protected
145
+
146
+ # Clears the associated {TimezoneOffset}.
147
+ #
148
+ # @return [TimeWithOffset] `self`.
149
+ def clear_timezone_offset
150
+ @timezone_offset = nil
151
+ self
152
+ end
127
153
  end
128
154
  end
@@ -0,0 +1,455 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module TZInfo
5
+ # Base class for rules definining the transition between standard and daylight
6
+ # savings time.
7
+ #
8
+ # @abstract
9
+ # @private
10
+ class TransitionRule #:nodoc:
11
+ # Returns the number of seconds after midnight local time on the day
12
+ # identified by the rule at which the transition occurs. Can be negative to
13
+ # denote a time on the prior day. Can be greater than or equal to 86,400 to
14
+ # denote a time of the following day.
15
+ #
16
+ # @return [Integer] the time in seconds after midnight local time at which
17
+ # the transition occurs.
18
+ attr_reader :transition_at
19
+
20
+ # Initializes a new {TransitionRule}.
21
+ #
22
+ # @param transition_at [Integer] the time in seconds after midnight local
23
+ # time at which the transition occurs.
24
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
25
+ def initialize(transition_at)
26
+ raise ArgumentError, 'Invalid transition_at' unless transition_at.kind_of?(Integer)
27
+ @transition_at = transition_at
28
+ end
29
+
30
+ # Calculates the time of the transition from a given offset on a given year.
31
+ #
32
+ # @param offset [TimezoneOffset] the current offset at the time the rule
33
+ # will transition.
34
+ # @param year [Integer] the year in which the transition occurs (local
35
+ # time).
36
+ # @return [TimestampWithOffset] the time at which the transition occurs.
37
+ def at(offset, year)
38
+ day = get_day(offset, year)
39
+ TimestampWithOffset.set_timezone_offset(Timestamp.for(day + @transition_at), offset)
40
+ end
41
+
42
+ # Determines if this {TransitionRule} is equal to another instance.
43
+ #
44
+ # @param r [Object] the instance to test for equality.
45
+ # @return [Boolean] `true` if `r` is a {TransitionRule} with the same
46
+ # {transition_at} as this {TransitionRule}, otherwise `false`.
47
+ def ==(r)
48
+ r.kind_of?(TransitionRule) && @transition_at == r.transition_at
49
+ end
50
+ alias eql? ==
51
+
52
+ # @return [Integer] a hash based on {hash_args} (defaulting to
53
+ # {transition_at}).
54
+ def hash
55
+ hash_args.hash
56
+ end
57
+
58
+ protected
59
+
60
+ # @return [Array] an `Array` of parameters that will influence the output of
61
+ # {hash}.
62
+ def hash_args
63
+ [@transition_at]
64
+ end
65
+ end
66
+ private_constant :TransitionRule
67
+
68
+ # A base class for transition rules that activate based on an integer day of
69
+ # the year.
70
+ #
71
+ # @abstract
72
+ # @private
73
+ class DayOfYearTransitionRule < TransitionRule #:nodoc:
74
+ # Initializes a new {DayOfYearTransitionRule}.
75
+ #
76
+ # @param day [Integer] the day of the year on which the transition occurs.
77
+ # The precise meaning is defined by subclasses.
78
+ # @param transition_at [Integer] the time in seconds after midnight local
79
+ # time at which the transition occurs.
80
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
81
+ # @raise [ArgumentError] if `day` is not an `Integer`.
82
+ def initialize(day, transition_at)
83
+ super(transition_at)
84
+ raise ArgumentError, 'Invalid day' unless day.kind_of?(Integer)
85
+ @seconds = day * 86400
86
+ end
87
+
88
+ # Determines if this {DayOfYearTransitionRule} is equal to another instance.
89
+ #
90
+ # @param r [Object] the instance to test for equality.
91
+ # @return [Boolean] `true` if `r` is a {DayOfYearTransitionRule} with the
92
+ # same {transition_at} and day as this {DayOfYearTransitionRule},
93
+ # otherwise `false`.
94
+ def ==(r)
95
+ super(r) && r.kind_of?(DayOfYearTransitionRule) && @seconds == r.seconds
96
+ end
97
+ alias eql? ==
98
+
99
+ protected
100
+
101
+ # @return [Integer] the day multipled by the number of seconds in a day.
102
+ attr_reader :seconds
103
+
104
+ # (see TransitionRule#hash_args)
105
+ def hash_args
106
+ [@seconds] + super
107
+ end
108
+ end
109
+ private_constant :DayOfYearTransitionRule
110
+
111
+ # Defines transitions that occur on the zero-based nth day of the year.
112
+ #
113
+ # Day 0 is 1 January.
114
+ #
115
+ # Leap days are counted. Day 59 will be 29 February on a leap year and 1 March
116
+ # on a non-leap year. Day 365 will be 31 December on a leap year and 1 January
117
+ # the following year on a non-leap year.
118
+ #
119
+ # @private
120
+ class AbsoluteDayOfYearTransitionRule < DayOfYearTransitionRule #:nodoc:
121
+ # Initializes a new {AbsoluteDayOfYearTransitionRule}.
122
+ #
123
+ # @param day [Integer] the zero-based day of the year on which the
124
+ # transition occurs (0 to 365 inclusive).
125
+ # @param transition_at [Integer] the time in seconds after midnight local
126
+ # time at which the transition occurs.
127
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
128
+ # @raise [ArgumentError] if `day` is not an `Integer`.
129
+ # @raise [ArgumentError] if `day` is less than 0 or greater than 365.
130
+ def initialize(day, transition_at = 0)
131
+ super(day, transition_at)
132
+ raise ArgumentError, 'Invalid day' unless day >= 0 && day <= 365
133
+ end
134
+
135
+ # @return [Boolean] `true` if the day specified by this transition is the
136
+ # first in the year (a day number of 0), otherwise `false`.
137
+ def is_always_first_day_of_year?
138
+ seconds == 0
139
+ end
140
+
141
+ # @return [Boolean] `false`.
142
+ def is_always_last_day_of_year?
143
+ false
144
+ end
145
+
146
+ # Determines if this {AbsoluteDayOfYearTransitionRule} is equal to another
147
+ # instance.
148
+ #
149
+ # @param r [Object] the instance to test for equality.
150
+ # @return [Boolean] `true` if `r` is a {AbsoluteDayOfYearTransitionRule}
151
+ # with the same {transition_at} and day as this
152
+ # {AbsoluteDayOfYearTransitionRule}, otherwise `false`.
153
+ def ==(r)
154
+ super(r) && r.kind_of?(AbsoluteDayOfYearTransitionRule)
155
+ end
156
+ alias eql? ==
157
+
158
+ protected
159
+
160
+ # Returns a `Time` representing midnight local time on the day specified by
161
+ # the rule for the given offset and year.
162
+ #
163
+ # @param offset [TimezoneOffset] the current offset at the time of the
164
+ # transition.
165
+ # @param year [Integer] the year in which the transition occurs.
166
+ # @return [Time] midnight local time on the day specified by the rule for
167
+ # the given offset and year.
168
+ def get_day(offset, year)
169
+ Time.new(year, 1, 1, 0, 0, 0, offset.observed_utc_offset) + seconds
170
+ end
171
+
172
+ # (see TransitionRule#hash_args)
173
+ def hash_args
174
+ [AbsoluteDayOfYearTransitionRule] + super
175
+ end
176
+ end
177
+
178
+ # Defines transitions that occur on the one-based nth Julian day of the year.
179
+ #
180
+ # Leap days are not counted. Day 1 is 1 January. Day 60 is always 1 March.
181
+ # Day 365 is always 31 December.
182
+ #
183
+ # @private
184
+ class JulianDayOfYearTransitionRule < DayOfYearTransitionRule #:nodoc:
185
+ # The 60 days in seconds.
186
+ LEAP = 60 * 86400
187
+ private_constant :LEAP
188
+
189
+ # The length of a non-leap year in seconds.
190
+ YEAR = 365 * 86400
191
+ private_constant :YEAR
192
+
193
+ # Initializes a new {JulianDayOfYearTransitionRule}.
194
+ #
195
+ # @param day [Integer] the one-based Julian day of the year on which the
196
+ # transition occurs (1 to 365 inclusive).
197
+ # @param transition_at [Integer] the time in seconds after midnight local
198
+ # time at which the transition occurs.
199
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
200
+ # @raise [ArgumentError] if `day` is not an `Integer`.
201
+ # @raise [ArgumentError] if `day` is less than 1 or greater than 365.
202
+ def initialize(day, transition_at = 0)
203
+ super(day, transition_at)
204
+ raise ArgumentError, 'Invalid day' unless day >= 1 && day <= 365
205
+ end
206
+
207
+ # @return [Boolean] `true` if the day specified by this transition is the
208
+ # first in the year (a day number of 1), otherwise `false`.
209
+ def is_always_first_day_of_year?
210
+ seconds == 86400
211
+ end
212
+
213
+ # @return [Boolean] `true` if the day specified by this transition is the
214
+ # last in the year (a day number of 365), otherwise `false`.
215
+ def is_always_last_day_of_year?
216
+ seconds == YEAR
217
+ end
218
+
219
+ # Determines if this {JulianDayOfYearTransitionRule} is equal to another
220
+ # instance.
221
+ #
222
+ # @param r [Object] the instance to test for equality.
223
+ # @return [Boolean] `true` if `r` is a {JulianDayOfYearTransitionRule} with
224
+ # the same {transition_at} and day as this
225
+ # {JulianDayOfYearTransitionRule}, otherwise `false`.
226
+ def ==(r)
227
+ super(r) && r.kind_of?(JulianDayOfYearTransitionRule)
228
+ end
229
+ alias eql? ==
230
+
231
+ protected
232
+
233
+ # Returns a `Time` representing midnight local time on the day specified by
234
+ # the rule for the given offset and year.
235
+ #
236
+ # @param offset [TimezoneOffset] the current offset at the time of the
237
+ # transition.
238
+ # @param year [Integer] the year in which the transition occurs.
239
+ # @return [Time] midnight local time on the day specified by the rule for
240
+ # the given offset and year.
241
+ def get_day(offset, year)
242
+ # Returns 1 March on non-leap years.
243
+ leap = Time.new(year, 2, 29, 0, 0, 0, offset.observed_utc_offset)
244
+ diff = seconds - LEAP
245
+ diff += 86400 if diff >= 0 && leap.mday == 29
246
+ leap + diff
247
+ end
248
+
249
+ # (see TransitionRule#hash_args)
250
+ def hash_args
251
+ [JulianDayOfYearTransitionRule] + super
252
+ end
253
+ end
254
+ private_constant :JulianDayOfYearTransitionRule
255
+
256
+ # A base class for rules that transition on a particular day of week of a
257
+ # given week (subclasses specify which week of the month).
258
+ #
259
+ # @abstract
260
+ # @private
261
+ class DayOfWeekTransitionRule < TransitionRule #:nodoc:
262
+ # Initializes a new {DayOfWeekTransitionRule}.
263
+ #
264
+ # @param month [Integer] the month of the year when the transition occurs.
265
+ # @param day_of_week [Integer] the day of the week when the transition
266
+ # occurs. 0 is Sunday, 6 is Saturday.
267
+ # @param transition_at [Integer] the time in seconds after midnight local
268
+ # time at which the transition occurs.
269
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
270
+ # @raise [ArgumentError] if `month` is not an `Integer`.
271
+ # @raise [ArgumentError] if `month` is less than 1 or greater than 12.
272
+ # @raise [ArgumentError] if `day_of_week` is not an `Integer`.
273
+ # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6.
274
+ def initialize(month, day_of_week, transition_at)
275
+ super(transition_at)
276
+ raise ArgumentError, 'Invalid month' unless month.kind_of?(Integer) && month >= 1 && month <= 12
277
+ raise ArgumentError, 'Invalid day_of_week' unless day_of_week.kind_of?(Integer) && day_of_week >= 0 && day_of_week <= 6
278
+ @month = month
279
+ @day_of_week = day_of_week
280
+ end
281
+
282
+ # @return [Boolean] `false`.
283
+ def is_always_first_day_of_year?
284
+ false
285
+ end
286
+
287
+ # @return [Boolean] `false`.
288
+ def is_always_last_day_of_year?
289
+ false
290
+ end
291
+
292
+ # Determines if this {DayOfWeekTransitionRule} is equal to another
293
+ # instance.
294
+ #
295
+ # @param r [Object] the instance to test for equality.
296
+ # @return [Boolean] `true` if `r` is a {DayOfWeekTransitionRule} with the
297
+ # same {transition_at}, month and day of week as this
298
+ # {DayOfWeekTransitionRule}, otherwise `false`.
299
+ def ==(r)
300
+ super(r) && r.kind_of?(DayOfWeekTransitionRule) && @month == r.month && @day_of_week == r.day_of_week
301
+ end
302
+ alias eql? ==
303
+
304
+ protected
305
+
306
+ # @return [Integer] the month of the year (1 to 12).
307
+ attr_reader :month
308
+
309
+ # @return [Integer] the day of the week (0 to 6 for Sunday to Monday).
310
+ attr_reader :day_of_week
311
+
312
+ # (see TransitionRule#hash_args)
313
+ def hash_args
314
+ [@month, @day_of_week] + super
315
+ end
316
+ end
317
+ private_constant :DayOfWeekTransitionRule
318
+
319
+ # A rule that transitions on the nth occurrence of a particular day of week
320
+ # of a calendar month.
321
+ #
322
+ # @private
323
+ class DayOfMonthTransitionRule < DayOfWeekTransitionRule #:nodoc:
324
+ # Initializes a new {DayOfMonthTransitionRule}.
325
+ #
326
+ # @param month [Integer] the month of the year when the transition occurs.
327
+ # @param week [Integer] the week of the month when the transition occurs (1
328
+ # to 4).
329
+ # @param day_of_week [Integer] the day of the week when the transition
330
+ # occurs. 0 is Sunday, 6 is Saturday.
331
+ # @param transition_at [Integer] the time in seconds after midnight local
332
+ # time at which the transition occurs.
333
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
334
+ # @raise [ArgumentError] if `month` is not an `Integer`.
335
+ # @raise [ArgumentError] if `month` is less than 1 or greater than 12.
336
+ # @raise [ArgumentError] if `week` is not an `Integer`.
337
+ # @raise [ArgumentError] if `week` is less than 1 or greater than 4.
338
+ # @raise [ArgumentError] if `day_of_week` is not an `Integer`.
339
+ # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6.
340
+ def initialize(month, week, day_of_week, transition_at = 0)
341
+ super(month, day_of_week, transition_at)
342
+ raise ArgumentError, 'Invalid week' unless week.kind_of?(Integer) && week >= 1 && week <= 4
343
+ @offset_start = (week - 1) * 7 + 1
344
+ end
345
+
346
+ # Determines if this {DayOfMonthTransitionRule} is equal to another
347
+ # instance.
348
+ #
349
+ # @param r [Object] the instance to test for equality.
350
+ # @return [Boolean] `true` if `r` is a {DayOfMonthTransitionRule} with the
351
+ # same {transition_at}, month, week and day of week as this
352
+ # {DayOfMonthTransitionRule}, otherwise `false`.
353
+ def ==(r)
354
+ super(r) && r.kind_of?(DayOfMonthTransitionRule) && @offset_start == r.offset_start
355
+ end
356
+ alias eql? ==
357
+
358
+ protected
359
+
360
+ # @return [Integer] the day the week starts on for a month starting on a
361
+ # Sunday.
362
+ attr_reader :offset_start
363
+
364
+ # Returns a `Time` representing midnight local time on the day specified by
365
+ # the rule for the given offset and year.
366
+ #
367
+ # @param offset [TimezoneOffset] the current offset at the time of the
368
+ # transition.
369
+ # @param year [Integer] the year in which the transition occurs.
370
+ # @return [Time] midnight local time on the day specified by the rule for
371
+ # the given offset and year.
372
+ def get_day(offset, year)
373
+ candidate = Time.new(year, month, @offset_start, 0, 0, 0, offset.observed_utc_offset)
374
+ diff = day_of_week - candidate.wday
375
+
376
+ if diff < 0
377
+ candidate + (7 + diff) * 86400
378
+ elsif diff > 0
379
+ candidate + diff * 86400
380
+ else
381
+ candidate
382
+ end
383
+ end
384
+
385
+ # (see TransitionRule#hash_args)
386
+ def hash_args
387
+ [@offset_start] + super
388
+ end
389
+ end
390
+ private_constant :DayOfMonthTransitionRule
391
+
392
+ # A rule that transitions on the last occurrence of a particular day of week
393
+ # of a calendar month.
394
+ #
395
+ # @private
396
+ class LastDayOfMonthTransitionRule < DayOfWeekTransitionRule #:nodoc:
397
+ # Initializes a new {LastDayOfMonthTransitionRule}.
398
+ #
399
+ # @param month [Integer] the month of the year when the transition occurs.
400
+ # @param day_of_week [Integer] the day of the week when the transition
401
+ # occurs. 0 is Sunday, 6 is Saturday.
402
+ # @param transition_at [Integer] the time in seconds after midnight local
403
+ # time at which the transition occurs.
404
+ # @raise [ArgumentError] if `transition_at` is not an `Integer`.
405
+ # @raise [ArgumentError] if `month` is not an `Integer`.
406
+ # @raise [ArgumentError] if `month` is less than 1 or greater than 12.
407
+ # @raise [ArgumentError] if `day_of_week` is not an `Integer`.
408
+ # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6.
409
+ def initialize(month, day_of_week, transition_at = 0)
410
+ super(month, day_of_week, transition_at)
411
+ end
412
+
413
+ # Determines if this {LastDayOfMonthTransitionRule} is equal to another
414
+ # instance.
415
+ #
416
+ # @param r [Object] the instance to test for equality.
417
+ # @return [Boolean] `true` if `r` is a {LastDayOfMonthTransitionRule} with
418
+ # the same {transition_at}, month and day of week as this
419
+ # {LastDayOfMonthTransitionRule}, otherwise `false`.
420
+ def ==(r)
421
+ super(r) && r.kind_of?(LastDayOfMonthTransitionRule)
422
+ end
423
+ alias eql? ==
424
+
425
+ protected
426
+
427
+ # Returns a `Time` representing midnight local time on the day specified by
428
+ # the rule for the given offset and year.
429
+ #
430
+ # @param offset [TimezoneOffset] the current offset at the time of the
431
+ # transition.
432
+ # @param year [Integer] the year in which the transition occurs.
433
+ # @return [Time] midnight local time on the day specified by the rule for
434
+ # the given offset and year.
435
+ def get_day(offset, year)
436
+ next_month = month + 1
437
+ if next_month == 13
438
+ year += 1
439
+ next_month = 1
440
+ end
441
+
442
+ candidate = Time.new(year, next_month, 1, 0, 0, 0, offset.observed_utc_offset) - 86400
443
+ diff = candidate.wday - day_of_week
444
+
445
+ if diff < 0
446
+ candidate - (diff + 7) * 86400
447
+ elsif diff > 0
448
+ candidate - diff * 86400
449
+ else
450
+ candidate
451
+ end
452
+ end
453
+ end
454
+ private_constant :LastDayOfMonthTransitionRule
455
+ end
@@ -3,5 +3,5 @@
3
3
 
4
4
  module TZInfo
5
5
  # The TZInfo version number.
6
- VERSION = '2.0.2'
6
+ VERSION = '2.0.3'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tzinfo
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Ross
@@ -29,7 +29,7 @@ cert_chain:
29
29
  J3Zn/kSTjTekiaspyGbczC3PUaeJNxr+yCvR4sk71Xmk/GaKKGOHedJ1uj/LAXrA
30
30
  MR0mpl7b8zCg0PFC1J73uw==
31
31
  -----END CERTIFICATE-----
32
- date: 2020-04-02 00:00:00.000000000 Z
32
+ date: 2020-11-08 00:00:00.000000000 Z
33
33
  dependencies:
34
34
  - !ruby/object:Gem::Dependency
35
35
  name: concurrent-ruby
@@ -60,6 +60,7 @@ files:
60
60
  - LICENSE
61
61
  - README.md
62
62
  - lib/tzinfo.rb
63
+ - lib/tzinfo/annual_rules.rb
63
64
  - lib/tzinfo/country.rb
64
65
  - lib/tzinfo/country_timezone.rb
65
66
  - lib/tzinfo/data_source.rb
@@ -68,6 +69,7 @@ files:
68
69
  - lib/tzinfo/data_sources/country_info.rb
69
70
  - lib/tzinfo/data_sources/data_timezone_info.rb
70
71
  - lib/tzinfo/data_sources/linked_timezone_info.rb
72
+ - lib/tzinfo/data_sources/posix_time_zone_parser.rb
71
73
  - lib/tzinfo/data_sources/ruby_data_source.rb
72
74
  - lib/tzinfo/data_sources/timezone_info.rb
73
75
  - lib/tzinfo/data_sources/transitions_data_timezone_info.rb
@@ -101,6 +103,7 @@ files:
101
103
  - lib/tzinfo/timezone_period.rb
102
104
  - lib/tzinfo/timezone_proxy.rb
103
105
  - lib/tzinfo/timezone_transition.rb
106
+ - lib/tzinfo/transition_rule.rb
104
107
  - lib/tzinfo/transitions_timezone_period.rb
105
108
  - lib/tzinfo/untaint_ext.rb
106
109
  - lib/tzinfo/version.rb
@@ -108,7 +111,12 @@ files:
108
111
  homepage: https://tzinfo.github.io
109
112
  licenses:
110
113
  - MIT
111
- metadata: {}
114
+ metadata:
115
+ bug_tracker_uri: https://github.com/tzinfo/tzinfo/issues
116
+ changelog_uri: https://github.com/tzinfo/tzinfo/blob/master/CHANGES.md
117
+ documentation_uri: https://rubydoc.info/gems/tzinfo/2.0.3
118
+ homepage_uri: https://tzinfo.github.io
119
+ source_code_uri: https://github.com/tzinfo/tzinfo/tree/v2.0.3
112
120
  post_install_message:
113
121
  rdoc_options:
114
122
  - "--title"
@@ -128,7 +136,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
136
  - !ruby/object:Gem::Version
129
137
  version: '0'
130
138
  requirements: []
131
- rubygems_version: 3.1.2
139
+ rubygems_version: 3.1.4
132
140
  signing_key:
133
141
  specification_version: 4
134
142
  summary: Time Zone Library
metadata.gz.sig CHANGED
Binary file