Skip to content

Commit f3168b6

Browse files
straight-shootaRX14
authored andcommittedJan 18, 2018
Add time zones support (#5324)
* Add cache for last zone to Time::Location#lookup * Implement Time::Location including timezone data loader Remove representation of floating time from `Time` (formerly expressed as `Time::Kind::Unspecified`). Floating time should not be represented as an instance of `Time` to avoid undefined operations through type safety (see #5332). Breaking changes: * Calls to `Time.new` and `Time.now` are now in the local time zone by default. * `Time.parse`, `Time::Format.new` and `Time::Format.parse` don't specify a default location. If none is included in the time format and no default argument is provided, the parse method wil raise an exception because there is no way to know how such a value should be represented as an instance of `Time`. Applications expecting time values without time zone should provide default location to apply in such a case. * Implement custom zip file reader to remove depenencies * Add location cache for `Location.load` * Rename `Location.local` to `.load_local` and make `local` a class property * Fix env ZONEINFO * Fix example code string representation of local Time instance * Time zone implementation for win32 This adds basic support for using the new time zone model on windows. * `Crystal::System::Time.zone_sources` returns an empty array because Windows does not include a copy of the tz database. * `Crystal::System::Time.load_localtime` creates a local time zone `Time::Location` based on data provided by `GetTimeZoneInformation`. * A mapping from Windows time zone names to identifiers used by the IANA timezone database is included as well as an automated generator for that file. * Add stubs for methods with file acces Trying to load a location from a file will fail because `File` is not yet ported to windows.
1 parent 6a574f2 commit f3168b6

27 files changed

+1874
-503
lines changed
 
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# This script generates the file src/crystal/system/win32/zone_names.cr
2+
# that contains mappings for windows time zone names based on the values
3+
# found in http://unicode.org/cldr/data/common/supplemental/windowsZones.xml
4+
5+
require "http/client"
6+
require "xml"
7+
require "../src/compiler/crystal/formatter"
8+
9+
WINDOWS_ZONE_NAMES_SOURCE = "http://unicode.org/cldr/data/common/supplemental/windowsZones.xml"
10+
TARGET_FILE = File.join(__DIR__, "..", "src", "crystal", "system", "win32", "zone_names.cr")
11+
12+
response = HTTP::Client.get(WINDOWS_ZONE_NAMES_SOURCE)
13+
14+
# Simple redirection resolver
15+
# TODO: Needs to be replaced by proper redirect handling that should be provided by `HTTP::Client`
16+
if (300..399).includes?(response.status_code) && (location = response.headers["Location"]?)
17+
response = HTTP::Client.get(location)
18+
end
19+
20+
xml = XML.parse(response.body)
21+
22+
nodes = xml.xpath_nodes("/supplementalData/windowsZones/mapTimezones/mapZone[@territory=001]")
23+
24+
entries = [] of {key: String, zones: {String, String}, tzdata_name: String}
25+
26+
nodes.each do |node|
27+
location = Time::Location.load(node["type"])
28+
next unless location
29+
time = Time.now(location).at_beginning_of_year
30+
zone1 = time.zone
31+
zone2 = (time + 6.months).zone
32+
33+
if zone1.offset > zone2.offset
34+
# southern hemisphere
35+
zones = {zone2.name, zone1.name}
36+
else
37+
# northern hemisphere
38+
zones = {zone1.name, zone2.name}
39+
end
40+
41+
entries << {key: node["other"], zones: zones, tzdata_name: location.name}
42+
rescue err : Time::Location::InvalidLocationNameError
43+
pp err
44+
end
45+
46+
# sort by IANA database identifier
47+
entries.sort_by! &.[:tzdata_name]
48+
49+
hash_items = String.build do |io|
50+
entries.each do |entry|
51+
entry[:key].inspect(io)
52+
io << " => "
53+
entry[:zones].inspect(io)
54+
io << ", # " << entry[:tzdata_name] << "\n"
55+
end
56+
end
57+
58+
source = <<-CRYSTAL
59+
# This file was automatically generated by running:
60+
#
61+
# scripts/generate_windows_zone_names.cr
62+
#
63+
# DO NOT EDIT
64+
65+
module Crystal::System::Time
66+
# These mappings for windows time zone names are based on
67+
# #{WINDOWS_ZONE_NAMES_SOURCE}
68+
WINDOWS_ZONE_NAMES = {
69+
#{hash_items}
70+
}
71+
end
72+
CRYSTAL
73+
74+
source = Crystal.format(source)
75+
76+
File.write(TARGET_FILE, source)

‎spec/std/data/zoneinfo.zip

358 KB
Binary file not shown.

‎spec/std/data/zoneinfo/Foo/Bar

118 Bytes
Binary file not shown.

‎spec/std/file_spec.cr

+5-5
Original file line numberDiff line numberDiff line change
@@ -1033,8 +1033,8 @@ describe "File" do
10331033
filename = "#{__DIR__}/data/temp_write.txt"
10341034
File.write(filename, "")
10351035

1036-
atime = Time.new(2000, 1, 2)
1037-
mtime = Time.new(2000, 3, 4)
1036+
atime = Time.utc(2000, 1, 2)
1037+
mtime = Time.utc(2000, 3, 4)
10381038

10391039
File.utime(atime, mtime, filename)
10401040

@@ -1046,8 +1046,8 @@ describe "File" do
10461046
end
10471047

10481048
it "raises if file not found" do
1049-
atime = Time.new(2000, 1, 2)
1050-
mtime = Time.new(2000, 3, 4)
1049+
atime = Time.utc(2000, 1, 2)
1050+
mtime = Time.utc(2000, 3, 4)
10511051

10521052
expect_raises Errno, "Error setting time to file" do
10531053
File.utime(atime, mtime, "#{__DIR__}/nonexistent_file")
@@ -1069,7 +1069,7 @@ describe "File" do
10691069

10701070
it "sets file times to given time" do
10711071
filename = "#{__DIR__}/data/temp_touch.txt"
1072-
time = Time.new(2000, 3, 4)
1072+
time = Time.utc(2000, 3, 4)
10731073
begin
10741074
File.touch(filename, time)
10751075

‎spec/std/http/http_spec.cr

+3-11
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe HTTP do
3333
it "parses and is local (#2744)" do
3434
date = "Mon, 09 Sep 2011 23:36:00 -0300"
3535
parsed_time = HTTP.parse_time(date).not_nil!
36-
parsed_time.local?.should be_true
36+
parsed_time.offset.should eq -3 * 3600
3737
parsed_time.to_utc.to_s.should eq("2011-09-10 02:36:00 UTC")
3838
end
3939

@@ -44,16 +44,8 @@ describe HTTP do
4444
end
4545

4646
it "with local time zone" do
47-
tz = ENV["TZ"]?
48-
ENV["TZ"] = "Europe/Berlin"
49-
LibC.tzset
50-
begin
51-
time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, kind: Time::Kind::Local)
52-
HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
53-
ensure
54-
ENV["TZ"] = tz
55-
LibC.tzset
56-
end
47+
time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, location: Time::Location.load("Europe/Berlin"))
48+
HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
5749
end
5850
end
5951

‎spec/std/json/mapping_spec.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe "JSON mapping" do
252252
it "parses json with Time::Format converter" do
253253
json = JSONWithTime.from_json(%({"value": "2014-10-31 23:37:16"}))
254254
json.value.should be_a(Time)
255-
json.value.to_s.should eq("2014-10-31 23:37:16")
255+
json.value.to_s.should eq("2014-10-31 23:37:16 UTC")
256256
json.to_json.should eq(%({"value":"2014-10-31 23:37:16"}))
257257
end
258258

‎spec/std/time/location_spec.cr

