tzinfo 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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