-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
/
location.cr
462 lines (416 loc) · 14.3 KB
/
location.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
require "./location/loader"
# `Location` maps time instants to the zone in use at that time.
# It typically represents the collection of time offsets observed in
# a certain geographical area.
#
# It contains a list of zone offsets and rules for transitioning between them.
#
# If a location has only one offset (such as `UTC`) it is considerd
# *fixed*.
#
# A `Location` instance is usually retrieved by name using
# `Time::Location.load`.
# It loads the zone offsets and transitioning rules from the time zone database
# provided by the operating system.
#
# ```
# location = Time::Location.load("Europe/Berlin")
# location # => #<Time::Location Europe/Berlin>
# time = Time.new(2016, 2, 15, 21, 1, 10, location: location)
# time # => 2016-02-15 21:01:10 +01:00 Europe/Berlin
# ```
#
# A custom time zone database can be configured through the environment variable
# `ZONEINFO`. See `.load` for details.
#
# ### Fixed Offset
#
# A fixed offset location is created using `Time::Location.fixed`:
#
# ```
# location = Time::Location.fixed(3600)
# location # => #<Time::Location +01:00>
# location.zones # => [#<Time::Location::Zone +01:00 (0s) STD>]
# ```
#
#
# ### Local Time Zone
#
# The local time zone can be accessed as `Time::Location.local`.
#
# It is initially configured according to system environment settings,
# but it's value can be changed:
#
# ```
# location = Time::Location.local
# Time::Location.local = Time::Location.load("America/New_York")
# ```
class Time::Location
# `InvalidLocationNameError` is raised if a location name cannot be found in
# the time zone database.
#
# See `Time::Location.load` for details.
class InvalidLocationNameError < Exception
getter name, source
def initialize(@name : String, @source : String? = nil)
msg = "Invalid location name: #{name}"
msg += " in #{source}" if source
super msg
end
end
# `InvalidTimezoneOffsetError` is raised if `Time::Location::Zone.new`
# receives an invalid time zone offset.
class InvalidTimezoneOffsetError < Exception
def initialize(offset : Int)
super "Invalid time zone offset: #{offset}"
end
end
# A `Zone` represents a time zone offset in effect in a specific `Location`.
#
# Some zones have a `name` or abbreviation (such as `PDT`, `CEST`).
# For an unnamed zone the formatted offset should be used as name.
struct Zone
# This is the `UTC` time zone with offset `+00:00`.
#
# It is the only zone offset used in `Time::Location::UTC`.
UTC = new "UTC", 0, false
# Returns the offset from UTC in seconds.
getter offset : Int32
# Returns `true` if this zone offset is daylight savings time.
getter? dst : Bool
# Creates a new `Zone` named *name* with *offset* from UTC in seconds.
# The parameter *dst* is used to declare this zone as daylight savings time.
#
# If `name` is `nil`, the formatted `offset` will be used as `name` (see
# `#format`).
#
# Raises `InvalidTimezoneOffsetError` if *seconds* is outside the supported
# value range `-86_400..86_400` seconds (`-24:00` to `+24:00`).
def initialize(@name : String?, @offset : Int32, @dst : Bool)
# Maximium offsets of IANA time zone database are -12:00 and +14:00.
# +/-24 hours allows a generous padding for unexpected offsets.
# TODO: Maybe reduce to Int16 (+/- 18 hours).
raise InvalidTimezoneOffsetError.new(offset) if offset >= SECONDS_PER_DAY || offset <= -SECONDS_PER_DAY
end
# Returns the name of the zone.
def name : String
@name || format
end
# Prints this `Zone` to *io*.
#
# It contains the `name`, hour-minute-second format (see `#format`),
# `offset` in seconds and `"DST"` if `#dst?`, otherwise `"STD"`.
def inspect(io : IO)
io << "Time::Location::Zone("
io << @name << ' ' unless @name.nil?
format(io)
io << " (" << offset << "s)"
if dst?
io << " DST"
else
io << " STD"
end
io << ')'
end
# Prints `#offset` to *io* in the format `+HH:mm:ss`.
# When *with_colon* is `false`, the format is `+HHmmss`.
#
# When *with_seconds* is `false`, seconds are omitted; when `:auto`, seconds
# are ommitted if `0`.
def format(io : IO, with_colon = true, with_seconds = :auto)
sign, hours, minutes, seconds = sign_hours_minutes_seconds
io << sign
io << '0' if hours < 10
io << hours
io << ':' if with_colon
io << '0' if minutes < 10
io << minutes
if with_seconds == true || (seconds != 0 && with_seconds == :auto)
io << ':' if with_colon
io << '0' if seconds < 10
io << seconds
end
end
# Returns the `#offset` formatted as `+HH:mm:ss`.
# When *with_colon* is `false`, the format is `+HHmmss`.
#
# When *with_seconds* is `false`, seconds are omitted; when `:auto`, seconds
# are ommitted if `0`.
def format(with_colon = true, with_seconds = :auto)
String.build do |io|
format(io, with_colon: with_colon, with_seconds: with_seconds)
end
end
# :nodoc:
def sign_hours_minutes_seconds
offset = @offset
if offset < 0
offset = -offset
sign = '-'
else
sign = '+'
end
seconds = offset % 60
minutes = offset / 60
hours = minutes / 60
minutes = minutes % 60
{sign, hours, minutes, seconds}
end
end
# :nodoc:
record ZoneTransition, when : Int64, index : UInt8, standard : Bool, utc : Bool do
getter? standard, utc
def inspect(io : IO)
io << "Time::Location::ZoneTransition("
io << '#' << index << ' '
Time.epoch(self.when).to_s("%F %T", io)
if standard?
io << " STD"
else
io << " DST"
end
io << " UTC" if utc?
io << ')'
end
end
# Describes the Coordinated Universal Time (UTC).
#
# The only time zone offset in this location is `Zone::UTC`.
UTC = new "UTC", [Zone::UTC]
# Returns the name of this location.
#
# It usually consists of a continent and city name separated by a slash, for
# example `Europe/Berlin`.
getter name : String
# Returns the array of time zone offsets (`Zone`) used in this time zone.
getter zones : Array(Zone)
# Most lookups will be for the current time.
# To avoid the binary search through tx, keep a
# static one-element cache that gives the correct
# zone for the time when the Location was created.
# The units for @cached_range are seconds
# since January 1, 1970 UTC, to match the argument
# to `#lookup`.
@cached_range : Tuple(Int64, Int64)
@cached_zone : Zone
# Creates a `Location` instance named *name* with fixed *offset* in seconds
# from UTC.
def self.fixed(name : String, offset : Int32) : Location
new name, [Zone.new(name, offset, false)]
end
# Creates a `Location` instance with fixed *offset* in seconds from UTC.
#
# The formatted *offset* is used as name.
def self.fixed(offset : Int32)
zone = Zone.new(nil, offset, false)
new zone.name, [zone]
end
# Loads the `Location` with the given *name*.
#
# ```
# location = Time::Location.load("Europe/Berlin")
# ```
#
# *name* is understood to be a location name in the IANA Time
# Zone database, such as `"America/New_York"`. As special cases,
# `"UTC"` and empty string (`""`) return `Location::UTC`, and
# `"Local"` returns `Location.local`.
#
# The implementation uses a list of system-specifc paths to look for a time
# zone database.
# The first time zone database entry matching the given name that is
# successfully loaded and parsed is returned.
# Typical paths on Unix-based operating systems are `/usr/share/zoneinfo/`,
# `/usr/share/lib/zoneinfo/`, or `/usr/lib/locale/TZ/`.
#
# A time zone database may not be present on all systems, especially non-Unix
# systems. In this case, you may need to distribute a copy of the database
# with an application that depends on time zone data being available.
#
# A custom lookup path can be set as environment variable `ZONEINFO`.
# The path can point to the root of a directory structure or an
# uncompressed ZIP file, each representing the time zone database using files
# and folders of the expected names.
#
# Example:
#
# ```
# # This tries to load the file `/usr/share/zoneinfo/Custom/Location`
# ENV["ZONEINFO"] = "/usr/share/zoneinfo/"
# Location.load("Custom/Location")
#
# # This tries to load the file `Custom/Location` in the uncompressed ZIP
# # file at `/path/to/zoneinfo.zip`
# ENV["ZONEINFO"] = "/path/to/zoneinfo.zip"
# Location.load("Custom/Location")
# ```
#
# If the location name cannot be found, `InvalidLocationNameError` is raised.
# If the loader encounters a format error in the time zone database,
# `InvalidTZDataError` is raised.
#
# Files are cached based on the modification time, so subsequent request for
# the same location name will most likely return the same instance of
# `Location`, unless the time zone database has been updated in between.
def self.load(name : String) : Location
case name
when "", "UTC"
UTC
when "Local"
local
when .includes?(".."), .starts_with?('/'), .starts_with?('\\')
# No valid IANA Time Zone name contains a single dot,
# much less dot dot. Likewise, none begin with a slash.
raise InvalidLocationNameError.new(name)
else
if zoneinfo = ENV["ZONEINFO"]?
if location = load_from_dir_or_zip(name, zoneinfo)
return location
else
raise InvalidLocationNameError.new(name, zoneinfo)
end
end
if location = load(name, Crystal::System::Time.zone_sources)
return location
end
raise InvalidLocationNameError.new(name)
end
end
# Returns the `Location` representing the application's local time zone.
#
# `Time` uses this property as default value for most method arguments
# expecting a `Location`.
#
# The initial value depends on the current application environment, see
# `.load_local` for details.
#
# The value can be changed to overwrite the system default:
#
# ```
# Time.now.location # => #<Time::Location America/New_York>
# Time::Location.local = Time::Location.load("Europe/Berlin")
# Time.now.location # => #<Time::Location Europe/Berlin>
# ```
class_property(local : Location) { load_local }
# Loads the local time zone according to the current application environment.
#
# The environment variable `ENV["TZ"]` is consulted for finding the time zone
# to use.
#
# * `"UTC"` and empty string (`""`) return `Location::UTC`
# * Any other value (such as `"Europe/Berlin"`) is tried to be resolved using
# `Location.load`.
# * If `ENV["TZ"]` is not set, the system's local time zone data will be used
# (`/etc/localtime` on unix-based systems).
# * If no time zone data could be found (i.e. the previous methods failed),
# `Location::UTC` is returned.
def self.load_local : Location
case tz = ENV["TZ"]?
when "", "UTC"
UTC
when Nil
if localtime = Crystal::System::Time.load_localtime
return localtime
end
else
if location = load?(tz, Crystal::System::Time.zone_sources)
return location
end
end
UTC
end
# :nodoc:
def initialize(@name : String, @zones : Array(Zone), @transitions = [] of ZoneTransition)
@cached_zone = lookup_first_zone
@cached_range = {Int64::MIN, @zones.size <= 1 ? Int64::MAX : Int64::MIN}
end
protected def transitions
@transitions
end
# Prints `name` to *io*.
def to_s(io : IO)
io << name
end
def inspect(io : IO)
io << "#<Time::Location "
to_s(io)
io << '>'
end
# Returns `true` if *other* is equal to `self`.
#
# Two `Location` instances are considered equal if they have the same name,
# offset zones and transition rules.
def_equals_and_hash name, zones, transitions
# Returns the time zone offset observed at *time*.
def lookup(time : Time) : Zone
lookup(time.epoch)
end
# Returns the time zone offset observed at *epoch*.
#
# *epoch* expresses the number of seconds since UNIX epoch
# (`1970-01-01 00:00:00 UTC`).
def lookup(epoch : Int) : Zone
unless @cached_range[0] <= epoch < @cached_range[1]
@cached_zone, @cached_range = lookup_with_boundaries(epoch)
end
@cached_zone
end
# :nodoc:
def lookup_with_boundaries(epoch : Int) : {Zone, {Int64, Int64}}
case
when zones.empty?
return Zone::UTC, {Int64::MIN, Int64::MAX}
when transitions.empty? || epoch < transitions.first.when
return lookup_first_zone, {Int64::MIN, transitions[0]?.try(&.when) || Int64::MAX}
else
tx_index = transitions.bsearch_index do |transition|
transition.when > epoch
end || transitions.size
tx_index -= 1 unless tx_index == 0
transition = transitions[tx_index]
range_end = transitions[tx_index + 1]?.try(&.when) || Int64::MAX
return zones[transition.index], {transition.when, range_end}
end
end
# Returns the time zone to use for times before the first transition
# time, or when there are no transition times.
#
# The reference implementation in localtime.c from
# http:#www.iana.org/time-zones/repository/releases/tzcode2013g.tar.gz
# implements the following algorithm for these cases:
# 1) If the first zone is unused by the transitions, use it.
# 2) Otherwise, if there are transition times, and the first
# transition is to a zone in daylight time, find the first
# non-daylight-time zone before and closest to the first transition
# zone.
# 3) Otherwise, use the first zone that is not daylight time, if
# there is one.
# 4) Otherwise, use the first zone.
private def lookup_first_zone : Zone
unless transitions.any? { |tx| tx.index == 0 }
return zones.first
end
if (tx = transitions[0]?) && zones[tx.index].dst?
index = tx.index
while index > 0
index -= 1
zone = zones[index]
return zone unless zone.dst?
end
end
first_zone_without_dst = zones.find { |tx| !tx.dst? }
first_zone_without_dst || zones.first
end
# Returns `true` if this location equals to `UTC`.
def utc? : Bool
self == UTC
end
# Returns `true` if this location equals to `Time::Location.local`.
def local? : Bool
self == Location.local
end
# Returns `true` if this location has a fixed offset.
def fixed? : Bool
zones.size <= 1
end
end