+327
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
require "spec"
2+
require "./spec_helper"
3+
4+
class Time::Location
5+
def __cached_range
6+
@cached_range
7+
end
8+
9+
def __cached_zone
10+
@cached_zone
11+
end
12+
13+
def __cached_zone=(zone)
14+
@cached_zone = zone
15+
end
16+
17+
def self.__clear_location_cache
18+
@@location_cache.clear
19+
end
20+
21+
describe Time::Location do
22+
describe ".load" do
23+
it "loads Europe/Berlin" do
24+
location = Location.load("Europe/Berlin")
25+
26+
location.name.should eq "Europe/Berlin"
27+
standard_time = location.lookup(Time.new(2017, 11, 22))
28+
standard_time.name.should eq "CET"
29+
standard_time.offset.should eq 3600
30+
standard_time.dst?.should be_false
31+
32+
summer_time = location.lookup(Time.new(2017, 10, 22))
33+
summer_time.name.should eq "CEST"
34+
summer_time.offset.should eq 7200
35+
summer_time.dst?.should be_true
36+
37+
location.utc?.should be_false
38+
location.fixed?.should be_false
39+
40+
with_env("TZ", nil) do
41+
location.local?.should be_false
42+
end
43+
44+
with_env("TZ", "Europe/Berlin") do
45+
location.local?.should be_true
46+
end
47+
48+
Location.load?("Europe/Berlin", Crystal::System::Time.zone_sources).should eq location
49+
end
50+
51+
it "invalid timezone identifier" do
52+
expect_raises(InvalidLocationNameError, "Foobar/Baz") do
53+
Location.load("Foobar/Baz")
54+
end
55+
56+
Location.load?("Foobar/Baz", Crystal::System::Time.zone_sources).should be_nil
57+
end
58+
59+
it "treats UTC as special case" do
60+
Location.load("UTC").should eq Location::UTC
61+
Location.load("").should eq Location::UTC
62+
63+
# Etc/UTC could be pointing to anything
64+
Location.load("Etc/UTC").should_not eq Location::UTC
65+
end
66+
67+
describe "validating name" do
68+
it "absolute path" do
69+
expect_raises(InvalidLocationNameError) do
70+
Location.load("/America/New_York")
71+
end
72+
expect_raises(InvalidLocationNameError) do
73+
Location.load("\\Zulu")
74+
end
75+
end
76+
it "dot dot" do
77+
expect_raises(InvalidLocationNameError) do
78+
Location.load("../zoneinfo/America/New_York")
79+
end
80+
expect_raises(InvalidLocationNameError) do
81+
Location.load("a..")
82+
end
83+
end
84+
end
85+
86+
context "with ZONEINFO" do
87+
it "loads from custom directory" do
88+
with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do
89+
location = Location.load("Foo/Bar")
90+
location.name.should eq "Foo/Bar"
91+
end
92+
end
93+
94+
it "loads from custom zipfile" do
95+
with_zoneinfo(ZONEINFO_ZIP) do
96+
location = Location.load("Asia/Jerusalem")
97+
location.not_nil!.name.should eq "Asia/Jerusalem"
98+
end
99+
end
100+
101+
it "raises if not available" do
102+
with_zoneinfo(ZONEINFO_ZIP) do
103+
expect_raises(InvalidLocationNameError) do
104+
Location.load("Foo/Bar")
105+
end
106+
Location.load?("Foo/Bar", Crystal::System::Time.zone_sources).should be_nil
107+
end
108+
end
109+
110+
it "does not fall back to default sources" do
111+
with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do
112+
expect_raises(InvalidLocationNameError) do
113+
Location.load("Europe/Berlin")
114+
end
115+
end
116+
117+
with_zoneinfo("nonexising_zipfile.zip") do
118+
expect_raises(InvalidLocationNameError) do
119+
Location.load("Europe/Berlin")
120+
end
121+
end
122+
end
123+
124+
it "caches result" do
125+
with_zoneinfo do
126+
location = Location.load("Europe/Berlin")
127+
Location.load("Europe/Berlin").should be location
128+
end
129+
end
130+
131+
it "loads new data if file was changed" do
132+
zoneinfo_path = File.join(__DIR__, "..", "data", "zoneinfo")
133+
with_zoneinfo(zoneinfo_path) do
134+
location1 = Location.load("Foo/Bar")
135+
File.touch(File.join(zoneinfo_path, "Foo/Bar"))
136+
location2 = Location.load("Foo/Bar")
137+
138+
location1.should eq location2
139+
location1.should_not be location2
140+
end
141+
end
142+
143+
it "loads new data if ZIP file was changed" do
144+
with_zoneinfo(ZONEINFO_ZIP) do
145+
location1 = Location.load("Europe/Berlin")
146+
File.touch(ZONEINFO_ZIP)
147+
location2 = Location.load("Europe/Berlin")
148+
149+
location1.should eq location2
150+
location1.should_not be location2
151+
end
152+
end
153+
end
154+
end
155+
156+
it "UTC" do
157+
location = Location::UTC
158+
location.name.should eq "UTC"
159+
160+
location.utc?.should be_true
161+
location.fixed?.should be_true
162+
163+
# this could fail if no source for localtime is available
164+
unless Location.local.utc?
165+
location.local?.should be_false
166+
end
167+
168+
zone = location.lookup(Time.now)
169+
zone.name.should eq "UTC"
170+
zone.offset.should eq 0
171+
zone.dst?.should be_false
172+
end
173+
174+
it ".local" do
175+
Location.local.should eq Location.load_local
176+
177+
Location.local = Location::UTC
178+
Location.local.should be Location::UTC
179+
end
180+
181+
it ".load_local" do
182+
with_env("TZ", nil) do
183+
Location.load_local.name.should eq "Local"
184+
end
185+
with_zoneinfo do
186+
with_env("TZ", "Europe/Berlin") do
187+
Location.load_local.name.should eq "Europe/Berlin"
188+
end
189+
end
190+
with_env("TZ", "") do
191+
Location.load_local.utc?.should be_true
192+
end
193+
end
194+
195+
describe ".fixed" do
196+
it "accepts a name" do
197+
location = Location.fixed("Fixed", 1800)
198+
location.name.should eq "Fixed"
199+
location.zones.should eq [Zone.new("Fixed", 1800, false)]
200+
location.transitions.size.should eq 0
201+
202+
location.utc?.should be_false
203+
location.fixed?.should be_true
204+
location.local?.should be_false
205+
end
206+
207+
it "positive" do
208+
location = Location.fixed 8000
209+
location.name.should eq "+02:13"
210+
location.zones.first.offset.should eq 8000
211+
end
212+
213+
it "ngeative" do
214+
location = Location.fixed -7539
215+
location.name.should eq "-02:05"
216+
location.zones.first.offset.should eq -7539
217+
end
218+
219+
it "raises if offset to large" do
220+
expect_raises(InvalidTimezoneOffsetError, "86401") do
221+
Location.fixed(86401)
222+
end
223+
expect_raises(InvalidTimezoneOffsetError, "-90000") do
224+
Location.fixed(-90000)
225+
end
226+
end
227+
end
228+
229+
describe "#lookup" do
230+
it "looks up" do
231+
with_zoneinfo do
232+
location = Location.load("Europe/Berlin")
233+
zone, range = location.lookup_with_boundaries(Time.utc(2017, 11, 23, 22, 6, 12).epoch)
234+
zone.should eq Zone.new("CET", 3600, false)
235+
range.should eq({1509238800_i64, 1521939600_i64})
236+
end
237+
end
238+
239+
it "handles dst change" do
240+
with_zoneinfo do
241+
location = Location.load("Europe/Berlin")
242+
time = Time.utc(2017, 10, 29, 1, 0, 0)
243+
244+
summer = location.lookup(time - 1.second)
245+
summer.name.should eq "CEST"
246+
summer.offset.should eq 2 * SECONDS_PER_HOUR
247+
summer.dst?.should be_true
248+
249+
winter = location.lookup(time)
250+
winter.name.should eq "CET"
251+
winter.offset.should eq 1 * SECONDS_PER_HOUR
252+
winter.dst?.should be_false
253+
254+
last_ns = location.lookup(time - 1.nanosecond)
255+
last_ns.name.should eq "CEST"
256+
last_ns.offset.should eq 2 * SECONDS_PER_HOUR
257+
last_ns.dst?.should be_true
258+
end
259+
end
260+
261+
it "handles value after last transition" do
262+
with_zoneinfo do
263+
location = Location.load("America/Buenos_Aires")
264+
zone = location.lookup(Time.utc(5000, 1, 1))
265+
zone.name.should eq "-03"
266+
zone.offset.should eq -3 * 3600
267+
end
268+
end
269+
270+
# Test that we get the correct results for times before the first
271+
# transition time. To do this we explicitly check early dates in a
272+
# couple of specific timezones.
273+
context "first zone" do
274+
it "PST8PDT" do
275+
with_zoneinfo do
276+
location = Location.load("PST8PDT")
277+
zone1 = location.lookup(-1633269601)
278+
zone2 = location.lookup(-1633269601 + 1)
279+
zone1.name.should eq "PST"
280+
zone1.offset.should eq -8 * SECONDS_PER_HOUR
281+
zone2.name.should eq "PDT"
282+
zone2.offset.should eq -7 * SECONDS_PER_HOUR
283+
end
284+
end
285+
286+
it "Pacific/Fakaofo" do
287+
with_zoneinfo do
288+
location = Location.load("Pacific/Fakaofo")
289+
zone1 = location.lookup(1325242799)
290+
zone2 = location.lookup(1325242799 + 1)
291+
zone1.name.should eq "-11"
292+
zone1.offset.should eq -11 * SECONDS_PER_HOUR
293+
zone2.name.should eq "+13"
294+
zone2.offset.should eq 13 * SECONDS_PER_HOUR
295+
end
296+
end
297+
end
298+
299+
it "caches last zone" do
300+
with_zoneinfo do
301+
location = Time::Location.load("Europe/Berlin")
302+
303+
location.__cached_range.should eq({Int64::MIN, Int64::MIN})
304+
location.__cached_zone.should eq Zone.new("LMT", 3208, false)
305+
306+
expected_zone = Zone.new("CET", 3600, false)
307+
308+
location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq expected_zone
309+
310+
location.__cached_range.should eq({1509238800_i64, 1521939600_i64})
311+
location.__cached_zone.should eq expected_zone
312+
end
313+
end
314+
315+
it "reads from cache" do
316+
with_zoneinfo do
317+
location = Location.load("Europe/Berlin")
318+
location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq Zone.new("CET", 3600, false)
319+
cached_zone = Zone.new("MyZone", 1234, true)
320+
location.__cached_zone = cached_zone
321+
322+
location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq cached_zone
323+
end
324+
end
325+
end
326+
end
327+
end

‎spec/std/time/spec_helper.cr

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
def with_env(name, value)
2+
previous = ENV[name]?
3+
begin
4+
ENV[name] = value
5+
6+
# Reset local time zone
7+
Time::Location.local = Time::Location.load_local
8+
yield
9+
ensure
10+
ENV[name] = previous
11+
end
12+
end
13+
14+
ZONEINFO_ZIP = File.join(__DIR__, "..", "data", "zoneinfo.zip")
15+
16+
def with_zoneinfo(path = ZONEINFO_ZIP)
17+
with_env("ZONEINFO", path) do
18+
Time::Location.__clear_location_cache
19+
20+
yield
21+
end
22+
end

‎spec/std/time/time_spec.cr

+415-248
Large diffs are not rendered by default.

‎spec/std/yaml/mapping_spec.cr

+2-2
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@ describe "YAML mapping" do
269269
it "parses yaml with Time::Format converter" do
270270
yaml = YAMLWithTime.from_yaml("---\nvalue: 2014-10-31 23:37:16\n")
271271
yaml.value.should be_a(Time)
272-
yaml.value.to_s.should eq("2014-10-31 23:37:16")
273-
yaml.value.should eq(Time.new(2014, 10, 31, 23, 37, 16))
272+
yaml.value.to_s.should eq("2014-10-31 23:37:16 UTC")
273+
yaml.value.should eq(Time.utc(2014, 10, 31, 23, 37, 16))
274274
yaml.to_yaml.should eq("---\nvalue: 2014-10-31 23:37:16\n")
275275
end
276276

‎spec/std/yaml/serialization_spec.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe "YAML serialization" do
153153
ctx = YAML::ParseContext.new
154154
nodes = YAML::Nodes.parse("--- 2014-01-02\n...\n").nodes.first
155155
value = Time::Format.new("%F").from_yaml(ctx, nodes)
156-
value.should eq(Time.new(2014, 1, 2))
156+
value.should eq(Time.utc(2014, 1, 2))
157157
end
158158

159159
it "deserializes union" do

‎src/crystal/system/time.cr

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
module Crystal::System::Time
2-
# Returns the number of seconds that you must add to UTC to get local time.
3-
# *seconds* are measured from `0001-01-01 00:00:00`.
4-
# def self.compute_utc_offset(seconds : Int64) : Int32
5-
62
# Returns the current UTC time measured in `{seconds, nanoseconds}`
73
# since `0001-01-01 00:00:00`.
84
# def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32}
95

106
# def self.monotonic : {Int64, Int32}
7+
8+
# Returns a list of paths where time zone data should be looked up.
9+
# def self.zone_sources : Enumerable(String)
10+
11+
# Returns the system's current local time zone
12+
# def self.load_localtime : ::Time::Location?
1113
end
1214

1315
{% if flag?(:win32) %}

‎src/crystal/system/unix/time.cr

+21-22
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,6 @@ require "c/time"
1313
module Crystal::System::Time
1414
UnixEpochInSeconds = 62135596800_i64
1515

16-
def self.compute_utc_offset(seconds : Int64) : Int32
17-
LibC.tzset
18-
offset = nil
19-
20-
{% if LibC.methods.includes?("daylight".id) %}
21-
if LibC.daylight == 0
22-
# current TZ doesn't have any DST, neither in past, present or future
23-
offset = -LibC.timezone.to_i
24-
end
25-
{% end %}
26-
27-
unless offset
28-
seconds_from_epoch = LibC::TimeT.new(seconds - UnixEpochInSeconds)
29-
# current TZ may have DST, either in past, present or future
30-
ret = LibC.localtime_r(pointerof(seconds_from_epoch), out tm)
31-
raise Errno.new("localtime_r") if ret.null?
32-
offset = tm.tm_gmtoff.to_i
33-
end
34-
35-
offset
36-
end
37-
3816
def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32}
3917
{% if LibC.methods.includes?("clock_gettime".id) %}
4018
ret = LibC.clock_gettime(LibC::CLOCK_REALTIME, out timespec)
@@ -62,6 +40,27 @@ module Crystal::System::Time
6240
{% end %}
6341
end
6442

43+
# Many systems use /usr/share/zoneinfo, Solaris 2 has
44+
# /usr/share/lib/zoneinfo, IRIX 6 has /usr/lib/locale/TZ.
45+
ZONE_SOURCES = {
46+
"/usr/share/zoneinfo/",
47+
"/usr/share/lib/zoneinfo/",
48+
"/usr/lib/locale/TZ/",
49+
}
50+
LOCALTIME = "/etc/localtime"
51+
52+
def self.zone_sources : Enumerable(String)
53+
ZONE_SOURCES
54+
end
55+
56+
def self.load_localtime : ::Time::Location?
57+
if ::File.exists?(LOCALTIME)
58+
::File.open(LOCALTIME) do |file|
59+
::Time::Location.read_zoneinfo("Local", file)
60+
end
61+
end
62+
end
63+
6564
{% if flag?(:darwin) %}
6665
@@mach_timebase_info : LibC::MachTimebaseInfo?
6766

‎src/crystal/system/win32/time.cr

+117-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "c/winbase"
22
require "winerror"
3+
require "./zone_names"
34

45
module Crystal::System::Time
56
# Win32 epoch is 1601-01-01 00:00:00 UTC
@@ -11,13 +12,7 @@ module Crystal::System::Time
1112
NANOSECONDS_PER_SECOND = 1_000_000_000
1213
FILETIME_TICKS_PER_SECOND = NANOSECONDS_PER_SECOND / NANOSECONDS_PER_FILETIME_TICK
1314

14-
# TODO: For now, this method returns the UTC offset currently in place, ignoring *seconds*.
15-
def self.compute_utc_offset(seconds : Int64) : Int32
16-
ret = LibC.GetTimeZoneInformation(out zone_information)
17-
raise WinError.new("GetTimeZoneInformation") if ret == -1
18-
19-
zone_information.bias.to_i32 * -60
20-
end
15+
BIAS_TO_OFFSET_FACTOR = -60
2116

2217
def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32}
2318
# TODO: Needs a check if `GetSystemTimePreciseAsFileTime` is actually available (only >= Windows 8)
@@ -47,4 +42,119 @@ module Crystal::System::Time
4742

4843
{ticks / @@performance_frequency, (ticks.remainder(NANOSECONDS_PER_SECOND) * NANOSECONDS_PER_SECOND / @@performance_frequency).to_i32}
4944
end
45+
46+
def self.load_localtime : ::Time::Location?
47+
if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_UNKNOWN
48+
initialize_location_from_TZI(info)
49+
end
50+
end
51+
52+
def self.zone_sources : Enumerable(String)
53+
[] of String
54+
end
55+
56+
private def self.initialize_location_from_TZI(info)
57+
stdname, dstname = normalize_zone_names(info)
58+
59+
if info.standardDate.wMonth == 0_u16
60+
# No DST
61+
zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false)
62+
return ::Time::Location.new("Local", [zone])
63+
end
64+
65+
zones = [
66+
::Time::Location::Zone.new(stdname, (info.bias + info.standardBias) * BIAS_TO_OFFSET_FACTOR, false),
67+
::Time::Location::Zone.new(dstname, (info.bias + info.daylightBias) * BIAS_TO_OFFSET_FACTOR, true),
68+
]
69+
70+
first_date = info.standardDate
71+
second_date = info.daylightDate
72+
first_index = 0_u8
73+
second_index = 1_u8
74+
75+
if info.standardDate.wMonth > info.daylightDate.wMonth
76+
first_date, second_date = second_date, first_date
77+
first_index, second_index = second_index, first_index
78+
end
79+
80+
transitions = [] of ::Time::Location::ZoneTransition
81+
82+
current_year = ::Time.utc_now.year
83+
84+
(current_year - 100).upto(current_year + 100) do |year|
85+
tstamp = calculate_switchdate_in_year(year, first_date) - (zones[second_index].offset)
86+
transitions << ::Time::Location::ZoneTransition.new(tstamp, first_index, first_index == 0, false)
87+
88+
tstamp = calculate_switchdate_in_year(year, second_date) - (zones[first_index].offset)
89+
transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false)
90+
end
91+
92+
::Time::Location.new("Local", zones, transitions)
93+
end
94+
95+
# Calculates the day of a DST switch in year *year* by extrapolating the date given in
96+
# *systemtime* (for the current year).
97+
#
98+
# Returns the number of seconds since UNIX epoch (Jan 1 1970) in the local time zone.
99+
private def self.calculate_switchdate_in_year(year, systemtime)
100+
# Windows specifies daylight savings information in "day in month" format:
101+
# wMonth is month number (1-12)
102+
# wDayOfWeek is appropriate weekday (Sunday=0 to Saturday=6)
103+
# wDay is week within the month (1 to 5, where 5 is last week of the month)
104+
# wHour, wMinute and wSecond are absolute time
105+
day = 1
106+
107+
time = ::Time.utc(year, systemtime.wMonth, day, systemtime.wHour, systemtime.wMinute, systemtime.wSecond)
108+
i = systemtime.wDayOfWeek.to_i32 - time.day_of_week.to_i32
109+
110+
if i < 0
111+
i += 7
112+
end
113+
114+
day += i
115+
116+
week = systemtime.wDay - 1
117+
118+
if week < 4
119+
day += week * 7
120+
else
121+
# "Last" instance of the day.
122+
day += 4 * 7
123+
if day > ::Time.days_in_month(year, systemtime.wMonth)
124+
day -= 7
125+
end
126+
end
127+
128+
time += (day - 1).days
129+
130+
time.epoch
131+
end
132+
133+
# Normalizes the names of the standard and dst zones.
134+
private def self.normalize_zone_names(info : LibC::TIME_ZONE_INFORMATION) : Tuple(String, String)
135+
stdname = String.from_utf16(info.standardName.to_unsafe)
136+
137+
if normalized_names = WINDOWS_ZONE_NAMES[stdname]?
138+
return normalized_names
139+
end
140+
141+
dstname = String.from_utf16(info.daylightName.to_unsafe)
142+
143+
if english_name = translate_zone_name(stdname, dstname)
144+
if normalized_names = WINDOWS_ZONE_NAMES[english_name]?
145+
return normalized_names
146+
end
147+
end
148+
149+
# As a last resort, return the raw names as provided by TIME_ZONE_INFORMATION.
150+
# They are most probably localized and we couldn't find a translation.
151+
return stdname, dstname
152+
end
153+
154+
# Searches the registry for an English name of a time zone named *stdname* or *dstname*
155+
# and returns the English name.
156+
private def self.translate_zone_name(stdname, dstname)
157+
# TODO: Needs implementation once there is access to the registry.
158+
nil
159+
end
50160
end
+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# This file was automatically generated by running:
2+
#
3+
# scripts/generate_windows_zone_names.cr
4+
#
5+
# DO NOT EDIT
6+
7+
module Crystal::System::Time
8+
# These mappings for windows time zone names are based on
9+
# http://unicode.org/cldr/data/common/supplemental/windowsZones.xml
10+
WINDOWS_ZONE_NAMES = {
11+
"Egypt Standard Time" => {"EET", "EET"}, # Africa/Cairo
12+
"Morocco Standard Time" => {"WET", "WEST"}, # Africa/Casablanca
13+
"South Africa Standard Time" => {"SAST", "SAST"}, # Africa/Johannesburg
14+
"W. Central Africa Standard Time" => {"WAT", "WAT"}, # Africa/Lagos
15+
"E. Africa Standard Time" => {"EAT", "EAT"}, # Africa/Nairobi
16+
"Libya Standard Time" => {"EET", "EET"}, # Africa/Tripoli
17+
"Namibia Standard Time" => {"WAT", "WAST"}, # Africa/Windhoek
18+
"Aleutian Standard Time" => {"HST", "HDT"}, # America/Adak
19+
"Alaskan Standard Time" => {"AKST", "AKDT"}, # America/Anchorage
20+
"Tocantins Standard Time" => {"BRT", "BRT"}, # America/Araguaina
21+
"Paraguay Standard Time" => {"PYT", "PYST"}, # America/Asuncion
22+
"Bahia Standard Time" => {"BRT", "BRT"}, # America/Bahia
23+
"SA Pacific Standard Time" => {"COT", "COT"}, # America/Bogota
24+
"Argentina Standard Time" => {"ART", "ART"}, # America/Buenos_Aires
25+
"Eastern Standard Time (Mexico)" => {"EST", "EST"}, # America/Cancun
26+
"Venezuela Standard Time" => {"VET", "VET"}, # America/Caracas
27+
"SA Eastern Standard Time" => {"GFT", "GFT"}, # America/Cayenne
28+
"Central Standard Time" => {"CST", "CDT"}, # America/Chicago
29+
"Mountain Standard Time (Mexico)" => {"MST", "MDT"}, # America/Chihuahua
30+
"Central Brazilian Standard Time" => {"AMT", "AMST"}, # America/Cuiaba
31+
"Mountain Standard Time" => {"MST", "MDT"}, # America/Denver
32+
"Greenland Standard Time" => {"WGT", "WGST"}, # America/Godthab
33+
"Turks And Caicos Standard Time" => {"AST", "AST"}, # America/Grand_Turk
34+
"Central America Standard Time" => {"CST", "CST"}, # America/Guatemala
35+
"Atlantic Standard Time" => {"AST", "ADT"}, # America/Halifax
36+
"Cuba Standard Time" => {"CST", "CDT"}, # America/Havana
37+
"US Eastern Standard Time" => {"EST", "EDT"}, # America/Indianapolis
38+
"SA Western Standard Time" => {"BOT", "BOT"}, # America/La_Paz
39+
"Pacific Standard Time" => {"PST", "PDT"}, # America/Los_Angeles
40+
"Central Standard Time (Mexico)" => {"CST", "CDT"}, # America/Mexico_City
41+
"Saint Pierre Standard Time" => {"PMST", "PMDT"}, # America/Miquelon
42+
"Montevideo Standard Time" => {"UYT", "UYT"}, # America/Montevideo
43+
"Eastern Standard Time" => {"EST", "EDT"}, # America/New_York
44+
"US Mountain Standard Time" => {"MST", "MST"}, # America/Phoenix
45+
"Haiti Standard Time" => {"EST", "EST"}, # America/Port-au-Prince
46+
"Canada Central Standard Time" => {"CST", "CST"}, # America/Regina
47+
"Pacific SA Standard Time" => {"CLT", "CLST"}, # America/Santiago
48+
"E. South America Standard Time" => {"BRT", "BRST"}, # America/Sao_Paulo
49+
"Newfoundland Standard Time" => {"NST", "NDT"}, # America/St_Johns
50+
"Pacific Standard Time (Mexico)" => {"PST", "PDT"}, # America/Tijuana
51+
"Central Asia Standard Time" => {"+06", "+06"}, # Asia/Almaty
52+
"Jordan Standard Time" => {"EET", "EEST"}, # Asia/Amman
53+
"Arabic Standard Time" => {"AST", "AST"}, # Asia/Baghdad
54+
"Azerbaijan Standard Time" => {"+04", "+04"}, # Asia/Baku
55+
"SE Asia Standard Time" => {"ICT", "ICT"}, # Asia/Bangkok
56+
"Altai Standard Time" => {"+07", "+07"}, # Asia/Barnaul
57+
"Middle East Standard Time" => {"EET", "EEST"}, # Asia/Beirut
58+
"India Standard Time" => {"IST", "IST"}, # Asia/Calcutta
59+
"Transbaikal Standard Time" => {"+09", "+09"}, # Asia/Chita
60+
"Sri Lanka Standard Time" => {"+0530", "+0530"}, # Asia/Colombo
61+
"Syria Standard Time" => {"EET", "EEST"}, # Asia/Damascus
62+
"Bangladesh Standard Time" => {"BDT", "BDT"}, # Asia/Dhaka
63+
"Arabian Standard Time" => {"GST", "GST"}, # Asia/Dubai
64+
"West Bank Standard Time" => {"EET", "EEST"}, # Asia/Hebron
65+
"W. Mongolia Standard Time" => {"HOVT", "HOVST"}, # Asia/Hovd
66+
"North Asia East Standard Time" => {"+08", "+08"}, # Asia/Irkutsk
67+
"Israel Standard Time" => {"IST", "IDT"}, # Asia/Jerusalem
68+
"Afghanistan Standard Time" => {"AFT", "AFT"}, # Asia/Kabul
69+
"Russia Time Zone 11" => {"+12", "+12"}, # Asia/Kamchatka
70+
"Pakistan Standard Time" => {"PKT", "PKT"}, # Asia/Karachi
71+
"Nepal Standard Time" => {"NPT", "NPT"}, # Asia/Katmandu
72+
"North Asia Standard Time" => {"+07", "+07"}, # Asia/Krasnoyarsk
73+
"Magadan Standard Time" => {"+11", "+11"}, # Asia/Magadan
74+
"N. Central Asia Standard Time" => {"+07", "+07"}, # Asia/Novosibirsk
75+
"Omsk Standard Time" => {"+06", "+06"}, # Asia/Omsk
76+
"North Korea Standard Time" => {"KST", "KST"}, # Asia/Pyongyang
77+
"Myanmar Standard Time" => {"MMT", "MMT"}, # Asia/Rangoon
78+
"Arab Standard Time" => {"AST", "AST"}, # Asia/Riyadh
79+
"Sakhalin Standard Time" => {"+11", "+11"}, # Asia/Sakhalin
80+
"Korea Standard Time" => {"KST", "KST"}, # Asia/Seoul
81+
"China Standard Time" => {"CST", "CST"}, # Asia/Shanghai
82+
"Singapore Standard Time" => {"SGT", "SGT"}, # Asia/Singapore
83+
"Russia Time Zone 10" => {"+11", "+11"}, # Asia/Srednekolymsk
84+
"Taipei Standard Time" => {"CST", "CST"}, # Asia/Taipei
85+
"West Asia Standard Time" => {"+05", "+05"}, # Asia/Tashkent
86+
"Georgian Standard Time" => {"+04", "+04"}, # Asia/Tbilisi
87+
"Iran Standard Time" => {"IRST", "IRDT"}, # Asia/Tehran
88+
"Tokyo Standard Time" => {"JST", "JST"}, # Asia/Tokyo
89+
"Tomsk Standard Time" => {"+07", "+07"}, # Asia/Tomsk
90+
"Ulaanbaatar Standard Time" => {"ULAT", "ULAST"}, # Asia/Ulaanbaatar
91+
"Vladivostok Standard Time" => {"+10", "+10"}, # Asia/Vladivostok
92+
"Yakutsk Standard Time" => {"+09", "+09"}, # Asia/Yakutsk
93+
"Ekaterinburg Standard Time" => {"+05", "+05"}, # Asia/Yekaterinburg
94+
"Caucasus Standard Time" => {"+04", "+04"}, # Asia/Yerevan
95+
"Azores Standard Time" => {"AZOT", "AZOST"}, # Atlantic/Azores
96+
"Cape Verde Standard Time" => {"CVT", "CVT"}, # Atlantic/Cape_Verde
97+
"Greenwich Standard Time" => {"GMT", "GMT"}, # Atlantic/Reykjavik
98+
"Cen. Australia Standard Time" => {"ACST", "ACDT"}, # Australia/Adelaide
99+
"E. Australia Standard Time" => {"AEST", "AEST"}, # Australia/Brisbane
100+
"AUS Central Standard Time" => {"ACST", "ACST"}, # Australia/Darwin
101+
"Aus Central W. Standard Time" => {"ACWST", "ACWST"}, # Australia/Eucla
102+
"Tasmania Standard Time" => {"AEST", "AEDT"}, # Australia/Hobart
103+
"Lord Howe Standard Time" => {"LHST", "LHDT"}, # Australia/Lord_Howe
104+
"W. Australia Standard Time" => {"AWST", "AWST"}, # Australia/Perth
105+
"AUS Eastern Standard Time" => {"AEST", "AEDT"}, # Australia/Sydney
106+
"UTC" => {"GMT", "GMT"}, # Etc/GMT
107+
"UTC-11" => {"-11", "-11"}, # Etc/GMT+11
108+
"Dateline Standard Time" => {"-12", "-12"}, # Etc/GMT+12
109+
"UTC-02" => {"-02", "-02"}, # Etc/GMT+2
110+
"UTC-08" => {"-08", "-08"}, # Etc/GMT+8
111+
"UTC-09" => {"-09", "-09"}, # Etc/GMT+9
112+
"UTC+12" => {"+12", "+12"}, # Etc/GMT-12
113+
"UTC+13" => {"+13", "+13"}, # Etc/GMT-13
114+
"Astrakhan Standard Time" => {"+04", "+04"}, # Europe/Astrakhan
115+
"W. Europe Standard Time" => {"CET", "CEST"}, # Europe/Berlin
116+
"GTB Standard Time" => {"EET", "EEST"}, # Europe/Bucharest
117+
"Central Europe Standard Time" => {"CET", "CEST"}, # Europe/Budapest
118+
"E. Europe Standard Time" => {"EET", "EEST"}, # Europe/Chisinau
119+
"Turkey Standard Time" => {"+03", "+03"}, # Europe/Istanbul
120+
"Kaliningrad Standard Time" => {"EET", "EET"}, # Europe/Kaliningrad
121+
"FLE Standard Time" => {"EET", "EEST"}, # Europe/Kiev
122+
"GMT Standard Time" => {"GMT", "BST"}, # Europe/London
123+
"Belarus Standard Time" => {"+03", "+03"}, # Europe/Minsk
124+
"Russian Standard Time" => {"MSK", "MSK"}, # Europe/Moscow
125+
"Romance Standard Time" => {"CET", "CEST"}, # Europe/Paris
126+
"Russia Time Zone 3" => {"+04", "+04"}, # Europe/Samara
127+
"Saratov Standard Time" => {"+04", "+04"}, # Europe/Saratov
128+
"Central European Standard Time" => {"CET", "CEST"}, # Europe/Warsaw
129+
"Mauritius Standard Time" => {"MUT", "MUT"}, # Indian/Mauritius
130+
"Samoa Standard Time" => {"WSST", "WSDT"}, # Pacific/Apia
131+
"New Zealand Standard Time" => {"NZST", "NZDT"}, # Pacific/Auckland
132+
"Bougainville Standard Time" => {"BST", "BST"}, # Pacific/Bougainville
133+
"Chatham Islands Standard Time" => {"CHAST", "CHADT"}, # Pacific/Chatham
134+
"Easter Island Standard Time" => {"EAST", "EASST"}, # Pacific/Easter
135+
"Fiji Standard Time" => {"FJT", "FJST"}, # Pacific/Fiji
136+
"Central Pacific Standard Time" => {"SBT", "SBT"}, # Pacific/Guadalcanal
137+
"Hawaiian Standard Time" => {"HST", "HST"}, # Pacific/Honolulu
138+
"Line Islands Standard Time" => {"LINT", "LINT"}, # Pacific/Kiritimati
139+
"Marquesas Standard Time" => {"MART", "MART"}, # Pacific/Marquesas
140+
"Norfolk Standard Time" => {"NFT", "NFT"}, # Pacific/Norfolk
141+
"West Pacific Standard Time" => {"PGT", "PGT"}, # Pacific/Port_Moresby
142+
"Tonga Standard Time" => {"+13", "+14"}, # Pacific/Tongatapu
143+
144+
}
145+
end

‎src/file/stat.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ class File
202202
end
203203
{% else %}
204204
private def time(value)
205-
Time.new value, Time::Kind::Utc
205+
Time.new value, Time::Location::UTC
206206
end
207207
{% end %}
208208
end

‎src/http/common.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ module HTTP
225225
def self.parse_time(time_str : String) : Time?
226226
DATE_PATTERNS.each do |pattern|
227227
begin
228-
return Time.parse(time_str, pattern, kind: Time::Kind::Utc)
228+
return Time.parse(time_str, pattern, location: Time::Location::UTC)
229229
rescue Time::Format::Error
230230
end
231231
end

‎src/json/from_json.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ end
237237
struct Time::Format
238238
def from_json(pull : JSON::PullParser)
239239
string = pull.read_string
240-
parse(string)
240+
parse(string, Time::Location::UTC)
241241
end
242242
end
243243

‎src/lib_c/x86_64-windows-msvc/c/winbase.cr

+4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ lib LibC
4141
daylightBias : LONG
4242
end
4343

44+
TIME_ZONE_ID_UNKNOWN = 0_u32
45+
TIME_ZONE_ID_STANDARD = 1_u32
46+
TIME_ZONE_ID_DAYLIGHT = 2_u32
47+
4448
fun GetTimeZoneInformation(tz_info : TIME_ZONE_INFORMATION*) : DWORD
4549
fun GetSystemTimeAsFileTime(time : FILETIME*)
4650
fun GetSystemTimePreciseAsFileTime(time : FILETIME*)

‎src/time.cr

+132-141
Large diffs are not rendered by default.

‎src/time/format.cr

+4-4
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,15 @@ struct Time::Format
6666
getter pattern : String
6767

6868
# Creates a new `Time::Format` with the given *pattern*. The given time
69-
# *kind* will be used when parsing a `Time` and no time zone is found in it.
70-
def initialize(@pattern : String, @kind = Time::Kind::Unspecified)
69+
# *location* will be used when parsing a `Time` and no time zone is found in it.
70+
def initialize(@pattern : String, @location : Location? = nil)
7171
end
7272

7373
# Parses a string into a `Time`.
74-
def parse(string, kind = @kind) : Time
74+
def parse(string, location = @location) : Time
7575
parser = Parser.new(string)
7676
parser.visit(pattern)
77-
parser.time(kind)
77+
parser.time(location)
7878
end
7979

8080
# Turns a `Time` into a `String`.

‎src/time/format/formatter.cr

+29-29
Original file line numberDiff line numberDiff line change
@@ -147,51 +147,51 @@ struct Time::Format
147147
io << time.epoch
148148
end
149149

150-
def time_zone
151-
case time.kind
152-
when Time::Kind::Utc, Time::Kind::Unspecified
153-
io << "+0000"
154-
when Time::Kind::Local
155-
negative, hours, minutes = local_time_zone_info
156-
io << (negative ? "-" : "+")
157-
io << "0" if hours < 10
158-
io << hours
159-
io << "0" if minutes < 10
160-
io << minutes
150+
def time_zone(with_seconds = false)
151+
negative, hours, minutes, seconds = local_time_zone_info
152+
io << (negative ? '-' : '+')
153+
io << '0' if hours < 10
154+
io << hours
155+
io << '0' if minutes < 10
156+
io << minutes
157+
if with_seconds
158+
io << '0' if seconds < 10
159+
io << seconds
161160
end
162161
end
163162

164-
def time_zone_colon
165-
case time.kind
166-
when Time::Kind::Utc, Time::Kind::Unspecified
167-
io << "+00:00"
168-
when Time::Kind::Local
169-
negative, hours, minutes = local_time_zone_info
170-
io << (negative ? "-" : "+")
171-
io << "0" if hours < 10
172-
io << hours
173-
io << ":"
174-
io << "0" if minutes < 10
175-
io << minutes
163+
def time_zone_colon(with_seconds = false)
164+
negative, hours, minutes, seconds = local_time_zone_info
165+
io << (negative ? '-' : '+')
166+
io << '0' if hours < 10
167+
io << hours
168+
io << ':'
169+
io << '0' if minutes < 10
170+
io << minutes
171+
if with_seconds
172+
io << ':'
173+
io << '0' if seconds < 10
174+
io << seconds
176175
end
177176
end
178177

179178
def time_zone_colon_with_seconds
180-
time_zone_colon
181-
io << ":00"
179+
time_zone_colon(with_seconds: true)
182180
end
183181

184182
def local_time_zone_info
185-
minutes = Time.local_offset_in_minutes
186-
if minutes < 0
187-
minutes = -minutes
183+
offset = time.offset
184+
if offset < 0
185+
offset = -offset
188186
negative = true
189187
else
190188
negative = false
191189
end
190+
seconds = offset % 60
191+
minutes = offset / 60
192192
hours = minutes / 60
193193
minutes = minutes % 60
194-
{negative, hours, minutes}
194+
{negative, hours, minutes, seconds}
195195
end
196196

197197
def char(char)

‎src/time/format/parser.cr

+26-22
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ struct Time::Format
44
include Pattern
55

66
@epoch : Int64?
7+
@location : Location?
78

89
def initialize(string)
910
@reader = Char::Reader.new(string)
@@ -17,26 +18,19 @@ struct Time::Format
1718
@pm = false
1819
end
1920

20-
def time(kind = Time::Kind::Unspecified)
21+
def time(location : Location? = nil)
2122
@hour += 12 if @pm
2223

23-
time_kind = @kind || kind
24-
2524
if epoch = @epoch
2625
return Time.epoch(epoch)
2726
end
2827

29-
time = Time.new @year, @month, @day, @hour, @minute, @second, nanosecond: @nanosecond, kind: time_kind
30-
31-
if offset_in_minutes = @offset_in_minutes
32-
time -= offset_in_minutes.minutes if offset_in_minutes != 0
33-
34-
if (offset_in_minutes != 0) || (kind == Time::Kind::Local && !time.local?)
35-
time = time.to_local
36-
end
28+
location = @location || location
29+
if location.nil?
30+
raise "Time format did not include time zone and no default location provided"
3731
end
3832

39-
time
33+
Time.new @year, @month, @day, @hour, @minute, @second, nanosecond: @nanosecond, location: location
4034
end
4135

4236
def year
@@ -232,16 +226,14 @@ struct Time::Format
232226
@epoch = consume_number_i64(19) * (epoch_negative ? -1 : 1)
233227
end
234228

235-
def time_zone
229+
def time_zone(with_seconds = false)
236230
case char = current_char
237231
when 'Z'
238-
@offset_in_minutes = 0
239-
@kind = Time::Kind::Utc
232+
@location = Location::UTC
240233
next_char
241234
when 'U'
242235
if next_char == 'T' && next_char == 'C'
243-
@offset_in_minutes = 0
244-
@kind = Time::Kind::Utc
236+
@location = Location::UTC
245237
next_char
246238
else
247239
raise "Invalid timezone"
@@ -255,7 +247,7 @@ struct Time::Format
255247

256248
char = next_char
257249
raise "Invalid timezone" unless char.ascii_number?
258-
hours = 10*hours + char.to_i
250+
hours = 10 * hours + char.to_i
259251

260252
char = next_char
261253
char = next_char if char == ':'
@@ -264,10 +256,22 @@ struct Time::Format
264256

265257
char = next_char
266258
raise "Invalid timezone" unless char.ascii_number?
267-
minutes = 10*minutes + char.to_i
259+
minutes = 10 * minutes + char.to_i
260+
261+
if with_seconds
262+
char = next_char
263+
char = next_char if char == ':'
264+
raise "Invalid timezone" unless char.ascii_number?
265+
seconds = char.to_i
268266

269-
@offset_in_minutes = sign * (60*hours + minutes)
270-
@kind = Time::Kind::Utc
267+
char = next_char
268+
raise "Invalid timezone" unless char.ascii_number?
269+
seconds = 10 * seconds + char.to_i
270+
else
271+
seconds = 0
272+
end
273+
274+
@location = Location.fixed(sign * (3600 * hours + 60 * minutes + seconds))
271275
char = next_char
272276

273277
if @reader.has_next?
@@ -288,7 +292,7 @@ struct Time::Format
288292
end
289293

290294
def time_zone_colon_with_seconds
291-
time_zone
295+
time_zone(with_seconds: true)
292296
end
293297

294298
def char(char)

‎src/time/location.cr

+295
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
require "./location/loader"
2+
3+
# `Location` represents a specific time zone.
4+
#
5+
# It can be either a time zone from the IANA Time Zone database,
6+
# a fixed offset, or `UTC`.
7+
#
8+
# Creating a location from timezone data:
9+
# ```
10+
# location = Time::Location.load("Europe/Berlin")
11+
# ```
12+
#
13+
# Initializing a `Time` instance with specified `Location`:
14+
#
15+
# ```
16+
# time = Time.new(2016, 2, 15, 21, 1, 10, location)
17+
# ```
18+
#
19+
# Alternatively, you can switch the `Location` for any `Time` instance:
20+
#
21+
# ```
22+
# time.location # => Europe/Berlin
23+
# time.in(Time::Location.load("Asia/Jerusalem"))
24+
# time.location # => Asia/Jerusalem
25+
# ```
26+
#
27+
# There are also a few special conversions:
28+
# ```
29+
# time.to_utc # == time.in(Location::UTC)
30+
# time.to_local # == time.in(Location.local)
31+
# ```
32+
class Time::Location
33+
class InvalidLocationNameError < Exception
34+
getter name, source
35+
36+
def initialize(@name : String, @source : String? = nil)
37+
msg = "Invalid location name: #{name}"
38+
msg += " in #{source}" if source
39+
super msg
40+
end
41+
end
42+
43+
class InvalidTimezoneOffsetError < Exception
44+
def initialize(offset : Int)
45+
super "Invalid time zone offset: #{offset}"
46+
end
47+
end
48+
49+
struct Zone
50+
UTC = new "UTC", 0, false
51+
52+
getter name : String
53+
getter offset : Int32
54+
getter? dst : Bool
55+
56+
def initialize(@name : String, @offset : Int32, @dst : Bool)
57+
# Maximium offets of IANA timezone database are -12:00 and +14:00.
58+
# +/-24 hours allows a generous padding for unexpected offsets.
59+
# TODO: Maybe reduce to Int16 (+/- 18 hours).
60+
raise InvalidTimezoneOffsetError.new(offset) if offset >= SECONDS_PER_DAY || offset <= -SECONDS_PER_DAY
61+
end
62+
63+
def inspect(io : IO)
64+
io << "Time::Zone<"
65+
io << offset
66+
io << ", " << name
67+
io << " (DST)" if dst?
68+
io << '>'
69+
end
70+
end
71+
72+
# :nodoc:
73+
record ZoneTransition, when : Int64, index : UInt8, standard : Bool, utc : Bool do
74+
getter? standard, utc
75+
76+
def inspect(io : IO)
77+
io << "Time::ZoneTransition<"
78+
io << '#' << index << ", "
79+
Time.epoch(self.when).to_s("%F %T", io)
80+
io << ", STD" if standard?
81+
io << ", UTC" if utc?
82+
io << '>'
83+
end
84+
end
85+
86+
# Describes the Coordinated Universal Time (UTC).
87+
UTC = new "UTC", [Zone::UTC]
88+
89+
property name : String
90+
property zones : Array(Zone)
91+
92+
# Most lookups will be for the current time.
93+
# To avoid the binary search through tx, keep a
94+
# static one-element cache that gives the correct
95+
# zone for the time when the Location was created.
96+
# The units for @cached_range are seconds
97+
# since January 1, 1970 UTC, to match the argument
98+
# to `#lookup`.
99+
@cached_range : Tuple(Int64, Int64)
100+
@cached_zone : Zone
101+
102+
# Creates a `Location` instance named *name* with fixed *offset*.
103+
def self.fixed(name : String, offset : Int32)
104+
new name, [Zone.new(name, offset, false)]
105+
end
106+
107+
# Creates a `Location` instance with fixed *offset*.
108+
def self.fixed(offset : Int32)
109+
span = offset.abs.seconds
110+
name = sprintf("%s%02d:%02d", offset.sign < 0 ? '-' : '+', span.hours, span.minutes)
111+
fixed name, offset
112+
end
113+
114+
# Returns the `Location` with the given name.
115+
#
116+
# This uses a list of paths to look for timezone data. Each path can
117+
# either point to a directory or an uncompressed ZIP file.
118+
# System-specific default paths are provided by the implementation.
119+
#
120+
# The first timezone data matching the given name that is successfully loaded
121+
# and parsed is returned.
122+
# A custom lookup path can be set as environment variable `ZONEINFO`.
123+
#
124+
# Special names:
125+
# * `"UTC"` and empty string `""` return `Location::UTC`
126+
# * `"Local"` returns `Location.local`
127+
#
128+
# This method caches files based on the modification time, so subsequent loads
129+
# of the same location name will return the same instance of `Location` unless
130+
# the timezone database has been updated in between.
131+
#
132+
# Example:
133+
# `ZONEINFO=/path/to/zoneinfo.zip crystal eval 'pp Location.load("Custom/Location")'`
134+
def self.load(name : String) : Location
135+
case name
136+
when "", "UTC"
137+
UTC
138+
when "Local"
139+
local
140+
when .includes?(".."), .starts_with?('/'), .starts_with?('\\')
141+
# No valid IANA Time Zone name contains a single dot,
142+
# much less dot dot. Likewise, none begin with a slash.
143+
raise InvalidLocationNameError.new(name)
144+
else
145+
if zoneinfo = ENV["ZONEINFO"]?
146+
if location = load_from_dir_or_zip(name, zoneinfo)
147+
return location
148+
else
149+
raise InvalidLocationNameError.new(name, zoneinfo)
150+
end
151+
end
152+
153+
if location = load(name, Crystal::System::Time.zone_sources)
154+
return location
155+
end
156+
157+
raise InvalidLocationNameError.new(name)
158+
end
159+
end
160+
161+
# Returns the location representing the local time zone.
162+
#
163+
# The value is loaded on first access based on the current application environment (see `.load_local` for details).
164+
class_property(local : Location) { load_local }
165+
166+
# Loads the local location described by the the current application environment.
167+
#
168+
# It consults the environment variable `ENV["TZ"]` to find the time zone to use.
169+
# * `"UTC"` and empty string `""` return `Location::UTC`
170+
# * `"Foo/Bar"` tries to load the zoneinfo from known system locations - such as `/usr/share/zoneinfo/Foo/Bar`,
171+
# `/usr/share/lib/zoneinfo/Foo/Bar` or `/usr/lib/locale/TZ/Foo/Bar` on unix-based operating systems.
172+
# See `Location.load` for details.
173+
# * If `ENV["TZ"]` is not set, the system's local timezone data will be used (`/etc/localtime` on unix-based systems).
174+
# * If no time zone data could be found, `Location::UTC` is returned.
175+
def self.load_local : Location
176+
case tz = ENV["TZ"]?
177+
when "", "UTC"
178+
UTC
179+
when Nil
180+
if localtime = Crystal::System::Time.load_localtime
181+
return localtime
182+
end
183+
else
184+
if location = load?(tz, Crystal::System::Time.zone_sources)
185+
return location
186+
end
187+
end
188+
189+
UTC
190+
end
191+
192+
# :nodoc:
193+
def initialize(@name : String, @zones : Array(Zone), @transitions = [] of ZoneTransition)
194+
@cached_zone = lookup_first_zone
195+
@cached_range = {Int64::MIN, @zones.size <= 1 ? Int64::MAX : Int64::MIN}
196+
end
197+
198+
protected def transitions
199+
@transitions
200+
end
201+
202+
def to_s(io : IO)
203+
io << name
204+
end
205+
206+
def inspect(io : IO)
207+
io << "Time::Location<"
208+
to_s(io)
209+
io << '>'
210+
end
211+
212+
def_equals_and_hash name, zones, transitions
213+
214+
# Returns the time zone in use at `time`.
215+
def lookup(time : Time) : Zone
216+
lookup(time.epoch)
217+
end
218+
219+
# Returns the time zone in use at `epoch` (time in seconds since UNIX epoch).
220+
def lookup(epoch : Int) : Zone
221+
unless @cached_range[0] <= epoch < @cached_range[1]
222+
@cached_zone, @cached_range = lookup_with_boundaries(epoch)
223+
end
224+
225+
@cached_zone
226+
end
227+
228+
# :nodoc:
229+
def lookup_with_boundaries(epoch : Int)
230+
case
231+
when zones.empty?
232+
return Zone::UTC, {Int64::MIN, Int64::MAX}
233+
when transitions.empty? || epoch < transitions.first.when
234+
return lookup_first_zone, {Int64::MIN, transitions[0]?.try(&.when) || Int64::MAX}
235+
else
236+
tx_index = transitions.bsearch_index do |transition|
237+
transition.when > epoch
238+
end || transitions.size
239+
240+
tx_index -= 1 unless tx_index == 0
241+
transition = transitions[tx_index]
242+
range_end = transitions[tx_index + 1]?.try(&.when) || Int64::MAX
243+
244+
return zones[transition.index], {transition.when, range_end}
245+
end
246+
end
247+
248+
# Returns the time zone to use for times before the first transition
249+
# time, or when there are no transition times.
250+
#
251+
# The reference implementation in localtime.c from
252+
# http:#www.iana.org/time-zones/repository/releases/tzcode2013g.tar.gz
253+
# implements the following algorithm for these cases:
254+
# 1) If the first zone is unused by the transitions, use it.
255+
# 2) Otherwise, if there are transition times, and the first
256+
# transition is to a zone in daylight time, find the first
257+
# non-daylight-time zone before and closest to the first transition
258+
# zone.
259+
# 3) Otherwise, use the first zone that is not daylight time, if
260+
# there is one.
261+
# 4) Otherwise, use the first zone.
262+
private def lookup_first_zone
263+
unless transitions.any? { |tx| tx.index == 0 }
264+
return zones.first
265+
end
266+
267+
if (tx = transitions[0]?) && zones[tx.index].dst?
268+
index = tx.index
269+
while index > 0
270+
index -= 1
271+
zone = zones[index]
272+
return zone unless zone.dst?
273+
end
274+
end
275+
276+
first_zone_without_dst = zones.find { |tx| !tx.dst? }
277+
278+
first_zone_without_dst || zones.first
279+
end
280+
281+
# Returns `true` if this location equals to `UTC`.
282+
def utc? : Bool
283+
self == UTC
284+
end
285+
286+
# Returns `true` if this location equals to `Location.local`.
287+
def local? : Bool
288+
self == Location.local
289+
end
290+
291+
# Returns `true` if this location has a fixed offset.
292+
def fixed? : Bool
293+
zones.size <= 1
294+
end
295+
end

‎src/time/location/loader.cr

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
class Time::Location
2+
@@location_cache = {} of String => NamedTuple(time: Time, location: Location)
3+
4+
class InvalidTZDataError < Exception
5+
def self.initialize(message : String? = "Malformed time zone information", cause : Exception? = nil)
6+
super(message, cause)
7+
end
8+
end
9+
10+
# :nodoc:
11+
def self.load?(name : String, sources : Enumerable(String))
12+
if source = find_zoneinfo_file(name, sources)
13+
load_from_dir_or_zip(name, source)
14+
end
15+
end
16+
17+
# :nodoc:
18+
def self.load(name : String, sources : Enumerable(String))
19+
if source = find_zoneinfo_file(name, sources)
20+
load_from_dir_or_zip(name, source) || raise InvalidLocationNameError.new(name, source)
21+
end
22+
end
23+
24+
# :nodoc:
25+
def self.load_from_dir_or_zip(name : String, source : String)
26+
{% if flag?(:win32) %}
27+
raise NotImplementedError.new("Time::Location.load_from_dir_or_zip")
28+
{% else %}
29+
if source.ends_with?(".zip")
30+
open_file_cached(name, source) do |file|
31+
read_zip_file(name, file) do |io|
32+
read_zoneinfo(name, io)
33+
end
34+
end
35+
else
36+
path = File.expand_path(name, source)
37+
open_file_cached(name, path) do |file|
38+
read_zoneinfo(name, file)
39+
end
40+
end
41+
{% end %}
42+
end
43+
44+
private def self.open_file_cached(name : String, path : String)
45+
return nil unless File.exists?(path)
46+
47+
mtime = File.stat(path).mtime
48+
if (cache = @@location_cache[name]?) && cache[:time] == mtime
49+
return cache[:location]
50+
else
51+
File.open(path) do |file|
52+
location = yield file
53+
if location
54+
@@location_cache[name] = {time: mtime, location: location}
55+
56+
return location
57+
end
58+
end
59+
end
60+
end
61+
62+
# :nodoc:
63+
def self.find_zoneinfo_file(name : String, sources : Enumerable(String))
64+
{% if flag?(:win32) %}
65+
raise NotImplementedError.new("Time::Location.find_zoneinfo_file")
66+
{% else %}
67+
sources.each do |source|
68+
if source.ends_with?(".zip")
69+
return source if File.exists?(source)
70+
else
71+
path = File.expand_path(name, source)
72+
return source if File.exists?(path)
73+
end
74+
end
75+
{% end %}
76+
end
77+
78+
# Parse "zoneinfo" time zone file.
79+
# This is the standard file format used by most operating systems.
80+
# See https://data.iana.org/time-zones/tz-link.html, https://github.com/eggert/tz, tzfile(5)
81+
82+
# :nodoc:
83+
def self.read_zoneinfo(location_name : String, io : IO)
84+
raise InvalidTZDataError.new unless io.read_string(4) == "TZif"
85+
86+
# 1-byte version, then 15 bytes of padding
87+
version = io.read_byte
88+
raise InvalidTZDataError.new unless {0_u8, '2'.ord, '3'.ord}.includes?(version)
89+
io.skip(15)
90+
91+
# six big-endian 32-bit integers:
92+
# number of UTC/local indicators
93+
# number of standard/wall indicators
94+
# number of leap seconds
95+
# number of transition times
96+
# number of local time zones
97+
# number of characters of time zone abbrev strings
98+
99+
num_utc_local = read_int32(io)
100+
num_std_wall = read_int32(io)
101+
num_leap_seconds = read_int32(io)
102+
num_transitions = read_int32(io)
103+
num_local_time_zones = read_int32(io)
104+
abbrev_length = read_int32(io)
105+
106+
transitionsdata = read_buffer(io, num_transitions * 4)
107+
108+
# Time zone indices for transition times.
109+
transition_indexes = Bytes.new(num_transitions)
110+
io.read_fully(transition_indexes)
111+
112+
zonedata = read_buffer(io, num_local_time_zones * 6)
113+
114+
abbreviations = read_buffer(io, abbrev_length)
115+
116+
leap_second_time_pairs = Bytes.new(num_leap_seconds)
117+
io.read_fully(leap_second_time_pairs)
118+
119+
isstddata = Bytes.new(num_std_wall)
120+
io.read_fully(isstddata)
121+
122+
isutcdata = Bytes.new(num_utc_local)
123+
io.read_fully(isutcdata)
124+
125+
# If version == 2 or 3, the entire file repeats, this time using
126+
# 8-byte ints for txtimes and leap seconds.
127+
# We won't need those until 2106.
128+
129+
zones = Array(Zone).new(num_local_time_zones) do
130+
offset = read_int32(zonedata)
131+
is_dst = zonedata.read_byte != 0_u8
132+
name_idx = zonedata.read_byte
133+
raise InvalidTZDataError.new unless name_idx && name_idx < abbreviations.size
134+
abbreviations.pos = name_idx
135+
name = abbreviations.gets(Char::ZERO, chomp: true)
136+
raise InvalidTZDataError.new unless name
137+
Zone.new(name, offset, is_dst)
138+
end
139+
140+
transitions = Array(ZoneTransition).new(num_transitions) do |transition_id|
141+
time = read_int32(transitionsdata).to_i64
142+
zone_idx = transition_indexes[transition_id]
143+
raise InvalidTZDataError.new unless zone_idx < zones.size
144+
145+
isstd = !{nil, 0_u8}.includes? isstddata[transition_id]?
146+
isutc = !{nil, 0_u8}.includes? isstddata[transition_id]?
147+
148+
ZoneTransition.new(time, zone_idx, isstd, isutc)
149+
end
150+
151+
new(location_name, zones, transitions)
152+
end
153+
154+
private def self.read_int32(io : IO)
155+
io.read_bytes(Int32, IO::ByteFormat::BigEndian)
156+
end
157+
158+
private def self.read_buffer(io : IO, size : Int)
159+
buffer = Bytes.new(size)
160+
io.read_fully(buffer)
161+
IO::Memory.new(buffer)
162+
end
163+
164+
# :nodoc:
165+
CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
166+
# :nodoc:
167+
END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x06054b50
168+
# :nodoc:
169+
ZIP_TAIL_SIZE = 22
170+
# :nodoc:
171+
LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
172+
# :nodoc:
173+
COMPRESSION_METHOD_UNCOMPRESSED = 0_i16
174+
175+
# This method loads an entry from an uncompressed zip file.
176+
# See http://www.onicos.com/staff/iz/formats/zip.html for ZIP format layout
177+
private def self.read_zip_file(name : String, file : IO::FileDescriptor)
178+
file.seek -ZIP_TAIL_SIZE, IO::Seek::End
179+
180+
if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE
181+
raise InvalidTZDataError.new("corrupt zip file")
182+
end
183+
184+
file.skip 6
185+
num_entries = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
186+
file.skip 4
187+
188+
file.pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)
189+
190+
num_entries.times do
191+
break if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != CENTRAL_DIRECTORY_HEADER_SIGNATURE
192+
193+
file.skip 6
194+
compression_method = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
195+
file.skip 12
196+
uncompressed_size = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)
197+
filename_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
198+
extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
199+
file_comment_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
200+
file.skip 8
201+
local_file_header_pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)
202+
filename = file.read_string(filename_length)
203+
204+
unless filename == name
205+
file.skip extra_field_length + file_comment_length
206+
next
207+
end
208+
209+
unless compression_method == COMPRESSION_METHOD_UNCOMPRESSED
210+
raise InvalidTZDataError.new("Unsupported compression for #{name}")
211+
end
212+
213+
file.pos = local_file_header_pos
214+
215+
unless file.read_bytes(Int32, IO::ByteFormat::LittleEndian) == LOCAL_FILE_HEADER_SIGNATURE
216+
raise InvalidTZDataError.new("Invalid Zip file")
217+
end
218+
file.skip 4
219+
unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == COMPRESSION_METHOD_UNCOMPRESSED
220+
raise InvalidTZDataError.new("Invalid Zip file")
221+
end
222+
file.skip 16
223+
unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == filename_length
224+
raise InvalidTZDataError.new("Invalid Zip file")
225+
end
226+
extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
227+
unless file.gets(filename_length) == name
228+
raise InvalidTZDataError.new("Invalid Zip file")
229+
end
230+
231+
file.skip extra_field_length
232+
233+
return yield file
234+
end
235+
end
236+
end

‎src/yaml/from_yaml.cr

+1-1
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ struct Time::Format
213213
node.raise "Expected scalar, not #{node.class}"
214214
end
215215

216-
parse(node.value)
216+
parse(node.value, Time::Location::UTC)
217217
end
218218
end
219219

‎src/yaml/to_yaml.cr

+3-2
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,16 @@ end
108108

109109
struct Time
110110
def to_yaml(yaml : YAML::Nodes::Builder)
111-
if kind.utc? || kind.unspecified?
111+
case
112+
when utc?
112113
if hour == 0 && minute == 0 && second == 0 && millisecond == 0
113114
yaml.scalar Time::Format.new("%F").format(self)
114115
elsif millisecond == 0
115116
yaml.scalar Time::Format.new("%F %X").format(self)
116117
else
117118
yaml.scalar Time::Format.new("%F %X.%L").format(self)
118119
end
119-
elsif millisecond == 0
120+
when millisecond == 0
120121
yaml.scalar Time::Format.new("%F %X %:z").format(self)
121122
else
122123
yaml.scalar Time::Format.new("%F %X.%L %:z").format(self)

0 commit comments

Comments
 (0)
Please sign in to comment.