diff --git a/scripts/generate_windows_zone_names.cr b/scripts/generate_windows_zone_names.cr new file mode 100644 index 000000000000..4c0a40c7c369 --- /dev/null +++ b/scripts/generate_windows_zone_names.cr @@ -0,0 +1,76 @@ +# This script generates the file src/crystal/system/win32/zone_names.cr +# that contains mappings for windows time zone names based on the values +# found in http://unicode.org/cldr/data/common/supplemental/windowsZones.xml + +require "http/client" +require "xml" +require "../src/compiler/crystal/formatter" + +WINDOWS_ZONE_NAMES_SOURCE = "http://unicode.org/cldr/data/common/supplemental/windowsZones.xml" +TARGET_FILE = File.join(__DIR__, "..", "src", "crystal", "system", "win32", "zone_names.cr") + +response = HTTP::Client.get(WINDOWS_ZONE_NAMES_SOURCE) + +# Simple redirection resolver +# TODO: Needs to be replaced by proper redirect handling that should be provided by `HTTP::Client` +if (300..399).includes?(response.status_code) && (location = response.headers["Location"]?) + response = HTTP::Client.get(location) +end + +xml = XML.parse(response.body) + +nodes = xml.xpath_nodes("/supplementalData/windowsZones/mapTimezones/mapZone[@territory=001]") + +entries = [] of {key: String, zones: {String, String}, tzdata_name: String} + +nodes.each do |node| + location = Time::Location.load(node["type"]) + next unless location + time = Time.now(location).at_beginning_of_year + zone1 = time.zone + zone2 = (time + 6.months).zone + + if zone1.offset > zone2.offset + # southern hemisphere + zones = {zone2.name, zone1.name} + else + # northern hemisphere + zones = {zone1.name, zone2.name} + end + + entries << {key: node["other"], zones: zones, tzdata_name: location.name} +rescue err : Time::Location::InvalidLocationNameError + pp err +end + +# sort by IANA database identifier +entries.sort_by! &.[:tzdata_name] + +hash_items = String.build do |io| + entries.each do |entry| + entry[:key].inspect(io) + io << " => " + entry[:zones].inspect(io) + io << ", # " << entry[:tzdata_name] << "\n" + end +end + +source = <<-CRYSTAL + # This file was automatically generated by running: + # + # scripts/generate_windows_zone_names.cr + # + # DO NOT EDIT + + module Crystal::System::Time + # These mappings for windows time zone names are based on + # #{WINDOWS_ZONE_NAMES_SOURCE} + WINDOWS_ZONE_NAMES = { + #{hash_items} + } + end + CRYSTAL + +source = Crystal.format(source) + +File.write(TARGET_FILE, source) diff --git a/spec/std/data/zoneinfo.zip b/spec/std/data/zoneinfo.zip new file mode 100644 index 000000000000..b60ae7f98e75 Binary files /dev/null and b/spec/std/data/zoneinfo.zip differ diff --git a/spec/std/data/zoneinfo/Foo/Bar b/spec/std/data/zoneinfo/Foo/Bar new file mode 100644 index 000000000000..5583f5b0c6e6 Binary files /dev/null and b/spec/std/data/zoneinfo/Foo/Bar differ diff --git a/spec/std/file_spec.cr b/spec/std/file_spec.cr index 84e838613c3b..fe6c1f520edc 100644 --- a/spec/std/file_spec.cr +++ b/spec/std/file_spec.cr @@ -1033,8 +1033,8 @@ describe "File" do filename = "#{__DIR__}/data/temp_write.txt" File.write(filename, "") - atime = Time.new(2000, 1, 2) - mtime = Time.new(2000, 3, 4) + atime = Time.utc(2000, 1, 2) + mtime = Time.utc(2000, 3, 4) File.utime(atime, mtime, filename) @@ -1046,8 +1046,8 @@ describe "File" do end it "raises if file not found" do - atime = Time.new(2000, 1, 2) - mtime = Time.new(2000, 3, 4) + atime = Time.utc(2000, 1, 2) + mtime = Time.utc(2000, 3, 4) expect_raises Errno, "Error setting time to file" do File.utime(atime, mtime, "#{__DIR__}/nonexistent_file") @@ -1069,7 +1069,7 @@ describe "File" do it "sets file times to given time" do filename = "#{__DIR__}/data/temp_touch.txt" - time = Time.new(2000, 3, 4) + time = Time.utc(2000, 3, 4) begin File.touch(filename, time) diff --git a/spec/std/http/http_spec.cr b/spec/std/http/http_spec.cr index e29d4b7ae55f..0d2d2b3fc6c8 100644 --- a/spec/std/http/http_spec.cr +++ b/spec/std/http/http_spec.cr @@ -33,7 +33,7 @@ describe HTTP do it "parses and is local (#2744)" do date = "Mon, 09 Sep 2011 23:36:00 -0300" parsed_time = HTTP.parse_time(date).not_nil! - parsed_time.local?.should be_true + parsed_time.offset.should eq -3 * 3600 parsed_time.to_utc.to_s.should eq("2011-09-10 02:36:00 UTC") end @@ -44,16 +44,8 @@ describe HTTP do end it "with local time zone" do - tz = ENV["TZ"]? - ENV["TZ"] = "Europe/Berlin" - LibC.tzset - begin - time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, kind: Time::Kind::Local) - HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT")) - ensure - ENV["TZ"] = tz - LibC.tzset - end + time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, location: Time::Location.load("Europe/Berlin")) + HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT")) end end diff --git a/spec/std/json/mapping_spec.cr b/spec/std/json/mapping_spec.cr index 59d5bab2a15e..6edb552c4b82 100644 --- a/spec/std/json/mapping_spec.cr +++ b/spec/std/json/mapping_spec.cr @@ -252,7 +252,7 @@ describe "JSON mapping" do it "parses json with Time::Format converter" do json = JSONWithTime.from_json(%({"value": "2014-10-31 23:37:16"})) json.value.should be_a(Time) - json.value.to_s.should eq("2014-10-31 23:37:16") + json.value.to_s.should eq("2014-10-31 23:37:16 UTC") json.to_json.should eq(%({"value":"2014-10-31 23:37:16"})) end diff --git a/spec/std/time/location_spec.cr b/spec/std/time/location_spec.cr new file mode 100644 index 000000000000..e4a9bea15190 --- /dev/null +++ b/spec/std/time/location_spec.cr @@ -0,0 +1,327 @@ +require "spec" +require "./spec_helper" + +class Time::Location + def __cached_range + @cached_range + end + + def __cached_zone + @cached_zone + end + + def __cached_zone=(zone) + @cached_zone = zone + end + + def self.__clear_location_cache + @@location_cache.clear + end + + describe Time::Location do + describe ".load" do + it "loads Europe/Berlin" do + location = Location.load("Europe/Berlin") + + location.name.should eq "Europe/Berlin" + standard_time = location.lookup(Time.new(2017, 11, 22)) + standard_time.name.should eq "CET" + standard_time.offset.should eq 3600 + standard_time.dst?.should be_false + + summer_time = location.lookup(Time.new(2017, 10, 22)) + summer_time.name.should eq "CEST" + summer_time.offset.should eq 7200 + summer_time.dst?.should be_true + + location.utc?.should be_false + location.fixed?.should be_false + + with_env("TZ", nil) do + location.local?.should be_false + end + + with_env("TZ", "Europe/Berlin") do + location.local?.should be_true + end + + Location.load?("Europe/Berlin", Crystal::System::Time.zone_sources).should eq location + end + + it "invalid timezone identifier" do + expect_raises(InvalidLocationNameError, "Foobar/Baz") do + Location.load("Foobar/Baz") + end + + Location.load?("Foobar/Baz", Crystal::System::Time.zone_sources).should be_nil + end + + it "treats UTC as special case" do + Location.load("UTC").should eq Location::UTC + Location.load("").should eq Location::UTC + + # Etc/UTC could be pointing to anything + Location.load("Etc/UTC").should_not eq Location::UTC + end + + describe "validating name" do + it "absolute path" do + expect_raises(InvalidLocationNameError) do + Location.load("/America/New_York") + end + expect_raises(InvalidLocationNameError) do + Location.load("\\Zulu") + end + end + it "dot dot" do + expect_raises(InvalidLocationNameError) do + Location.load("../zoneinfo/America/New_York") + end + expect_raises(InvalidLocationNameError) do + Location.load("a..") + end + end + end + + context "with ZONEINFO" do + it "loads from custom directory" do + with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do + location = Location.load("Foo/Bar") + location.name.should eq "Foo/Bar" + end + end + + it "loads from custom zipfile" do + with_zoneinfo(ZONEINFO_ZIP) do + location = Location.load("Asia/Jerusalem") + location.not_nil!.name.should eq "Asia/Jerusalem" + end + end + + it "raises if not available" do + with_zoneinfo(ZONEINFO_ZIP) do + expect_raises(InvalidLocationNameError) do + Location.load("Foo/Bar") + end + Location.load?("Foo/Bar", Crystal::System::Time.zone_sources).should be_nil + end + end + + it "does not fall back to default sources" do + with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do + expect_raises(InvalidLocationNameError) do + Location.load("Europe/Berlin") + end + end + + with_zoneinfo("nonexising_zipfile.zip") do + expect_raises(InvalidLocationNameError) do + Location.load("Europe/Berlin") + end + end + end + + it "caches result" do + with_zoneinfo do + location = Location.load("Europe/Berlin") + Location.load("Europe/Berlin").should be location + end + end + + it "loads new data if file was changed" do + zoneinfo_path = File.join(__DIR__, "..", "data", "zoneinfo") + with_zoneinfo(zoneinfo_path) do + location1 = Location.load("Foo/Bar") + File.touch(File.join(zoneinfo_path, "Foo/Bar")) + location2 = Location.load("Foo/Bar") + + location1.should eq location2 + location1.should_not be location2 + end + end + + it "loads new data if ZIP file was changed" do + with_zoneinfo(ZONEINFO_ZIP) do + location1 = Location.load("Europe/Berlin") + File.touch(ZONEINFO_ZIP) + location2 = Location.load("Europe/Berlin") + + location1.should eq location2 + location1.should_not be location2 + end + end + end + end + + it "UTC" do + location = Location::UTC + location.name.should eq "UTC" + + location.utc?.should be_true + location.fixed?.should be_true + + # this could fail if no source for localtime is available + unless Location.local.utc? + location.local?.should be_false + end + + zone = location.lookup(Time.now) + zone.name.should eq "UTC" + zone.offset.should eq 0 + zone.dst?.should be_false + end + + it ".local" do + Location.local.should eq Location.load_local + + Location.local = Location::UTC + Location.local.should be Location::UTC + end + + it ".load_local" do + with_env("TZ", nil) do + Location.load_local.name.should eq "Local" + end + with_zoneinfo do + with_env("TZ", "Europe/Berlin") do + Location.load_local.name.should eq "Europe/Berlin" + end + end + with_env("TZ", "") do + Location.load_local.utc?.should be_true + end + end + + describe ".fixed" do + it "accepts a name" do + location = Location.fixed("Fixed", 1800) + location.name.should eq "Fixed" + location.zones.should eq [Zone.new("Fixed", 1800, false)] + location.transitions.size.should eq 0 + + location.utc?.should be_false + location.fixed?.should be_true + location.local?.should be_false + end + + it "positive" do + location = Location.fixed 8000 + location.name.should eq "+02:13" + location.zones.first.offset.should eq 8000 + end + + it "ngeative" do + location = Location.fixed -7539 + location.name.should eq "-02:05" + location.zones.first.offset.should eq -7539 + end + + it "raises if offset to large" do + expect_raises(InvalidTimezoneOffsetError, "86401") do + Location.fixed(86401) + end + expect_raises(InvalidTimezoneOffsetError, "-90000") do + Location.fixed(-90000) + end + end + end + + describe "#lookup" do + it "looks up" do + with_zoneinfo do + location = Location.load("Europe/Berlin") + zone, range = location.lookup_with_boundaries(Time.utc(2017, 11, 23, 22, 6, 12).epoch) + zone.should eq Zone.new("CET", 3600, false) + range.should eq({1509238800_i64, 1521939600_i64}) + end + end + + it "handles dst change" do + with_zoneinfo do + location = Location.load("Europe/Berlin") + time = Time.utc(2017, 10, 29, 1, 0, 0) + + summer = location.lookup(time - 1.second) + summer.name.should eq "CEST" + summer.offset.should eq 2 * SECONDS_PER_HOUR + summer.dst?.should be_true + + winter = location.lookup(time) + winter.name.should eq "CET" + winter.offset.should eq 1 * SECONDS_PER_HOUR + winter.dst?.should be_false + + last_ns = location.lookup(time - 1.nanosecond) + last_ns.name.should eq "CEST" + last_ns.offset.should eq 2 * SECONDS_PER_HOUR + last_ns.dst?.should be_true + end + end + + it "handles value after last transition" do + with_zoneinfo do + location = Location.load("America/Buenos_Aires") + zone = location.lookup(Time.utc(5000, 1, 1)) + zone.name.should eq "-03" + zone.offset.should eq -3 * 3600 + end + end + + # Test that we get the correct results for times before the first + # transition time. To do this we explicitly check early dates in a + # couple of specific timezones. + context "first zone" do + it "PST8PDT" do + with_zoneinfo do + location = Location.load("PST8PDT") + zone1 = location.lookup(-1633269601) + zone2 = location.lookup(-1633269601 + 1) + zone1.name.should eq "PST" + zone1.offset.should eq -8 * SECONDS_PER_HOUR + zone2.name.should eq "PDT" + zone2.offset.should eq -7 * SECONDS_PER_HOUR + end + end + + it "Pacific/Fakaofo" do + with_zoneinfo do + location = Location.load("Pacific/Fakaofo") + zone1 = location.lookup(1325242799) + zone2 = location.lookup(1325242799 + 1) + zone1.name.should eq "-11" + zone1.offset.should eq -11 * SECONDS_PER_HOUR + zone2.name.should eq "+13" + zone2.offset.should eq 13 * SECONDS_PER_HOUR + end + end + end + + it "caches last zone" do + with_zoneinfo do + location = Time::Location.load("Europe/Berlin") + + location.__cached_range.should eq({Int64::MIN, Int64::MIN}) + location.__cached_zone.should eq Zone.new("LMT", 3208, false) + + expected_zone = Zone.new("CET", 3600, false) + + location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq expected_zone + + location.__cached_range.should eq({1509238800_i64, 1521939600_i64}) + location.__cached_zone.should eq expected_zone + end + end + + it "reads from cache" do + with_zoneinfo do + location = Location.load("Europe/Berlin") + location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq Zone.new("CET", 3600, false) + cached_zone = Zone.new("MyZone", 1234, true) + location.__cached_zone = cached_zone + + location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq cached_zone + end + end + end + end +end diff --git a/spec/std/time/spec_helper.cr b/spec/std/time/spec_helper.cr new file mode 100644 index 000000000000..22b7f60cd5ef --- /dev/null +++ b/spec/std/time/spec_helper.cr @@ -0,0 +1,22 @@ +def with_env(name, value) + previous = ENV[name]? + begin + ENV[name] = value + + # Reset local time zone + Time::Location.local = Time::Location.load_local + yield + ensure + ENV[name] = previous + end +end + +ZONEINFO_ZIP = File.join(__DIR__, "..", "data", "zoneinfo.zip") + +def with_zoneinfo(path = ZONEINFO_ZIP) + with_env("ZONEINFO", path) do + Time::Location.__clear_location_cache + + yield + end +end diff --git a/spec/std/time/time_spec.cr b/spec/std/time/time_spec.cr index b53dc43765f2..83e95bbadff7 100644 --- a/spec/std/time/time_spec.cr +++ b/spec/std/time/time_spec.cr @@ -1,4 +1,5 @@ require "spec" +require "./spec_helper" def Time.expect_invalid expect_raises ArgumentError, "Invalid time" do @@ -6,12 +7,17 @@ def Time.expect_invalid end end +private def parse_time(format, string) + Time.parse(format, string, Time::Location::UTC) +end + describe Time do it "initialize" do t1 = Time.new 2002, 2, 25 t1.year.should eq(2002) t1.month.should eq(2) t1.day.should eq(25) + t1.local?.should be_true t2 = Time.new 2002, 2, 25, 15, 25, 13, nanosecond: 8 t2.year.should eq(2002) @@ -21,6 +27,7 @@ describe Time do t2.minute.should eq(25) t2.second.should eq(13) t2.nanosecond.should eq(8) + t2.local?.should be_true end it "initialize max" do @@ -51,6 +58,7 @@ describe Time do time = Time.epoch(seconds) time.should eq(Time.utc(2015, 8, 12, 18, 29, 15)) time.epoch.should eq(seconds) + time.utc?.should be_true end it "initialize with .epoch_ms" do @@ -58,6 +66,7 @@ describe Time do time = Time.epoch_ms(milliseconds) time.should eq(Time.utc(2015, 8, 12, 18, 29, 15)) time.epoch_ms.should eq(milliseconds) + time.utc?.should be_true end it "returns always increasing monotonic clock" do @@ -79,7 +88,7 @@ describe Time do end it "add" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) span = Time::Span.new 3, 54, 1 t2 = t1 + span @@ -92,10 +101,12 @@ describe Time do t1.hour.should eq(15) t1.minute.should eq(25) t1.second.should eq(13) + + t2.location.should eq t1.location end it "add out of range 1" do - t1 = Time.new(9980, 2, 25, 15, 25, 13) + t1 = Time.utc(9980, 2, 25, 15, 25, 13) expect_raises ArgumentError do t1 + Time::Span.new(nanoseconds: Int64::MAX) @@ -103,7 +114,7 @@ describe Time do end it "add out of range 2" do - t1 = Time.new(1, 2, 25, 15, 25, 13) + t1 = Time.utc(1, 2, 25, 15, 25, 13) expect_raises ArgumentError do t1 + Time::Span.new(nanoseconds: Int64::MIN) @@ -111,7 +122,7 @@ describe Time do end it "add days" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) t1 = t1 + 3.days t1.day.should eq(28) @@ -132,53 +143,63 @@ describe Time do t1.second.should eq(13) end + pending "add days over dst" do + with_zoneinfo do + location = Time::Location.load("Europe/Berlin") + reference = Time.new(2017, 10, 28, 13, 37, location: location) + next_day = Time.new(2017, 10, 29, 13, 37, location: location) + + (reference + 1.day).should eq next_day + end + end + it "add days out of range 1" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) expect_raises ArgumentError do t1 + 10000000.days end end it "add days out of range 2" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) expect_raises ArgumentError do t1 - 10000000.days end end it "add months" do - t = Time.new 2014, 10, 30, 21, 18, 13 + t = Time.utc 2014, 10, 30, 21, 18, 13 t2 = t + 1.month - t2.to_s.should eq("2014-11-30 21:18:13") + t2.to_s.should eq("2014-11-30 21:18:13 UTC") t2 = t + 1.months - t2.to_s.should eq("2014-11-30 21:18:13") + t2.to_s.should eq("2014-11-30 21:18:13 UTC") - t = Time.new 2014, 10, 31, 21, 18, 13 + t = Time.utc 2014, 10, 31, 21, 18, 13 t2 = t + 1.month - t2.to_s.should eq("2014-11-30 21:18:13") + t2.to_s.should eq("2014-11-30 21:18:13 UTC") - t = Time.new 2014, 10, 31, 21, 18, 13 + t = Time.utc 2014, 10, 31, 21, 18, 13 t2 = t - 1.month - t2.to_s.should eq("2014-09-30 21:18:13") + t2.to_s.should eq("2014-09-30 21:18:13 UTC") - t = Time.new 2014, 10, 31, 21, 18, 13 + t = Time.utc 2014, 10, 31, 21, 18, 13 t2 = t + 6.month - t2.to_s.should eq("2015-04-30 21:18:13") + t2.to_s.should eq("2015-04-30 21:18:13 UTC") end it "add years" do - t = Time.new 2014, 10, 30, 21, 18, 13 + t = Time.utc 2014, 10, 30, 21, 18, 13 t2 = t + 1.year - t2.to_s.should eq("2015-10-30 21:18:13") + t2.to_s.should eq("2015-10-30 21:18:13 UTC") - t = Time.new 2014, 10, 30, 21, 18, 13 + t = Time.utc 2014, 10, 30, 21, 18, 13 t2 = t - 2.years - t2.to_s.should eq("2012-10-30 21:18:13") + t2.to_s.should eq("2012-10-30 21:18:13 UTC") end it "add hours" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) t1 = t1 + 10.hours t1.day.should eq(26) @@ -200,7 +221,7 @@ describe Time do end it "add milliseconds" do - t1 = Time.new(2002, 2, 25, 15, 25, 13) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) t1 = t1 + 1e10.milliseconds t1.day.should eq(21) @@ -257,126 +278,183 @@ describe Time do t1.epoch_f.should be_close(t1.to_utc.epoch_f, 1e-01) end - it "to_s" do - t = Time.new 2014, 10, 30, 21, 18, 13 - t.to_s.should eq("2014-10-30 21:18:13") + it "current time is similar in differnt locations" do + (Time.now - Time.utc_now).should be_close(0.seconds, 1.second) + (Time.now - Time.now(Time::Location.fixed(1234))).should be_close(0.seconds, 1.second) + end + + describe "to_s" do + it "prints local time" do + with_env("TZ", nil) do + t = Time.new 2014, 10, 30, 21, 18, 13 + t.to_s.should eq("2014-10-30 21:18:13 #{t.to_s("%:z")}") + + t = Time.new 2014, 1, 30, 21, 18, 13 + t.to_s.should eq("2014-01-30 21:18:13 #{t.to_s("%:z")}") - t = Time.new 2014, 1, 30, 21, 18, 13 - t.to_s.should eq("2014-01-30 21:18:13") + t = Time.new 2014, 10, 1, 21, 18, 13 + t.to_s.should eq("2014-10-01 21:18:13 #{t.to_s("%:z")}") - t = Time.new 2014, 10, 1, 21, 18, 13 - t.to_s.should eq("2014-10-01 21:18:13") + t = Time.new 2014, 10, 30, 1, 18, 13 + t.to_s.should eq("2014-10-30 01:18:13 #{t.to_s("%:z")}") - t = Time.new 2014, 10, 30, 1, 18, 13 - t.to_s.should eq("2014-10-30 01:18:13") + t = Time.new 2014, 10, 30, 21, 1, 13 + t.to_s.should eq("2014-10-30 21:01:13 #{t.to_s("%:z")}") - t = Time.new 2014, 10, 30, 21, 1, 13 - t.to_s.should eq("2014-10-30 21:01:13") + t = Time.new 2014, 10, 30, 21, 18, 1 + t.to_s.should eq("2014-10-30 21:18:01 #{t.to_s("%:z")}") + end + end + + it "prints without nanoseconds" do + with_env("TZ", nil) do + t = Time.new 2014, 10, 30, 21, 18, 13, nanosecond: 12345 + t.to_s.should eq("2014-10-30 21:18:13 #{t.to_s("%:z")}") + end + end + + it "prints UTC" do + t = Time.utc 2014, 10, 30, 21, 18, 13 + t.to_s.should eq("2014-10-30 21:18:13 UTC") + end + + it "prints zone" do + with_zoneinfo do + location = Time::Location.load("Europe/Berlin") + t = Time.new 2014, 10, 30, 21, 18, 13, location: location + t.to_s.should eq("2014-10-30 21:18:13 +01:00 Europe/Berlin") - t = Time.new 2014, 10, 30, 21, 18, 1 - t.to_s.should eq("2014-10-30 21:18:01") + t = Time.new 2014, 10, 10, 21, 18, 13, location: location + t.to_s.should eq("2014-10-10 21:18:13 +02:00 Europe/Berlin") + end + end + + it "prints offset" do + t = Time.new 2014, 10, 30, 21, 18, 13, location: Time::Location.fixed(-9000) + t.to_s.should eq("2014-10-30 21:18:13 -02:30") + end end it "formats" do - t = Time.new 2014, 1, 2, 3, 4, 5, nanosecond: 6_000_000 - t2 = Time.new 2014, 1, 2, 15, 4, 5, nanosecond: 6_000_000 - t3 = Time.new 2014, 1, 2, 12, 4, 5, nanosecond: 6_000_000 - - t.to_s("%Y").should eq("2014") - Time.new(1, 1, 2, 3, 4, 5, nanosecond: 6).to_s("%Y").should eq("0001") - - t.to_s("%C").should eq("20") - t.to_s("%y").should eq("14") - t.to_s("%m").should eq("01") - t.to_s("%_m").should eq(" 1") - t.to_s("%_%_m2").should eq("%_ 12") - t.to_s("%-m").should eq("1") - t.to_s("%-%-m2").should eq("%-12") - t.to_s("%B").should eq("January") - t.to_s("%^B").should eq("JANUARY") - t.to_s("%^%^B2").should eq("%^JANUARY2") - t.to_s("%b").should eq("Jan") - t.to_s("%^b").should eq("JAN") - t.to_s("%h").should eq("Jan") - t.to_s("%^h").should eq("JAN") - t.to_s("%d").should eq("02") - t.to_s("%-d").should eq("2") - t.to_s("%e").should eq(" 2") - t.to_s("%j").should eq("002") - t.to_s("%H").should eq("03") - - t.to_s("%k").should eq(" 3") - t2.to_s("%k").should eq("15") - - t.to_s("%I").should eq("03") - t2.to_s("%I").should eq("03") - t3.to_s("%I").should eq("12") - - t.to_s("%l").should eq(" 3") - t2.to_s("%l").should eq(" 3") - t3.to_s("%l").should eq("12") - - # Note: we purposely match %p to am/pm and %P to AM/PM (makes more sense) - t.to_s("%p").should eq("am") - t2.to_s("%p").should eq("pm") - - t.to_s("%P").should eq("AM") - t2.to_s("%P").should eq("PM") - - t.to_s("%M").to_s.should eq("04") - t.to_s("%S").to_s.should eq("05") - t.to_s("%L").to_s.should eq("006") - t.to_s("%N").to_s.should eq("006000000") - t.to_s("%3N").to_s.should eq("006") - t.to_s("%6N").to_s.should eq("006000") - t.to_s("%9N").to_s.should eq("006000000") - - Time.utc_now.to_s("%z").should eq("+0000") - Time.utc_now.to_s("%:z").should eq("+00:00") - Time.utc_now.to_s("%::z").should eq("+00:00:00") - - # TODO %Z - - t.to_s("%A").to_s.should eq("Thursday") - t.to_s("%^A").to_s.should eq("THURSDAY") - t.to_s("%a").to_s.should eq("Thu") - t.to_s("%^a").to_s.should eq("THU") - t.to_s("%u").to_s.should eq("4") - t.to_s("%w").to_s.should eq("4") - - t3 = Time.new 2014, 1, 5 # A Sunday - t3.to_s("%u").to_s.should eq("7") - t3.to_s("%w").to_s.should eq("0") - - # TODO %G - # TODO %g - # TODO %V - # TODO %U - # TODO %W - # TODO %s - # TODO %n - # TODO %t - # TODO %% - - t.to_s("%%").should eq("%") - t.to_s("%c").should eq(t.to_s("%a %b %e %T %Y")) - t.to_s("%D").should eq(t.to_s("%m/%d/%y")) - t.to_s("%F").should eq(t.to_s("%Y-%m-%d")) - # TODO %v - t.to_s("%x").should eq(t.to_s("%D")) - t.to_s("%X").should eq(t.to_s("%T")) - t.to_s("%r").should eq(t.to_s("%I:%M:%S %P")) - t.to_s("%R").should eq(t.to_s("%H:%M")) - t.to_s("%T").should eq(t.to_s("%H:%M:%S")) - - t.to_s("%Y-%m-hello").should eq("2014-01-hello") - - t = Time.utc 2014, 1, 2, 3, 4, 5, nanosecond: 6 - t.to_s("%s").should eq("1388631845") + with_zoneinfo do + t = Time.new 2014, 1, 2, 3, 4, 5, nanosecond: 6_000_000 + t2 = Time.new 2014, 1, 2, 15, 4, 5, nanosecond: 6_000_000 + t3 = Time.new 2014, 1, 2, 12, 4, 5, nanosecond: 6_000_000 + + t.to_s("%Y").should eq("2014") + Time.new(1, 1, 2, 3, 4, 5, nanosecond: 6).to_s("%Y").should eq("0001") + + t.to_s("%C").should eq("20") + t.to_s("%y").should eq("14") + t.to_s("%m").should eq("01") + t.to_s("%_m").should eq(" 1") + t.to_s("%_%_m2").should eq("%_ 12") + t.to_s("%-m").should eq("1") + t.to_s("%-%-m2").should eq("%-12") + t.to_s("%B").should eq("January") + t.to_s("%^B").should eq("JANUARY") + t.to_s("%^%^B2").should eq("%^JANUARY2") + t.to_s("%b").should eq("Jan") + t.to_s("%^b").should eq("JAN") + t.to_s("%h").should eq("Jan") + t.to_s("%^h").should eq("JAN") + t.to_s("%d").should eq("02") + t.to_s("%-d").should eq("2") + t.to_s("%e").should eq(" 2") + t.to_s("%j").should eq("002") + t.to_s("%H").should eq("03") + + t.to_s("%k").should eq(" 3") + t2.to_s("%k").should eq("15") + + t.to_s("%I").should eq("03") + t2.to_s("%I").should eq("03") + t3.to_s("%I").should eq("12") + + t.to_s("%l").should eq(" 3") + t2.to_s("%l").should eq(" 3") + t3.to_s("%l").should eq("12") + + # Note: we purposely match %p to am/pm and %P to AM/PM (makes more sense) + t.to_s("%p").should eq("am") + t2.to_s("%p").should eq("pm") + + t.to_s("%P").should eq("AM") + t2.to_s("%P").should eq("PM") + + t.to_s("%M").to_s.should eq("04") + t.to_s("%S").to_s.should eq("05") + t.to_s("%L").to_s.should eq("006") + t.to_s("%N").to_s.should eq("006000000") + t.to_s("%3N").to_s.should eq("006") + t.to_s("%6N").to_s.should eq("006000") + t.to_s("%9N").to_s.should eq("006000000") + + Time.utc_now.to_s("%z").should eq("+0000") + Time.utc_now.to_s("%:z").should eq("+00:00") + Time.utc_now.to_s("%::z").should eq("+00:00:00") + + zoned = Time.new(2017, 11, 24, 13, 5, 6, location: Time::Location.load("Europe/Berlin")) + zoned.to_s("%z").should eq("+0100") + zoned.to_s("%:z").should eq("+01:00") + zoned.to_s("%::z").should eq("+01:00:00") + + zoned = Time.new(2017, 11, 24, 13, 5, 6, location: Time::Location.load("America/Buenos_Aires")) + zoned.to_s("%z").should eq("-0300") + zoned.to_s("%:z").should eq("-03:00") + zoned.to_s("%::z").should eq("-03:00:00") + + offset = Time.new(2017, 11, 24, 13, 5, 6, location: Time::Location.fixed(9000)) + offset.to_s("%z").should eq("+0230") + offset.to_s("%:z").should eq("+02:30") + offset.to_s("%::z").should eq("+02:30:00") + + offset = Time.new(2017, 11, 24, 13, 5, 6, location: Time::Location.fixed(9001)) + offset.to_s("%z").should eq("+0230") + offset.to_s("%:z").should eq("+02:30") + offset.to_s("%::z").should eq("+02:30:01") + + t.to_s("%A").to_s.should eq("Thursday") + t.to_s("%^A").to_s.should eq("THURSDAY") + t.to_s("%a").to_s.should eq("Thu") + t.to_s("%^a").to_s.should eq("THU") + t.to_s("%u").to_s.should eq("4") + t.to_s("%w").to_s.should eq("4") + + t3 = Time.new 2014, 1, 5 # A Sunday + t3.to_s("%u").to_s.should eq("7") + t3.to_s("%w").to_s.should eq("0") + + # TODO %G + # TODO %g + # TODO %V + # TODO %U + # TODO %W + # TODO %s + # TODO %n + # TODO %t + # TODO %% + + t.to_s("%%").should eq("%") + t.to_s("%c").should eq(t.to_s("%a %b %e %T %Y")) + t.to_s("%D").should eq(t.to_s("%m/%d/%y")) + t.to_s("%F").should eq(t.to_s("%Y-%m-%d")) + # TODO %v + t.to_s("%x").should eq(t.to_s("%D")) + t.to_s("%X").should eq(t.to_s("%T")) + t.to_s("%r").should eq(t.to_s("%I:%M:%S %P")) + t.to_s("%R").should eq(t.to_s("%H:%M")) + t.to_s("%T").should eq(t.to_s("%H:%M:%S")) + + t.to_s("%Y-%m-hello").should eq("2014-01-hello") + + t = Time.utc 2014, 1, 2, 3, 4, 5, nanosecond: 6 + t.to_s("%s").should eq("1388631845") + end end it "parses empty" do - t = Time.parse("", "") + t = Time.parse("", "", Time::Location.local) t.year.should eq(1) t.month.should eq(1) t.day.should eq(1) @@ -384,60 +462,115 @@ describe Time do t.minute.should eq(0) t.second.should eq(0) t.millisecond.should eq(0) + t.local?.should be_true end - it { Time.parse("2014", "%Y").year.should eq(2014) } - it { Time.parse("19", "%C").year.should eq(1900) } - it { Time.parse("14", "%y").year.should eq(2014) } - it { Time.parse("09", "%m").month.should eq(9) } - it { Time.parse(" 9", "%_m").month.should eq(9) } - it { Time.parse("9", "%-m").month.should eq(9) } - it { Time.parse("February", "%B").month.should eq(2) } - it { Time.parse("March", "%B").month.should eq(3) } - it { Time.parse("MaRcH", "%B").month.should eq(3) } - it { Time.parse("MaR", "%B").month.should eq(3) } - it { Time.parse("MARCH", "%^B").month.should eq(3) } - it { Time.parse("Mar", "%b").month.should eq(3) } - it { Time.parse("Mar", "%^b").month.should eq(3) } - it { Time.parse("MAR", "%^b").month.should eq(3) } - it { Time.parse("MAR", "%h").month.should eq(3) } - it { Time.parse("MAR", "%^h").month.should eq(3) } - it { Time.parse("2", "%d").day.should eq(2) } - it { Time.parse("02", "%d").day.should eq(2) } - it { Time.parse("02", "%-d").day.should eq(2) } - it { Time.parse(" 2", "%e").day.should eq(2) } - it { Time.parse("9", "%H").hour.should eq(9) } - it { Time.parse(" 9", "%k").hour.should eq(9) } - it { Time.parse("09", "%I").hour.should eq(9) } - it { Time.parse(" 9", "%l").hour.should eq(9) } - it { Time.parse("9pm", "%l%p").hour.should eq(21) } - it { Time.parse("9PM", "%l%P").hour.should eq(21) } - it { Time.parse("09", "%M").minute.should eq(9) } - it { Time.parse("09", "%S").second.should eq(9) } - it { Time.parse("123", "%L").millisecond.should eq(123) } - it { Time.parse("1", "%L").millisecond.should eq(100) } - it { Time.parse("000000321", "%N").nanosecond.should eq(321) } - it { Time.parse("321", "%N").nanosecond.should eq(321000000) } - it { Time.parse("321999", "%3N").nanosecond.should eq(321000000) } - it { Time.parse("321", "%6N").nanosecond.should eq(321000000) } - it { Time.parse("000321999", "%6N").nanosecond.should eq(321000) } - it { Time.parse("000000321999", "%9N").nanosecond.should eq(321) } - it { Time.parse("321", "%9N").nanosecond.should eq(321000000) } - it { Time.parse("3214569879999", "%N").nanosecond.should eq(321456987) } - it { Time.parse("Fri Oct 31 23:00:24 2014", "%c").to_s.should eq("2014-10-31 23:00:24") } - it { Time.parse("10/31/14", "%D").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("10/31/69", "%D").to_s.should eq("1969-10-31 00:00:00") } - it { Time.parse("2014-10-31", "%F").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("2014-10-31", "%F").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("10/31/14", "%x").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("10:11:12", "%X").to_s.should eq("0001-01-01 10:11:12") } - it { Time.parse("11:14:01 PM", "%r").to_s.should eq("0001-01-01 23:14:01") } - it { Time.parse("11:14", "%R").to_s.should eq("0001-01-01 11:14:00") } - it { Time.parse("11:12:13", "%T").to_s.should eq("0001-01-01 11:12:13") } - it { Time.parse("This was done on Friday, October 31, 2014", "This was done on %A, %B %d, %Y").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("今は Friday, October 31, 2014", "今は %A, %B %d, %Y").to_s.should eq("2014-10-31 00:00:00") } - it { Time.parse("epoch: 1459864667", "epoch: %s").epoch.should eq(1459864667) } - it { Time.parse("epoch: -1459864667", "epoch: %s").epoch.should eq(-1459864667) } + it "parse fails without time zone" do + expect_raises(Time::Format::Error, "no default location provided") do + Time.parse("2017-12-01 20:15:13", "%F %T") + end + Time.parse("2017-12-01 20:15:13", "%F %T", Time::Location.local).to_s("%F %T").should eq "2017-12-01 20:15:13" + Time.parse("2017-12-01 20:15:13 +01:00", "%F %T %:z").to_s("%F %T %:z").should eq "2017-12-01 20:15:13 +01:00" + end + + it "parses" do + parse_time("2014", "%Y").year.should eq(2014) + parse_time("19", "%C").year.should eq(1900) + parse_time("14", "%y").year.should eq(2014) + parse_time("09", "%m").month.should eq(9) + parse_time(" 9", "%_m").month.should eq(9) + parse_time("9", "%-m").month.should eq(9) + parse_time("February", "%B").month.should eq(2) + parse_time("March", "%B").month.should eq(3) + parse_time("MaRcH", "%B").month.should eq(3) + parse_time("MaR", "%B").month.should eq(3) + parse_time("MARCH", "%^B").month.should eq(3) + parse_time("Mar", "%b").month.should eq(3) + parse_time("Mar", "%^b").month.should eq(3) + parse_time("MAR", "%^b").month.should eq(3) + parse_time("MAR", "%h").month.should eq(3) + parse_time("MAR", "%^h").month.should eq(3) + parse_time("2", "%d").day.should eq(2) + parse_time("02", "%d").day.should eq(2) + parse_time("02", "%-d").day.should eq(2) + parse_time(" 2", "%e").day.should eq(2) + parse_time("9", "%H").hour.should eq(9) + parse_time(" 9", "%k").hour.should eq(9) + parse_time("09", "%I").hour.should eq(9) + parse_time(" 9", "%l").hour.should eq(9) + parse_time("9pm", "%l%p").hour.should eq(21) + parse_time("9PM", "%l%P").hour.should eq(21) + parse_time("09", "%M").minute.should eq(9) + parse_time("09", "%S").second.should eq(9) + parse_time("123", "%L").millisecond.should eq(123) + parse_time("1", "%L").millisecond.should eq(100) + parse_time("000000321", "%N").nanosecond.should eq(321) + parse_time("321", "%N").nanosecond.should eq(321000000) + parse_time("321999", "%3N").nanosecond.should eq(321000000) + parse_time("321", "%6N").nanosecond.should eq(321000000) + parse_time("000321999", "%6N").nanosecond.should eq(321000) + parse_time("000000321999", "%9N").nanosecond.should eq(321) + parse_time("321", "%9N").nanosecond.should eq(321000000) + parse_time("3214569879999", "%N").nanosecond.should eq(321456987) + parse_time("Fri Oct 31 23:00:24 2014", "%c").to_s.should eq("2014-10-31 23:00:24 UTC") + parse_time("10/31/14", "%D").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("10/31/69", "%D").to_s.should eq("1969-10-31 00:00:00 UTC") + parse_time("2014-10-31", "%F").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("2014-10-31", "%F").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("10/31/14", "%x").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("10:11:12", "%X").to_s.should eq("0001-01-01 10:11:12 UTC") + parse_time("11:14:01 PM", "%r").to_s.should eq("0001-01-01 23:14:01 UTC") + parse_time("11:14", "%R").to_s.should eq("0001-01-01 11:14:00 UTC") + parse_time("11:12:13", "%T").to_s.should eq("0001-01-01 11:12:13 UTC") + parse_time("This was done on Friday, October 31, 2014", "This was done on %A, %B %d, %Y").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("今は Friday, October 31, 2014", "今は %A, %B %d, %Y").to_s.should eq("2014-10-31 00:00:00 UTC") + parse_time("epoch: 1459864667", "epoch: %s").epoch.should eq(1459864667) + parse_time("epoch: -1459864667", "epoch: %s").epoch.should eq(-1459864667) + end + + it "parses timezone" do + patterns = {"%z", "%:z", "%::z"} + + {"+0000", "+00:00", "+00:00:00"}.zip(patterns) do |string, pattern| + time = Time.parse(string, pattern) + time.offset.should eq 0 + time.utc?.should be_false + time.location.fixed?.should be_true + end + + {"-0000", "-00:00", "-00:00:00"}.zip(patterns) do |string, pattern| + time = Time.parse(string, pattern) + time.offset.should eq 0 + time.utc?.should be_false + time.location.fixed?.should be_true + end + + {"-0200", "-02:00", "-02:00:00"}.zip(patterns) do |string, pattern| + time = Time.parse(string, pattern) + time.offset.should eq -2 * 3600 + time.utc?.should be_false + time.location.fixed?.should be_true + end + + {"Z", "Z", "Z"}.zip(patterns) do |string, pattern| + time = Time.parse(string, pattern) + time.offset.should eq 0 + time.utc?.should be_true + time.location.fixed?.should be_true + end + + {"UTC", "UTC", "UTC"}.zip(patterns) do |string, pattern| + time = Time.parse(string, pattern) + time.offset.should eq 0 + time.utc?.should be_true + time.location.fixed?.should be_true + end + + time = Time.parse("+04:12:39", "%::z") + time.offset.should eq 4 * 3600 + 12 * 60 + 39 + time.utc?.should be_false + time.location.fixed?.should be_true + end # TODO %Z # TODO %G @@ -465,25 +598,33 @@ describe Time do it do time = Time.parse("2014-10-31 10:11:12 -06:00 hi", "%F %T %z hi") - time.local?.should be_true + time.utc?.should be_false + time.location.fixed?.should be_true + time.offset.should eq -6 * 3600 time.to_utc.to_s.should eq("2014-10-31 16:11:12 UTC") end it do time = Time.parse("2014-10-31 10:11:12 +05:00 hi", "%F %T %z hi") - time.local?.should be_true + time.utc?.should be_false + time.location.fixed?.should be_true + time.offset.should eq 5 * 3600 time.to_utc.to_s.should eq("2014-10-31 05:11:12 UTC") end it do time = Time.parse("2014-10-31 10:11:12 -06:00:00 hi", "%F %T %z hi") - time.local?.should be_true + time.utc?.should be_false + time.location.fixed?.should be_true + time.offset.should eq -6 * 3600 time.to_utc.to_s.should eq("2014-10-31 16:11:12 UTC") end it do time = Time.parse("2014-10-31 10:11:12 -060000 hi", "%F %T %z hi") - time.local?.should be_true + time.utc?.should be_false + time.location.fixed?.should be_true + time.offset.should eq -6 * 3600 time.to_utc.to_s.should eq("2014-10-31 16:11:12 UTC") end @@ -528,20 +669,20 @@ describe Time do end it "parses discarding additional decimals" do - time = Time.parse("2016-09-09T17:03:28.456789123999+01:00", "%FT%T.%3N%z").to_utc + time = Time.parse("2016-09-09T17:03:28.456789123999", "%FT%T.%3N", Time::Location::UTC) time.nanosecond.should eq(456000000) - time = Time.parse("2016-09-09T17:03:28.456789123999+01:00", "%FT%T.%6N%z").to_utc + time = Time.parse("2016-09-09T17:03:28.456789123999", "%FT%T.%6N", Time::Location::UTC) time.nanosecond.should eq(456789000) - time = Time.parse("2016-09-09T17:03:28.456789123999+01:00", "%FT%T.%9N%z").to_utc + time = Time.parse("2016-09-09T17:03:28.456789123990", "%FT%T.%9N", Time::Location::UTC) time.nanosecond.should eq(456789123) - time = Time.parse("2016-09-09T17:03:28.456789123999999+01:00", "%FT%T.%N%z").to_utc - time.to_s.should eq("2016-09-09 16:03:28 UTC") + time = Time.parse("2016-09-09T17:03:28.456789123999999+01:00", "%FT%T.%N%z") + time.to_s.should eq("2016-09-09 17:03:28 +01:00") time.nanosecond.should eq(456789123) - time = Time.parse("4567892016-09-09T17:03:28+01:00", "%6N%FT%T%z").to_utc + time = Time.parse("4567892016-09-09T17:03:28", "%6N%FT%T", Time::Location::UTC) time.year.should eq(2016) time.nanosecond.should eq(456789000) end @@ -558,103 +699,112 @@ describe Time do end it "parses the correct amount of digits (#853)" do - time = Time.parse("20150624", "%Y%m%d") + time = Time.parse("20150624", "%Y%m%d", Time::Location::UTC) time.year.should eq(2015) time.month.should eq(6) time.day.should eq(24) end it "parses month blank padded" do - time = Time.parse("2015 624", "%Y%_m%d") + time = Time.parse("2015 624", "%Y%_m%d", Time::Location::UTC) time.year.should eq(2015) time.month.should eq(6) time.day.should eq(24) end it "parses day of month blank padded" do - time = Time.parse("201506 4", "%Y%m%e") + time = Time.parse("201506 4", "%Y%m%e", Time::Location::UTC) time.year.should eq(2015) time.month.should eq(6) time.day.should eq(4) end it "parses hour 24 blank padded" do - time = Time.parse(" 31112", "%k%M%S") + time = Time.parse(" 31112", "%k%M%S", Time::Location::UTC) time.hour.should eq(3) time.minute.should eq(11) time.second.should eq(12) end it "parses hour 12 blank padded" do - time = Time.parse(" 31112", "%l%M%S") + time = Time.parse(" 31112", "%l%M%S", Time::Location::UTC) time.hour.should eq(3) time.minute.should eq(11) time.second.should eq(12) end - it "can parse in UTC" do - time = Time.parse("2014-10-31 11:12:13", "%F %T", Time::Kind::Utc) - time.utc?.should be_true + it "can parse in location" do + with_zoneinfo do + time = Time.parse("2014-10-31 11:12:13", "%F %T", Time::Location::UTC) + time.utc?.should be_true + + location = Time::Location.load("Europe/Berlin") + time = Time.parse("2016-11-24 14:32:02", "%F %T", location) + time.location.should eq location + + time = Time.parse("2016-11-24 14:32:02 +01:00", "%F %T %:z", location) + time.location.should eq Time::Location.fixed(3600) + end end it "at" do t1 = Time.new 2014, 11, 25, 10, 11, 12, nanosecond: 13 t2 = Time.new 2014, 6, 25, 10, 11, 12, nanosecond: 13 - t1.at_beginning_of_year.to_s.should eq("2014-01-01 00:00:00") + t1.at_beginning_of_year.to_s("%F %T").should eq("2014-01-01 00:00:00") 1.upto(3) do |i| - Time.new(2014, i, 10).at_beginning_of_quarter.to_s.should eq("2014-01-01 00:00:00") - Time.new(2014, i, 10).at_end_of_quarter.to_s.should eq("2014-03-31 23:59:59") + Time.new(2014, i, 10).at_beginning_of_quarter.to_s("%F %T").should eq("2014-01-01 00:00:00") + Time.new(2014, i, 10).at_end_of_quarter.to_s("%F %T").should eq("2014-03-31 23:59:59") end 4.upto(6) do |i| - Time.new(2014, i, 10).at_beginning_of_quarter.to_s.should eq("2014-04-01 00:00:00") - Time.new(2014, i, 10).at_end_of_quarter.to_s.should eq("2014-06-30 23:59:59") + Time.new(2014, i, 10).at_beginning_of_quarter.to_s("%F %T").should eq("2014-04-01 00:00:00") + Time.new(2014, i, 10).at_end_of_quarter.to_s("%F %T").should eq("2014-06-30 23:59:59") end 7.upto(9) do |i| - Time.new(2014, i, 10).at_beginning_of_quarter.to_s.should eq("2014-07-01 00:00:00") - Time.new(2014, i, 10).at_end_of_quarter.to_s.should eq("2014-09-30 23:59:59") + Time.new(2014, i, 10).at_beginning_of_quarter.to_s("%F %T").should eq("2014-07-01 00:00:00") + Time.new(2014, i, 10).at_end_of_quarter.to_s("%F %T").should eq("2014-09-30 23:59:59") end 10.upto(12) do |i| - Time.new(2014, i, 10).at_beginning_of_quarter.to_s.should eq("2014-10-01 00:00:00") - Time.new(2014, i, 10).at_end_of_quarter.to_s.should eq("2014-12-31 23:59:59") + Time.new(2014, i, 10).at_beginning_of_quarter.to_s("%F %T").should eq("2014-10-01 00:00:00") + Time.new(2014, i, 10).at_end_of_quarter.to_s("%F %T").should eq("2014-12-31 23:59:59") end - t1.at_beginning_of_quarter.to_s.should eq("2014-10-01 00:00:00") - t1.at_beginning_of_month.to_s.should eq("2014-11-01 00:00:00") + t1.at_beginning_of_quarter.to_s("%F %T").should eq("2014-10-01 00:00:00") + t1.at_beginning_of_month.to_s("%F %T").should eq("2014-11-01 00:00:00") 3.upto(9) do |i| - Time.new(2014, 11, i).at_beginning_of_week.to_s.should eq("2014-11-03 00:00:00") + Time.new(2014, 11, i).at_beginning_of_week.to_s("%F %T").should eq("2014-11-03 00:00:00") end - t1.at_beginning_of_day.to_s.should eq("2014-11-25 00:00:00") - t1.at_beginning_of_hour.to_s.should eq("2014-11-25 10:00:00") - t1.at_beginning_of_minute.to_s.should eq("2014-11-25 10:11:00") + t1.at_beginning_of_day.to_s("%F %T").should eq("2014-11-25 00:00:00") + t1.at_beginning_of_hour.to_s("%F %T").should eq("2014-11-25 10:00:00") + t1.at_beginning_of_minute.to_s("%F %T").should eq("2014-11-25 10:11:00") - t1.at_end_of_year.to_s.should eq("2014-12-31 23:59:59") + t1.at_end_of_year.to_s("%F %T").should eq("2014-12-31 23:59:59") - t1.at_end_of_quarter.to_s.should eq("2014-12-31 23:59:59") - t2.at_end_of_quarter.to_s.should eq("2014-06-30 23:59:59") + t1.at_end_of_quarter.to_s("%F %T").should eq("2014-12-31 23:59:59") + t2.at_end_of_quarter.to_s("%F %T").should eq("2014-06-30 23:59:59") - t1.at_end_of_month.to_s.should eq("2014-11-30 23:59:59") - t1.at_end_of_week.to_s.should eq("2014-11-30 23:59:59") + t1.at_end_of_month.to_s("%F %T").should eq("2014-11-30 23:59:59") + t1.at_end_of_week.to_s("%F %T").should eq("2014-11-30 23:59:59") - Time.new(2014, 11, 2).at_end_of_week.to_s.should eq("2014-11-02 23:59:59") + Time.new(2014, 11, 2).at_end_of_week.to_s("%F %T").should eq("2014-11-02 23:59:59") 3.upto(9) do |i| - Time.new(2014, 11, i).at_end_of_week.to_s.should eq("2014-11-09 23:59:59") + Time.new(2014, 11, i).at_end_of_week.to_s("%F %T").should eq("2014-11-09 23:59:59") end - t1.at_end_of_day.to_s.should eq("2014-11-25 23:59:59") - t1.at_end_of_hour.to_s.should eq("2014-11-25 10:59:59") - t1.at_end_of_minute.to_s.should eq("2014-11-25 10:11:59") + t1.at_end_of_day.to_s("%F %T").should eq("2014-11-25 23:59:59") + t1.at_end_of_hour.to_s("%F %T").should eq("2014-11-25 10:59:59") + t1.at_end_of_minute.to_s("%F %T").should eq("2014-11-25 10:11:59") - t1.at_midday.to_s.should eq("2014-11-25 12:00:00") + t1.at_midday.to_s("%F %T").should eq("2014-11-25 12:00:00") - t1.at_beginning_of_semester.to_s.should eq("2014-07-01 00:00:00") - t2.at_beginning_of_semester.to_s.should eq("2014-01-01 00:00:00") + t1.at_beginning_of_semester.to_s("%F %T").should eq("2014-07-01 00:00:00") + t2.at_beginning_of_semester.to_s("%F %T").should eq("2014-01-01 00:00:00") - t1.at_end_of_semester.to_s.should eq("2014-12-31 23:59:59") - t2.at_end_of_semester.to_s.should eq("2014-06-30 23:59:59") + t1.at_end_of_semester.to_s("%F %T").should eq("2014-12-31 23:59:59") + t2.at_end_of_semester.to_s("%F %T").should eq("2014-06-30 23:59:59") end it "does time span units" do @@ -671,11 +821,14 @@ describe Time do 2.weeks.should eq(14.days) end - it "preserves kind when adding" do + it "preserves location when adding" do time = Time.utc_now time.utc?.should be_true (time + 5.minutes).utc?.should be_true + + time = Time.now + (time + 5.minutes).location.should eq time.location end it "asks for day name" do @@ -691,27 +844,11 @@ describe Time do end end - it "compares different kinds" do + it "compares different locations" do time = Time.now (time.to_utc <=> time).should eq(0) end - it %(changes timezone with ENV["TZ"]) do - old_tz = ENV["TZ"]? - - begin - ENV["TZ"] = "America/New_York" - offset1 = Time.local_offset_in_minutes - - ENV["TZ"] = "Europe/Berlin" - offset2 = Time.local_offset_in_minutes - - offset1.should_not eq(offset2) - ensure - ENV["TZ"] = old_tz - end - end - it "does diff of utc vs local time" do local = Time.now utc = local.to_utc @@ -719,6 +856,36 @@ describe Time do (local - utc).should eq(0.seconds) end + describe "#in" do + it "changes location" do + location = Time::Location.fixed(3600) + location2 = Time::Location.fixed(12345) + time1 = Time.now(location) + time1.location.should eq(location) + + time2 = time1.in(location2) + time2.should eq(time1) + time2.location.should eq(location2) + end + end + + it "#to_s" do + with_zoneinfo do + time = Time.new(2017, 11, 25, 22, 6, 17, location: Time::Location::UTC) + time.to_s.should eq "2017-11-25 22:06:17 UTC" + + time = Time.new(2017, 11, 25, 22, 6, 17, location: Time::Location.fixed(-7200)) + time.to_s.should eq "2017-11-25 22:06:17 -02:00" + + time = Time.new(2017, 11, 25, 22, 6, 17, location: Time::Location.fixed(-7259)) + time.to_s.should eq "2017-11-25 22:06:17 -02:00:59" + + location = Time::Location.load("Europe/Berlin") + time = Time.new(2017, 11, 25, 22, 6, 17, location: location) + time.to_s.should eq "2017-11-25 22:06:17 +01:00 Europe/Berlin" + end + end + describe "days in month" do it "returns days for valid month and year" do Time.days_in_month(2016, 2).should eq(29) diff --git a/spec/std/yaml/mapping_spec.cr b/spec/std/yaml/mapping_spec.cr index 809b3c949ff1..f649fb7ff2e1 100644 --- a/spec/std/yaml/mapping_spec.cr +++ b/spec/std/yaml/mapping_spec.cr @@ -269,8 +269,8 @@ describe "YAML mapping" do it "parses yaml with Time::Format converter" do yaml = YAMLWithTime.from_yaml("---\nvalue: 2014-10-31 23:37:16\n") yaml.value.should be_a(Time) - yaml.value.to_s.should eq("2014-10-31 23:37:16") - yaml.value.should eq(Time.new(2014, 10, 31, 23, 37, 16)) + yaml.value.to_s.should eq("2014-10-31 23:37:16 UTC") + yaml.value.should eq(Time.utc(2014, 10, 31, 23, 37, 16)) yaml.to_yaml.should eq("---\nvalue: 2014-10-31 23:37:16\n") end diff --git a/spec/std/yaml/serialization_spec.cr b/spec/std/yaml/serialization_spec.cr index fa5026a046f9..801726d383f3 100644 --- a/spec/std/yaml/serialization_spec.cr +++ b/spec/std/yaml/serialization_spec.cr @@ -153,7 +153,7 @@ describe "YAML serialization" do ctx = YAML::ParseContext.new nodes = YAML::Nodes.parse("--- 2014-01-02\n...\n").nodes.first value = Time::Format.new("%F").from_yaml(ctx, nodes) - value.should eq(Time.new(2014, 1, 2)) + value.should eq(Time.utc(2014, 1, 2)) end it "deserializes union" do diff --git a/src/crystal/system/time.cr b/src/crystal/system/time.cr index e310995ac317..340a9e06f19e 100644 --- a/src/crystal/system/time.cr +++ b/src/crystal/system/time.cr @@ -1,13 +1,15 @@ module Crystal::System::Time - # Returns the number of seconds that you must add to UTC to get local time. - # *seconds* are measured from `0001-01-01 00:00:00`. - # def self.compute_utc_offset(seconds : Int64) : Int32 - # Returns the current UTC time measured in `{seconds, nanoseconds}` # since `0001-01-01 00:00:00`. # def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32} # def self.monotonic : {Int64, Int32} + + # Returns a list of paths where time zone data should be looked up. + # def self.zone_sources : Enumerable(String) + + # Returns the system's current local time zone + # def self.load_localtime : ::Time::Location? end {% if flag?(:win32) %} diff --git a/src/crystal/system/unix/time.cr b/src/crystal/system/unix/time.cr index be8ea2dd7f07..df0075d8d39c 100644 --- a/src/crystal/system/unix/time.cr +++ b/src/crystal/system/unix/time.cr @@ -13,28 +13,6 @@ require "c/time" module Crystal::System::Time UnixEpochInSeconds = 62135596800_i64 - def self.compute_utc_offset(seconds : Int64) : Int32 - LibC.tzset - offset = nil - - {% if LibC.methods.includes?("daylight".id) %} - if LibC.daylight == 0 - # current TZ doesn't have any DST, neither in past, present or future - offset = -LibC.timezone.to_i - end - {% end %} - - unless offset - seconds_from_epoch = LibC::TimeT.new(seconds - UnixEpochInSeconds) - # current TZ may have DST, either in past, present or future - ret = LibC.localtime_r(pointerof(seconds_from_epoch), out tm) - raise Errno.new("localtime_r") if ret.null? - offset = tm.tm_gmtoff.to_i - end - - offset - end - def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32} {% if LibC.methods.includes?("clock_gettime".id) %} ret = LibC.clock_gettime(LibC::CLOCK_REALTIME, out timespec) @@ -62,6 +40,27 @@ module Crystal::System::Time {% end %} end + # Many systems use /usr/share/zoneinfo, Solaris 2 has + # /usr/share/lib/zoneinfo, IRIX 6 has /usr/lib/locale/TZ. + ZONE_SOURCES = { + "/usr/share/zoneinfo/", + "/usr/share/lib/zoneinfo/", + "/usr/lib/locale/TZ/", + } + LOCALTIME = "/etc/localtime" + + def self.zone_sources : Enumerable(String) + ZONE_SOURCES + end + + def self.load_localtime : ::Time::Location? + if ::File.exists?(LOCALTIME) + ::File.open(LOCALTIME) do |file| + ::Time::Location.read_zoneinfo("Local", file) + end + end + end + {% if flag?(:darwin) %} @@mach_timebase_info : LibC::MachTimebaseInfo? diff --git a/src/crystal/system/win32/time.cr b/src/crystal/system/win32/time.cr index 55c6d147f39d..36d5aeed8780 100644 --- a/src/crystal/system/win32/time.cr +++ b/src/crystal/system/win32/time.cr @@ -1,5 +1,6 @@ require "c/winbase" require "winerror" +require "./zone_names" module Crystal::System::Time # Win32 epoch is 1601-01-01 00:00:00 UTC @@ -11,13 +12,7 @@ module Crystal::System::Time NANOSECONDS_PER_SECOND = 1_000_000_000 FILETIME_TICKS_PER_SECOND = NANOSECONDS_PER_SECOND / NANOSECONDS_PER_FILETIME_TICK - # TODO: For now, this method returns the UTC offset currently in place, ignoring *seconds*. - def self.compute_utc_offset(seconds : Int64) : Int32 - ret = LibC.GetTimeZoneInformation(out zone_information) - raise WinError.new("GetTimeZoneInformation") if ret == -1 - - zone_information.bias.to_i32 * -60 - end + BIAS_TO_OFFSET_FACTOR = -60 def self.compute_utc_seconds_and_nanoseconds : {Int64, Int32} # TODO: Needs a check if `GetSystemTimePreciseAsFileTime` is actually available (only >= Windows 8) @@ -47,4 +42,119 @@ module Crystal::System::Time {ticks / @@performance_frequency, (ticks.remainder(NANOSECONDS_PER_SECOND) * NANOSECONDS_PER_SECOND / @@performance_frequency).to_i32} end + + def self.load_localtime : ::Time::Location? + if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_UNKNOWN + initialize_location_from_TZI(info) + end + end + + def self.zone_sources : Enumerable(String) + [] of String + end + + private def self.initialize_location_from_TZI(info) + stdname, dstname = normalize_zone_names(info) + + if info.standardDate.wMonth == 0_u16 + # No DST + zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false) + return ::Time::Location.new("Local", [zone]) + end + + zones = [ + ::Time::Location::Zone.new(stdname, (info.bias + info.standardBias) * BIAS_TO_OFFSET_FACTOR, false), + ::Time::Location::Zone.new(dstname, (info.bias + info.daylightBias) * BIAS_TO_OFFSET_FACTOR, true), + ] + + first_date = info.standardDate + second_date = info.daylightDate + first_index = 0_u8 + second_index = 1_u8 + + if info.standardDate.wMonth > info.daylightDate.wMonth + first_date, second_date = second_date, first_date + first_index, second_index = second_index, first_index + end + + transitions = [] of ::Time::Location::ZoneTransition + + current_year = ::Time.utc_now.year + + (current_year - 100).upto(current_year + 100) do |year| + tstamp = calculate_switchdate_in_year(year, first_date) - (zones[second_index].offset) + transitions << ::Time::Location::ZoneTransition.new(tstamp, first_index, first_index == 0, false) + + tstamp = calculate_switchdate_in_year(year, second_date) - (zones[first_index].offset) + transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false) + end + + ::Time::Location.new("Local", zones, transitions) + end + + # Calculates the day of a DST switch in year *year* by extrapolating the date given in + # *systemtime* (for the current year). + # + # Returns the number of seconds since UNIX epoch (Jan 1 1970) in the local time zone. + private def self.calculate_switchdate_in_year(year, systemtime) + # Windows specifies daylight savings information in "day in month" format: + # wMonth is month number (1-12) + # wDayOfWeek is appropriate weekday (Sunday=0 to Saturday=6) + # wDay is week within the month (1 to 5, where 5 is last week of the month) + # wHour, wMinute and wSecond are absolute time + day = 1 + + time = ::Time.utc(year, systemtime.wMonth, day, systemtime.wHour, systemtime.wMinute, systemtime.wSecond) + i = systemtime.wDayOfWeek.to_i32 - time.day_of_week.to_i32 + + if i < 0 + i += 7 + end + + day += i + + week = systemtime.wDay - 1 + + if week < 4 + day += week * 7 + else + # "Last" instance of the day. + day += 4 * 7 + if day > ::Time.days_in_month(year, systemtime.wMonth) + day -= 7 + end + end + + time += (day - 1).days + + time.epoch + end + + # Normalizes the names of the standard and dst zones. + private def self.normalize_zone_names(info : LibC::TIME_ZONE_INFORMATION) : Tuple(String, String) + stdname = String.from_utf16(info.standardName.to_unsafe) + + if normalized_names = WINDOWS_ZONE_NAMES[stdname]? + return normalized_names + end + + dstname = String.from_utf16(info.daylightName.to_unsafe) + + if english_name = translate_zone_name(stdname, dstname) + if normalized_names = WINDOWS_ZONE_NAMES[english_name]? + return normalized_names + end + end + + # As a last resort, return the raw names as provided by TIME_ZONE_INFORMATION. + # They are most probably localized and we couldn't find a translation. + return stdname, dstname + end + + # Searches the registry for an English name of a time zone named *stdname* or *dstname* + # and returns the English name. + private def self.translate_zone_name(stdname, dstname) + # TODO: Needs implementation once there is access to the registry. + nil + end end diff --git a/src/crystal/system/win32/zone_names.cr b/src/crystal/system/win32/zone_names.cr new file mode 100644 index 000000000000..b29df3597772 --- /dev/null +++ b/src/crystal/system/win32/zone_names.cr @@ -0,0 +1,145 @@ +# This file was automatically generated by running: +# +# scripts/generate_windows_zone_names.cr +# +# DO NOT EDIT + +module Crystal::System::Time + # These mappings for windows time zone names are based on + # http://unicode.org/cldr/data/common/supplemental/windowsZones.xml + WINDOWS_ZONE_NAMES = { + "Egypt Standard Time" => {"EET", "EET"}, # Africa/Cairo + "Morocco Standard Time" => {"WET", "WEST"}, # Africa/Casablanca + "South Africa Standard Time" => {"SAST", "SAST"}, # Africa/Johannesburg + "W. Central Africa Standard Time" => {"WAT", "WAT"}, # Africa/Lagos + "E. Africa Standard Time" => {"EAT", "EAT"}, # Africa/Nairobi + "Libya Standard Time" => {"EET", "EET"}, # Africa/Tripoli + "Namibia Standard Time" => {"WAT", "WAST"}, # Africa/Windhoek + "Aleutian Standard Time" => {"HST", "HDT"}, # America/Adak + "Alaskan Standard Time" => {"AKST", "AKDT"}, # America/Anchorage + "Tocantins Standard Time" => {"BRT", "BRT"}, # America/Araguaina + "Paraguay Standard Time" => {"PYT", "PYST"}, # America/Asuncion + "Bahia Standard Time" => {"BRT", "BRT"}, # America/Bahia + "SA Pacific Standard Time" => {"COT", "COT"}, # America/Bogota + "Argentina Standard Time" => {"ART", "ART"}, # America/Buenos_Aires + "Eastern Standard Time (Mexico)" => {"EST", "EST"}, # America/Cancun + "Venezuela Standard Time" => {"VET", "VET"}, # America/Caracas + "SA Eastern Standard Time" => {"GFT", "GFT"}, # America/Cayenne + "Central Standard Time" => {"CST", "CDT"}, # America/Chicago + "Mountain Standard Time (Mexico)" => {"MST", "MDT"}, # America/Chihuahua + "Central Brazilian Standard Time" => {"AMT", "AMST"}, # America/Cuiaba + "Mountain Standard Time" => {"MST", "MDT"}, # America/Denver + "Greenland Standard Time" => {"WGT", "WGST"}, # America/Godthab + "Turks And Caicos Standard Time" => {"AST", "AST"}, # America/Grand_Turk + "Central America Standard Time" => {"CST", "CST"}, # America/Guatemala + "Atlantic Standard Time" => {"AST", "ADT"}, # America/Halifax + "Cuba Standard Time" => {"CST", "CDT"}, # America/Havana + "US Eastern Standard Time" => {"EST", "EDT"}, # America/Indianapolis + "SA Western Standard Time" => {"BOT", "BOT"}, # America/La_Paz + "Pacific Standard Time" => {"PST", "PDT"}, # America/Los_Angeles + "Central Standard Time (Mexico)" => {"CST", "CDT"}, # America/Mexico_City + "Saint Pierre Standard Time" => {"PMST", "PMDT"}, # America/Miquelon + "Montevideo Standard Time" => {"UYT", "UYT"}, # America/Montevideo + "Eastern Standard Time" => {"EST", "EDT"}, # America/New_York + "US Mountain Standard Time" => {"MST", "MST"}, # America/Phoenix + "Haiti Standard Time" => {"EST", "EST"}, # America/Port-au-Prince + "Canada Central Standard Time" => {"CST", "CST"}, # America/Regina + "Pacific SA Standard Time" => {"CLT", "CLST"}, # America/Santiago + "E. South America Standard Time" => {"BRT", "BRST"}, # America/Sao_Paulo + "Newfoundland Standard Time" => {"NST", "NDT"}, # America/St_Johns + "Pacific Standard Time (Mexico)" => {"PST", "PDT"}, # America/Tijuana + "Central Asia Standard Time" => {"+06", "+06"}, # Asia/Almaty + "Jordan Standard Time" => {"EET", "EEST"}, # Asia/Amman + "Arabic Standard Time" => {"AST", "AST"}, # Asia/Baghdad + "Azerbaijan Standard Time" => {"+04", "+04"}, # Asia/Baku + "SE Asia Standard Time" => {"ICT", "ICT"}, # Asia/Bangkok + "Altai Standard Time" => {"+07", "+07"}, # Asia/Barnaul + "Middle East Standard Time" => {"EET", "EEST"}, # Asia/Beirut + "India Standard Time" => {"IST", "IST"}, # Asia/Calcutta + "Transbaikal Standard Time" => {"+09", "+09"}, # Asia/Chita + "Sri Lanka Standard Time" => {"+0530", "+0530"}, # Asia/Colombo + "Syria Standard Time" => {"EET", "EEST"}, # Asia/Damascus + "Bangladesh Standard Time" => {"BDT", "BDT"}, # Asia/Dhaka + "Arabian Standard Time" => {"GST", "GST"}, # Asia/Dubai + "West Bank Standard Time" => {"EET", "EEST"}, # Asia/Hebron + "W. Mongolia Standard Time" => {"HOVT", "HOVST"}, # Asia/Hovd + "North Asia East Standard Time" => {"+08", "+08"}, # Asia/Irkutsk + "Israel Standard Time" => {"IST", "IDT"}, # Asia/Jerusalem + "Afghanistan Standard Time" => {"AFT", "AFT"}, # Asia/Kabul + "Russia Time Zone 11" => {"+12", "+12"}, # Asia/Kamchatka + "Pakistan Standard Time" => {"PKT", "PKT"}, # Asia/Karachi + "Nepal Standard Time" => {"NPT", "NPT"}, # Asia/Katmandu + "North Asia Standard Time" => {"+07", "+07"}, # Asia/Krasnoyarsk + "Magadan Standard Time" => {"+11", "+11"}, # Asia/Magadan + "N. Central Asia Standard Time" => {"+07", "+07"}, # Asia/Novosibirsk + "Omsk Standard Time" => {"+06", "+06"}, # Asia/Omsk + "North Korea Standard Time" => {"KST", "KST"}, # Asia/Pyongyang + "Myanmar Standard Time" => {"MMT", "MMT"}, # Asia/Rangoon + "Arab Standard Time" => {"AST", "AST"}, # Asia/Riyadh + "Sakhalin Standard Time" => {"+11", "+11"}, # Asia/Sakhalin + "Korea Standard Time" => {"KST", "KST"}, # Asia/Seoul + "China Standard Time" => {"CST", "CST"}, # Asia/Shanghai + "Singapore Standard Time" => {"SGT", "SGT"}, # Asia/Singapore + "Russia Time Zone 10" => {"+11", "+11"}, # Asia/Srednekolymsk + "Taipei Standard Time" => {"CST", "CST"}, # Asia/Taipei + "West Asia Standard Time" => {"+05", "+05"}, # Asia/Tashkent + "Georgian Standard Time" => {"+04", "+04"}, # Asia/Tbilisi + "Iran Standard Time" => {"IRST", "IRDT"}, # Asia/Tehran + "Tokyo Standard Time" => {"JST", "JST"}, # Asia/Tokyo + "Tomsk Standard Time" => {"+07", "+07"}, # Asia/Tomsk + "Ulaanbaatar Standard Time" => {"ULAT", "ULAST"}, # Asia/Ulaanbaatar + "Vladivostok Standard Time" => {"+10", "+10"}, # Asia/Vladivostok + "Yakutsk Standard Time" => {"+09", "+09"}, # Asia/Yakutsk + "Ekaterinburg Standard Time" => {"+05", "+05"}, # Asia/Yekaterinburg + "Caucasus Standard Time" => {"+04", "+04"}, # Asia/Yerevan + "Azores Standard Time" => {"AZOT", "AZOST"}, # Atlantic/Azores + "Cape Verde Standard Time" => {"CVT", "CVT"}, # Atlantic/Cape_Verde + "Greenwich Standard Time" => {"GMT", "GMT"}, # Atlantic/Reykjavik + "Cen. Australia Standard Time" => {"ACST", "ACDT"}, # Australia/Adelaide + "E. Australia Standard Time" => {"AEST", "AEST"}, # Australia/Brisbane + "AUS Central Standard Time" => {"ACST", "ACST"}, # Australia/Darwin + "Aus Central W. Standard Time" => {"ACWST", "ACWST"}, # Australia/Eucla + "Tasmania Standard Time" => {"AEST", "AEDT"}, # Australia/Hobart + "Lord Howe Standard Time" => {"LHST", "LHDT"}, # Australia/Lord_Howe + "W. Australia Standard Time" => {"AWST", "AWST"}, # Australia/Perth + "AUS Eastern Standard Time" => {"AEST", "AEDT"}, # Australia/Sydney + "UTC" => {"GMT", "GMT"}, # Etc/GMT + "UTC-11" => {"-11", "-11"}, # Etc/GMT+11 + "Dateline Standard Time" => {"-12", "-12"}, # Etc/GMT+12 + "UTC-02" => {"-02", "-02"}, # Etc/GMT+2 + "UTC-08" => {"-08", "-08"}, # Etc/GMT+8 + "UTC-09" => {"-09", "-09"}, # Etc/GMT+9 + "UTC+12" => {"+12", "+12"}, # Etc/GMT-12 + "UTC+13" => {"+13", "+13"}, # Etc/GMT-13 + "Astrakhan Standard Time" => {"+04", "+04"}, # Europe/Astrakhan + "W. Europe Standard Time" => {"CET", "CEST"}, # Europe/Berlin + "GTB Standard Time" => {"EET", "EEST"}, # Europe/Bucharest + "Central Europe Standard Time" => {"CET", "CEST"}, # Europe/Budapest + "E. Europe Standard Time" => {"EET", "EEST"}, # Europe/Chisinau + "Turkey Standard Time" => {"+03", "+03"}, # Europe/Istanbul + "Kaliningrad Standard Time" => {"EET", "EET"}, # Europe/Kaliningrad + "FLE Standard Time" => {"EET", "EEST"}, # Europe/Kiev + "GMT Standard Time" => {"GMT", "BST"}, # Europe/London + "Belarus Standard Time" => {"+03", "+03"}, # Europe/Minsk + "Russian Standard Time" => {"MSK", "MSK"}, # Europe/Moscow + "Romance Standard Time" => {"CET", "CEST"}, # Europe/Paris + "Russia Time Zone 3" => {"+04", "+04"}, # Europe/Samara + "Saratov Standard Time" => {"+04", "+04"}, # Europe/Saratov + "Central European Standard Time" => {"CET", "CEST"}, # Europe/Warsaw + "Mauritius Standard Time" => {"MUT", "MUT"}, # Indian/Mauritius + "Samoa Standard Time" => {"WSST", "WSDT"}, # Pacific/Apia + "New Zealand Standard Time" => {"NZST", "NZDT"}, # Pacific/Auckland + "Bougainville Standard Time" => {"BST", "BST"}, # Pacific/Bougainville + "Chatham Islands Standard Time" => {"CHAST", "CHADT"}, # Pacific/Chatham + "Easter Island Standard Time" => {"EAST", "EASST"}, # Pacific/Easter + "Fiji Standard Time" => {"FJT", "FJST"}, # Pacific/Fiji + "Central Pacific Standard Time" => {"SBT", "SBT"}, # Pacific/Guadalcanal + "Hawaiian Standard Time" => {"HST", "HST"}, # Pacific/Honolulu + "Line Islands Standard Time" => {"LINT", "LINT"}, # Pacific/Kiritimati + "Marquesas Standard Time" => {"MART", "MART"}, # Pacific/Marquesas + "Norfolk Standard Time" => {"NFT", "NFT"}, # Pacific/Norfolk + "West Pacific Standard Time" => {"PGT", "PGT"}, # Pacific/Port_Moresby + "Tonga Standard Time" => {"+13", "+14"}, # Pacific/Tongatapu + + } +end diff --git a/src/file/stat.cr b/src/file/stat.cr index cbce74db9b95..9bce15b19d69 100644 --- a/src/file/stat.cr +++ b/src/file/stat.cr @@ -202,7 +202,7 @@ class File end {% else %} private def time(value) - Time.new value, Time::Kind::Utc + Time.new value, Time::Location::UTC end {% end %} end diff --git a/src/http/common.cr b/src/http/common.cr index abd751f879e4..0c2d0b371b01 100644 --- a/src/http/common.cr +++ b/src/http/common.cr @@ -225,7 +225,7 @@ module HTTP def self.parse_time(time_str : String) : Time? DATE_PATTERNS.each do |pattern| begin - return Time.parse(time_str, pattern, kind: Time::Kind::Utc) + return Time.parse(time_str, pattern, location: Time::Location::UTC) rescue Time::Format::Error end end diff --git a/src/json/from_json.cr b/src/json/from_json.cr index 56b396a5e80b..362bc55120b9 100644 --- a/src/json/from_json.cr +++ b/src/json/from_json.cr @@ -237,7 +237,7 @@ end struct Time::Format def from_json(pull : JSON::PullParser) string = pull.read_string - parse(string) + parse(string, Time::Location::UTC) end end diff --git a/src/lib_c/x86_64-windows-msvc/c/winbase.cr b/src/lib_c/x86_64-windows-msvc/c/winbase.cr index 642a8ca8f1f0..fdcc39cde2b2 100644 --- a/src/lib_c/x86_64-windows-msvc/c/winbase.cr +++ b/src/lib_c/x86_64-windows-msvc/c/winbase.cr @@ -41,6 +41,10 @@ lib LibC daylightBias : LONG end + TIME_ZONE_ID_UNKNOWN = 0_u32 + TIME_ZONE_ID_STANDARD = 1_u32 + TIME_ZONE_ID_DAYLIGHT = 2_u32 + fun GetTimeZoneInformation(tz_info : TIME_ZONE_INFORMATION*) : DWORD fun GetSystemTimeAsFileTime(time : FILETIME*) fun GetSystemTimePreciseAsFileTime(time : FILETIME*) diff --git a/src/time.cr b/src/time.cr index 79ee7ba971b6..8d5a902cb54d 100644 --- a/src/time.cr +++ b/src/time.cr @@ -1,6 +1,6 @@ require "crystal/system/time" -# `Time` represents an instance in time. Here are some examples: +# `Time` represents an instance in incremental time. Here are some examples: # # ### Basic Usage # @@ -15,11 +15,12 @@ require "crystal/system/time" # time.second # => 30 # time.monday? # => true # -# # Creating a time instance with a date only -# Time.new(2016, 2, 15) # => 2016-02-15 00:00:00 +# # Creating a time instance with a date only in local timezone `Time::Location.local`. +# # The examples show an offset of `+01:00` but that can vary depending on +# Time.new(2016, 2, 15) # => 2016-02-15 00:00:00 +01:00 # # # Specifying a time -# Time.new(2016, 2, 15, 10, 20, 30) # => 2016-02-15 10:20:30 +# Time.new(2016, 2, 15, 10, 20, 30) # => 2016-02-15 10:20:30 +01:00 # # # Creating a time instance in UTC # Time.utc(2016, 2, 15, 10, 20, 30) # => 2016-02-15 10:20:30 UTC @@ -39,7 +40,7 @@ require "crystal/system/time" # ### Calculation # # ``` -# Time.new(2015, 10, 10) - 5.days # => 2015-10-05 00:00:00 +# Time.new(2015, 10, 10) - 5.days # => 2015-10-05 00:00:00 +01:00 # # # Time calculation returns a Time::Span instance # span = Time.new(2015, 10, 10) - Time.new(2015, 9, 10) @@ -56,6 +57,9 @@ require "crystal/system/time" # span.hours # => 1 # ``` struct Time + class FloatingTimeConversionError < Exception + end + include Comparable(self) # :nodoc: @@ -129,37 +133,9 @@ struct Time Saturday end - # `Kind` represents a specified time zone. - # - # Initializing a `Time` instance with specified `Kind`: - # - # ``` - # time = Time.new(2016, 2, 15, 21, 1, 10, 0, Time::Kind::Local) - # ``` - # - # Alternatively, you can switch the `Kind` for any instance: - # - # ``` - # time.to_utc # => 2016-02-15 21:00:00 UTC - # time.to_local # => 2016-02-16 05:01:10 +0800 - # ``` - # - # Inspection: - # - # ``` - # time.local? # => true - # time.utc? # => false - # ``` - # - enum Kind - Unspecified = 0 - Utc = 1 - Local = 2 - end - @seconds : Int64 @nanoseconds : Int32 - @kind : Kind + @location : Location # Returns a clock from an unspecified starting point, but strictly linearly # increasing. This clock should be independent from discontinuous jumps in the @@ -181,12 +157,12 @@ struct Time monotonic - start end - def self.new - seconds, nanoseconds, offset = Time.compute_seconds_nanoseconds_and_offset - new(seconds: seconds + offset, nanoseconds: nanoseconds, kind: Kind::Local) + def self.new(location = Location.local) + seconds, nanoseconds = Crystal::System::Time.compute_utc_seconds_and_nanoseconds + new(seconds: seconds, nanoseconds: nanoseconds, location: location) end - def self.new(year, month, day, hour = 0, minute = 0, second = 0, *, nanosecond = 0, kind = Kind::Unspecified) + def self.new(year, month, day, hour = 0, minute = 0, second = 0, *, nanosecond = 0, location = Location.local) unless 1 <= year <= 9999 && 1 <= month <= 12 && 1 <= day <= Time.days_in_month(year, month) && @@ -205,19 +181,22 @@ struct Time SECONDS_PER_MINUTE * minute + second - new(seconds: seconds, nanoseconds: nanosecond.to_i, kind: kind) + # Normalize internal representation to UTC + seconds = seconds - zone_offset_at(seconds, location) + + new(seconds: seconds, nanoseconds: nanosecond.to_i, location: location) end {% unless flag?(:win32) %} # :nodoc: - def self.new(time : LibC::Timespec, kind = Kind::Unspecified) + def self.new(time : LibC::Timespec, location = Location.local) seconds = UNIX_SECONDS + time.tv_sec nanoseconds = time.tv_nsec.to_i - new(seconds: seconds, nanoseconds: nanoseconds, kind: kind) + new(seconds: seconds, nanoseconds: nanoseconds, location: location) end {% end %} - def initialize(*, @seconds : Int64, @nanoseconds : Int32, @kind : Kind) + def initialize(*, @seconds : Int64, @nanoseconds : Int32, @location : Location) unless 0 <= @nanoseconds < NANOSECONDS_PER_SECOND raise ArgumentError.new "Invalid time: nanoseconds out of range" end @@ -249,12 +228,12 @@ struct Time # Returns a new `Time` instance at the specified time in UTC time zone. def self.utc(year, month, day, hour = 0, minute = 0, second = 0, *, nanosecond = 0) : Time - new(year, month, day, hour, minute, second, nanosecond: nanosecond, kind: Kind::Utc) + new(year, month, day, hour, minute, second, nanosecond: nanosecond, location: Location::UTC) end # Returns a new `Time` instance at the specified time in UTC time zone. def self.utc(*, seconds : Int64, nanoseconds : Int32) : Time - new(seconds: seconds, nanoseconds: nanoseconds, kind: Kind::Utc) + new(seconds: seconds, nanoseconds: nanoseconds, location: Location::UTC) end def clone : self @@ -299,7 +278,7 @@ struct Time day = maxday end - temp = Time.new(year, month, day, kind: kind) + temp = Time.new(year, month, day, location: location) temp + time_of_day end @@ -322,39 +301,33 @@ struct Time raise ArgumentError.new "Invalid time" end - Time.new(seconds: seconds, nanoseconds: nanoseconds.to_i, kind: kind) + Time.new(seconds: seconds, nanoseconds: nanoseconds.to_i, location: location) end # Returns the amount of time between *other* and `self`. # # The amount can be negative if `self` is a `Time` that happens before *other*. def -(other : Time) : Time::Span - if local? && other.utc? - self - other.to_local - elsif utc? && other.local? - self - other.to_utc - else - Span.new( - seconds: total_seconds - other.total_seconds, - nanoseconds: nanosecond - other.nanosecond, - ) - end + Span.new( + seconds: total_seconds - other.total_seconds, + nanoseconds: nanosecond - other.nanosecond, + ) end - # Returns the current time in the local time zone. - def self.now : Time - new + # Returns the current time in the time zone currently observed in *location*, + # using local time zone by default. + def self.now(location = Location.local) : Time + new(location) end # Returns the current time in UTC time zone. def self.utc_now : Time - seconds, nanoseconds = compute_seconds_and_nanoseconds - utc(seconds: seconds, nanoseconds: nanoseconds) + now(Location::UTC) end # Returns a copy of `self` with time-of-day components (hour, minute, ...) set to zero. def date : Time - Time.new(year, month, day, kind: kind) + Time.new(year, month, day, location: location) end # Returns the year number (in the Common Era). @@ -374,17 +347,17 @@ struct Time # Returns the hour of the day (`0..23`). def hour : Int32 - ((total_seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR).to_i + ((offset_seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR).to_i end # Returns the minute of the hour (`0..59`). def minute : Int32 - ((total_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).to_i + ((offset_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).to_i end # Returns the second of the minute (`0..59`). def second : Int32 - (total_seconds % SECONDS_PER_MINUTE).to_i + (offset_seconds % SECONDS_PER_MINUTE).to_i end # Returns the millisecond of the second (`0..999`). @@ -399,12 +372,12 @@ struct Time # Returns how much time has passed since midnight of this day. def time_of_day : Time::Span - Span.new(nanoseconds: NANOSECONDS_PER_SECOND * (total_seconds % SECONDS_PER_DAY) + nanosecond) + Span.new(nanoseconds: NANOSECONDS_PER_SECOND * (offset_seconds % SECONDS_PER_DAY) + nanosecond) end # Returns the day of the week (`Sunday..Saturday`). def day_of_week : Time::DayOfWeek - value = ((total_seconds / SECONDS_PER_DAY) + 1) % 7 + value = ((offset_seconds / SECONDS_PER_DAY) + 1) % 7 DayOfWeek.new value.to_i end @@ -413,31 +386,43 @@ struct Time year_month_day_day_year[3] end - # Returns `Kind` (UTC/local) of the instance. - def kind : Kind - @kind + # Returns `Location` of the instance. + def location : Location + @location + end + + # Returns the time zone in effect in `location` at this point in time. + def zone + location.lookup(self) end - # Returns `true` if `Kind` is set to *Utc*. + # Returns the offset from UTC (in seconds) in `location` at this point in time. + def offset : Int32 + zone.offset + end + + # Returns `true` if `#location` equals to `Location::UTC`. def utc? : Bool - kind == Kind::Utc + location.utc? end - # Returns `true` if `Kind` is set to *Local*. + # Returns `true` if this time's `#location` equals to the current + # local location as returned by `Location.local`. + # + # Since the system's settings may change during a programm's runtime, + # the result may not be identical between different invocations. def local? : Bool - kind == Kind::Local + location.local? end def <=>(other : self) - if utc? && other.local? - self <=> other.to_utc - elsif local? && other.utc? - to_utc <=> other - else - cmp = total_seconds <=> other.total_seconds - cmp = nanosecond <=> other.nanosecond if cmp == 0 - cmp - end + cmp = total_seconds <=> other.total_seconds + cmp = nanosecond <=> other.nanosecond if cmp == 0 + cmp + end + + def ==(other : self) + total_seconds == other.total_seconds && nanosecond == other.nanosecond end def_hash total_seconds, nanosecond @@ -481,13 +466,16 @@ struct Time end def inspect(io : IO) - Format.new("%F %T").format(self, io) - case when utc? - io << " UTC" - when local? - Format.new(" %:z").format(self, io) + to_s "%F %T UTC", io + else + if offset % 60 == 0 + to_s "%F %T %:z", io + else + to_s "%F %T %::z", io + end + io << ' ' << location.name unless location.fixed? || location.name == "Local" end io end @@ -512,10 +500,10 @@ struct Time # `Time::Format`). # # ``` - # Time.parse("2016-04-05", "%F") # => 2016-04-05 00:00:00 + # Time.parse("2016-04-05", "%F") # => 2016-04-05 00:00:00 +01:00 # ``` - def self.parse(time : String, pattern : String, kind = Time::Kind::Unspecified) : Time - Format.new(pattern, kind).parse(time) + def self.parse(time : String, pattern : String, location = nil) : Time + Format.new(pattern, location).parse(time) end # Returns the number of seconds since the Epoch for this time. @@ -525,7 +513,7 @@ struct Time # time.epoch # => 1452567845 # ``` def epoch : Int64 - (to_utc.total_seconds - UNIX_SECONDS).to_i64 + (total_seconds - UNIX_SECONDS).to_i64 end # Returns the number of milliseconds since the Epoch for this time. @@ -549,13 +537,31 @@ struct Time epoch.to_f + nanosecond.to_f / 1e9 end + # Retuns this instance of time represented in `Location` *location*. + # + # ``` + # time = Time.new(2018, 1, 7, 15, 51, location: Time::Location.load("Europe/Berlin")) + # time # => 2018-01-07 15:51:00 +01:00 Europe/Berlin + # time = time.in(Time::Location.load("Australia/Sydney")) + # time # => 2018-01-08 01:51:00 +11:00 Australia/Sydney + # ``` + def in(location : Location) : Time + return self if location == self.location + + Time.new( + seconds: total_seconds, + nanoseconds: nanosecond, + location: location + ) + end + # Returns a copy of this `Time` converted to UTC. def to_utc : Time if utc? self else Time.utc( - seconds: total_seconds - Time.compute_offset, + seconds: total_seconds, nanoseconds: nanosecond ) end @@ -566,11 +572,7 @@ struct Time if local? self else - Time.new( - seconds: total_seconds + Time.compute_offset, - nanoseconds: nanosecond, - kind: Kind::Local, - ) + in(Location.local) end end @@ -590,13 +592,13 @@ struct Time end end - def_at_beginning(year) { Time.new(year, 1, 1, kind: kind) } - def_at_beginning(semester) { Time.new(year, ((month - 1) / 6) * 6 + 1, 1, kind: kind) } - def_at_beginning(quarter) { Time.new(year, ((month - 1) / 3) * 3 + 1, 1, kind: kind) } - def_at_beginning(month) { Time.new(year, month, 1, kind: kind) } - def_at_beginning(day) { Time.new(year, month, day, kind: kind) } - def_at_beginning(hour) { Time.new(year, month, day, hour, kind: kind) } - def_at_beginning(minute) { Time.new(year, month, day, hour, minute, kind: kind) } + def_at_beginning(year) { Time.new(year, 1, 1, location: location) } + def_at_beginning(semester) { Time.new(year, ((month - 1) / 6) * 6 + 1, 1, location: location) } + def_at_beginning(quarter) { Time.new(year, ((month - 1) / 3) * 3 + 1, 1, location: location) } + def_at_beginning(month) { Time.new(year, month, 1, location: location) } + def_at_beginning(day) { Time.new(year, month, day, location: location) } + def_at_beginning(hour) { Time.new(year, month, day, hour, location: location) } + def_at_beginning(minute) { Time.new(year, month, day, hour, minute, location: location) } # Returns the time when the week that includes `self` starts. def at_beginning_of_week : Time @@ -608,7 +610,7 @@ struct Time end end - def_at_end(year) { Time.new(year, 12, 31, 23, 59, 59, nanosecond: 999_999_999, kind: kind) } + def_at_end(year) { Time.new(year, 12, 31, 23, 59, 59, nanosecond: 999_999_999, location: location) } # Returns the time when the half-year that includes `self` ends. def at_end_of_semester : Time @@ -618,7 +620,7 @@ struct Time else month, day = 12, 31 end - Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, kind: kind) + Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, location: location) end # Returns the time when the quarter-year that includes `self` ends. @@ -633,10 +635,10 @@ struct Time else month, day = 12, 31 end - Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, kind: kind) + Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, location: location) end - def_at_end(month) { Time.new(year, month, Time.days_in_month(year, month), 23, 59, 59, nanosecond: 999_999_999, kind: kind) } + def_at_end(month) { Time.new(year, month, Time.days_in_month(year, month), 23, 59, 59, nanosecond: 999_999_999, location: location) } # Returns the time when the week that includes `self` ends. def at_end_of_week : Time @@ -648,14 +650,14 @@ struct Time end end - def_at_end(day) { Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, kind: kind) } - def_at_end(hour) { Time.new(year, month, day, hour, 59, 59, nanosecond: 999_999_999, kind: kind) } - def_at_end(minute) { Time.new(year, month, day, hour, minute, 59, nanosecond: 999_999_999, kind: kind) } + def_at_end(day) { Time.new(year, month, day, 23, 59, 59, nanosecond: 999_999_999, location: location) } + def_at_end(hour) { Time.new(year, month, day, hour, 59, 59, nanosecond: 999_999_999, location: location) } + def_at_end(minute) { Time.new(year, month, day, hour, minute, 59, nanosecond: 999_999_999, location: location) } # Returns the midday (12:00) of the day represented by `self`. def at_midday : Time year, month, day = year_month_day_day_year - Time.new(year, month, day, 12, 0, 0, nanosecond: 0, kind: kind) + Time.new(year, month, day, 12, 0, 0, nanosecond: 0, location: location) end {% for name in DayOfWeek.constants %} @@ -682,11 +684,15 @@ struct Time @seconds end + protected def offset_seconds + @seconds + offset + end + private def year_month_day_day_year m = 1 days = DAYS_MONTH - totaldays = total_seconds / SECONDS_PER_DAY + totaldays = offset_seconds / SECONDS_PER_DAY num400 = totaldays / DAYS_PER_400_YEARS totaldays -= num400 * DAYS_PER_400_YEARS @@ -726,35 +732,20 @@ struct Time {year.to_i, month.to_i, day.to_i, day_year.to_i} end - # Returns the local time offset in minutes relative to GMT. - # - # ``` - # # Assume in Argentina, where it's GMT-3 - # Time.local_offset_in_minutes # => -180 - # ``` - def self.local_offset_in_minutes - compute_offset / SECONDS_PER_MINUTE - end - - # Returns `seconds, nanoseconds, offset` where - # `offset` is the number of seconds for now's timezone offset. - protected def self.compute_seconds_nanoseconds_and_offset - seconds, nanoseconds = compute_seconds_and_nanoseconds - offset = compute_offset(seconds) - {seconds, nanoseconds, offset} - end - - protected def self.compute_offset - seconds, nanoseconds = compute_seconds_and_nanoseconds - compute_offset(seconds) - end + protected def self.zone_offset_at(seconds, location) + unix = seconds - UNIX_SECONDS + zone, range = location.lookup_with_boundaries(unix) - private def self.compute_offset(seconds) - Crystal::System::Time.compute_utc_offset(seconds) - end + if zone.offset != 0 + case utc = unix - zone.offset + when .<(range[0]) + zone = location.lookup(range[0] - 1) + when .>=(range[1]) + zone = location.lookup(range[1]) + end + end - private def self.compute_seconds_and_nanoseconds - Crystal::System::Time.compute_utc_seconds_and_nanoseconds + zone.offset end end diff --git a/src/time/format.cr b/src/time/format.cr index ab80930fa879..7fb185ad7e4c 100644 --- a/src/time/format.cr +++ b/src/time/format.cr @@ -66,15 +66,15 @@ struct Time::Format getter pattern : String # Creates a new `Time::Format` with the given *pattern*. The given time - # *kind* will be used when parsing a `Time` and no time zone is found in it. - def initialize(@pattern : String, @kind = Time::Kind::Unspecified) + # *location* will be used when parsing a `Time` and no time zone is found in it. + def initialize(@pattern : String, @location : Location? = nil) end # Parses a string into a `Time`. - def parse(string, kind = @kind) : Time + def parse(string, location = @location) : Time parser = Parser.new(string) parser.visit(pattern) - parser.time(kind) + parser.time(location) end # Turns a `Time` into a `String`. diff --git a/src/time/format/formatter.cr b/src/time/format/formatter.cr index 9f8c5dc50728..7ecb8b208591 100644 --- a/src/time/format/formatter.cr +++ b/src/time/format/formatter.cr @@ -147,51 +147,51 @@ struct Time::Format io << time.epoch end - def time_zone - case time.kind - when Time::Kind::Utc, Time::Kind::Unspecified - io << "+0000" - when Time::Kind::Local - negative, hours, minutes = local_time_zone_info - io << (negative ? "-" : "+") - io << "0" if hours < 10 - io << hours - io << "0" if minutes < 10 - io << minutes + def time_zone(with_seconds = false) + negative, hours, minutes, seconds = local_time_zone_info + io << (negative ? '-' : '+') + io << '0' if hours < 10 + io << hours + io << '0' if minutes < 10 + io << minutes + if with_seconds + io << '0' if seconds < 10 + io << seconds end end - def time_zone_colon - case time.kind - when Time::Kind::Utc, Time::Kind::Unspecified - io << "+00:00" - when Time::Kind::Local - negative, hours, minutes = local_time_zone_info - io << (negative ? "-" : "+") - io << "0" if hours < 10 - io << hours - io << ":" - io << "0" if minutes < 10 - io << minutes + def time_zone_colon(with_seconds = false) + negative, hours, minutes, seconds = local_time_zone_info + io << (negative ? '-' : '+') + io << '0' if hours < 10 + io << hours + io << ':' + io << '0' if minutes < 10 + io << minutes + if with_seconds + io << ':' + io << '0' if seconds < 10 + io << seconds end end def time_zone_colon_with_seconds - time_zone_colon - io << ":00" + time_zone_colon(with_seconds: true) end def local_time_zone_info - minutes = Time.local_offset_in_minutes - if minutes < 0 - minutes = -minutes + offset = time.offset + if offset < 0 + offset = -offset negative = true else negative = false end + seconds = offset % 60 + minutes = offset / 60 hours = minutes / 60 minutes = minutes % 60 - {negative, hours, minutes} + {negative, hours, minutes, seconds} end def char(char) diff --git a/src/time/format/parser.cr b/src/time/format/parser.cr index e1d7cde04c8a..6b23fd1a60e4 100644 --- a/src/time/format/parser.cr +++ b/src/time/format/parser.cr @@ -4,6 +4,7 @@ struct Time::Format include Pattern @epoch : Int64? + @location : Location? def initialize(string) @reader = Char::Reader.new(string) @@ -17,26 +18,19 @@ struct Time::Format @pm = false end - def time(kind = Time::Kind::Unspecified) + def time(location : Location? = nil) @hour += 12 if @pm - time_kind = @kind || kind - if epoch = @epoch return Time.epoch(epoch) end - time = Time.new @year, @month, @day, @hour, @minute, @second, nanosecond: @nanosecond, kind: time_kind - - if offset_in_minutes = @offset_in_minutes - time -= offset_in_minutes.minutes if offset_in_minutes != 0 - - if (offset_in_minutes != 0) || (kind == Time::Kind::Local && !time.local?) - time = time.to_local - end + location = @location || location + if location.nil? + raise "Time format did not include time zone and no default location provided" end - time + Time.new @year, @month, @day, @hour, @minute, @second, nanosecond: @nanosecond, location: location end def year @@ -232,16 +226,14 @@ struct Time::Format @epoch = consume_number_i64(19) * (epoch_negative ? -1 : 1) end - def time_zone + def time_zone(with_seconds = false) case char = current_char when 'Z' - @offset_in_minutes = 0 - @kind = Time::Kind::Utc + @location = Location::UTC next_char when 'U' if next_char == 'T' && next_char == 'C' - @offset_in_minutes = 0 - @kind = Time::Kind::Utc + @location = Location::UTC next_char else raise "Invalid timezone" @@ -255,7 +247,7 @@ struct Time::Format char = next_char raise "Invalid timezone" unless char.ascii_number? - hours = 10*hours + char.to_i + hours = 10 * hours + char.to_i char = next_char char = next_char if char == ':' @@ -264,10 +256,22 @@ struct Time::Format char = next_char raise "Invalid timezone" unless char.ascii_number? - minutes = 10*minutes + char.to_i + minutes = 10 * minutes + char.to_i + + if with_seconds + char = next_char + char = next_char if char == ':' + raise "Invalid timezone" unless char.ascii_number? + seconds = char.to_i - @offset_in_minutes = sign * (60*hours + minutes) - @kind = Time::Kind::Utc + char = next_char + raise "Invalid timezone" unless char.ascii_number? + seconds = 10 * seconds + char.to_i + else + seconds = 0 + end + + @location = Location.fixed(sign * (3600 * hours + 60 * minutes + seconds)) char = next_char if @reader.has_next? @@ -288,7 +292,7 @@ struct Time::Format end def time_zone_colon_with_seconds - time_zone + time_zone(with_seconds: true) end def char(char) diff --git a/src/time/location.cr b/src/time/location.cr new file mode 100644 index 000000000000..f9237235dfc9 --- /dev/null +++ b/src/time/location.cr @@ -0,0 +1,295 @@ +require "./location/loader" + +# `Location` represents a specific time zone. +# +# It can be either a time zone from the IANA Time Zone database, +# a fixed offset, or `UTC`. +# +# Creating a location from timezone data: +# ``` +# location = Time::Location.load("Europe/Berlin") +# ``` +# +# Initializing a `Time` instance with specified `Location`: +# +# ``` +# time = Time.new(2016, 2, 15, 21, 1, 10, location) +# ``` +# +# Alternatively, you can switch the `Location` for any `Time` instance: +# +# ``` +# time.location # => Europe/Berlin +# time.in(Time::Location.load("Asia/Jerusalem")) +# time.location # => Asia/Jerusalem +# ``` +# +# There are also a few special conversions: +# ``` +# time.to_utc # == time.in(Location::UTC) +# time.to_local # == time.in(Location.local) +# ``` +class Time::Location + 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 + + class InvalidTimezoneOffsetError < Exception + def initialize(offset : Int) + super "Invalid time zone offset: #{offset}" + end + end + + struct Zone + UTC = new "UTC", 0, false + + getter name : String + getter offset : Int32 + getter? dst : Bool + + def initialize(@name : String, @offset : Int32, @dst : Bool) + # Maximium offets of IANA timezone 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 + + def inspect(io : IO) + io << "Time::Zone<" + io << offset + io << ", " << name + io << " (DST)" if dst? + io << '>' + end + end + + # :nodoc: + record ZoneTransition, when : Int64, index : UInt8, standard : Bool, utc : Bool do + getter? standard, utc + + def inspect(io : IO) + io << "Time::ZoneTransition<" + io << '#' << index << ", " + Time.epoch(self.when).to_s("%F %T", io) + io << ", STD" if standard? + io << ", UTC" if utc? + io << '>' + end + end + + # Describes the Coordinated Universal Time (UTC). + UTC = new "UTC", [Zone::UTC] + + property name : String + property 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*. + def self.fixed(name : String, offset : Int32) + new name, [Zone.new(name, offset, false)] + end + + # Creates a `Location` instance with fixed *offset*. + def self.fixed(offset : Int32) + span = offset.abs.seconds + name = sprintf("%s%02d:%02d", offset.sign < 0 ? '-' : '+', span.hours, span.minutes) + fixed name, offset + end + + # Returns the `Location` with the given name. + # + # This uses a list of paths to look for timezone data. Each path can + # either point to a directory or an uncompressed ZIP file. + # System-specific default paths are provided by the implementation. + # + # The first timezone data matching the given name that is successfully loaded + # and parsed is returned. + # A custom lookup path can be set as environment variable `ZONEINFO`. + # + # Special names: + # * `"UTC"` and empty string `""` return `Location::UTC` + # * `"Local"` returns `Location.local` + # + # This method caches files based on the modification time, so subsequent loads + # of the same location name will return the same instance of `Location` unless + # the timezone database has been updated in between. + # + # Example: + # `ZONEINFO=/path/to/zoneinfo.zip crystal eval 'pp Location.load("Custom/Location")'` + 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 local time zone. + # + # The value is loaded on first access based on the current application environment (see `.load_local` for details). + class_property(local : Location) { load_local } + + # Loads the local location described by the the current application environment. + # + # It consults the environment variable `ENV["TZ"]` to find the time zone to use. + # * `"UTC"` and empty string `""` return `Location::UTC` + # * `"Foo/Bar"` tries to load the zoneinfo from known system locations - such as `/usr/share/zoneinfo/Foo/Bar`, + # `/usr/share/lib/zoneinfo/Foo/Bar` or `/usr/lib/locale/TZ/Foo/Bar` on unix-based operating systems. + # See `Location.load` for details. + # * If `ENV["TZ"]` is not set, the system's local timezone data will be used (`/etc/localtime` on unix-based systems). + # * If no time zone data could be found, `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 + + def to_s(io : IO) + io << name + end + + def inspect(io : IO) + io << "Time::Location<" + to_s(io) + io << '>' + end + + def_equals_and_hash name, zones, transitions + + # Returns the time zone in use at `time`. + def lookup(time : Time) : Zone + lookup(time.epoch) + end + + # Returns the time zone in use at `epoch` (time in seconds since UNIX epoch). + 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) + 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 + 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 `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 diff --git a/src/time/location/loader.cr b/src/time/location/loader.cr new file mode 100644 index 000000000000..4ebb0e8048f2 --- /dev/null +++ b/src/time/location/loader.cr @@ -0,0 +1,236 @@ +class Time::Location + @@location_cache = {} of String => NamedTuple(time: Time, location: Location) + + class InvalidTZDataError < Exception + def self.initialize(message : String? = "Malformed time zone information", cause : Exception? = nil) + super(message, cause) + end + end + + # :nodoc: + def self.load?(name : String, sources : Enumerable(String)) + if source = find_zoneinfo_file(name, sources) + load_from_dir_or_zip(name, source) + end + end + + # :nodoc: + def self.load(name : String, sources : Enumerable(String)) + if source = find_zoneinfo_file(name, sources) + load_from_dir_or_zip(name, source) || raise InvalidLocationNameError.new(name, source) + end + end + + # :nodoc: + def self.load_from_dir_or_zip(name : String, source : String) + {% if flag?(:win32) %} + raise NotImplementedError.new("Time::Location.load_from_dir_or_zip") + {% else %} + if source.ends_with?(".zip") + open_file_cached(name, source) do |file| + read_zip_file(name, file) do |io| + read_zoneinfo(name, io) + end + end + else + path = File.expand_path(name, source) + open_file_cached(name, path) do |file| + read_zoneinfo(name, file) + end + end + {% end %} + end + + private def self.open_file_cached(name : String, path : String) + return nil unless File.exists?(path) + + mtime = File.stat(path).mtime + if (cache = @@location_cache[name]?) && cache[:time] == mtime + return cache[:location] + else + File.open(path) do |file| + location = yield file + if location + @@location_cache[name] = {time: mtime, location: location} + + return location + end + end + end + end + + # :nodoc: + def self.find_zoneinfo_file(name : String, sources : Enumerable(String)) + {% if flag?(:win32) %} + raise NotImplementedError.new("Time::Location.find_zoneinfo_file") + {% else %} + sources.each do |source| + if source.ends_with?(".zip") + return source if File.exists?(source) + else + path = File.expand_path(name, source) + return source if File.exists?(path) + end + end + {% end %} + end + + # Parse "zoneinfo" time zone file. + # This is the standard file format used by most operating systems. + # See https://data.iana.org/time-zones/tz-link.html, https://github.com/eggert/tz, tzfile(5) + + # :nodoc: + def self.read_zoneinfo(location_name : String, io : IO) + raise InvalidTZDataError.new unless io.read_string(4) == "TZif" + + # 1-byte version, then 15 bytes of padding + version = io.read_byte + raise InvalidTZDataError.new unless {0_u8, '2'.ord, '3'.ord}.includes?(version) + io.skip(15) + + # six big-endian 32-bit integers: + # number of UTC/local indicators + # number of standard/wall indicators + # number of leap seconds + # number of transition times + # number of local time zones + # number of characters of time zone abbrev strings + + num_utc_local = read_int32(io) + num_std_wall = read_int32(io) + num_leap_seconds = read_int32(io) + num_transitions = read_int32(io) + num_local_time_zones = read_int32(io) + abbrev_length = read_int32(io) + + transitionsdata = read_buffer(io, num_transitions * 4) + + # Time zone indices for transition times. + transition_indexes = Bytes.new(num_transitions) + io.read_fully(transition_indexes) + + zonedata = read_buffer(io, num_local_time_zones * 6) + + abbreviations = read_buffer(io, abbrev_length) + + leap_second_time_pairs = Bytes.new(num_leap_seconds) + io.read_fully(leap_second_time_pairs) + + isstddata = Bytes.new(num_std_wall) + io.read_fully(isstddata) + + isutcdata = Bytes.new(num_utc_local) + io.read_fully(isutcdata) + + # If version == 2 or 3, the entire file repeats, this time using + # 8-byte ints for txtimes and leap seconds. + # We won't need those until 2106. + + zones = Array(Zone).new(num_local_time_zones) do + offset = read_int32(zonedata) + is_dst = zonedata.read_byte != 0_u8 + name_idx = zonedata.read_byte + raise InvalidTZDataError.new unless name_idx && name_idx < abbreviations.size + abbreviations.pos = name_idx + name = abbreviations.gets(Char::ZERO, chomp: true) + raise InvalidTZDataError.new unless name + Zone.new(name, offset, is_dst) + end + + transitions = Array(ZoneTransition).new(num_transitions) do |transition_id| + time = read_int32(transitionsdata).to_i64 + zone_idx = transition_indexes[transition_id] + raise InvalidTZDataError.new unless zone_idx < zones.size + + isstd = !{nil, 0_u8}.includes? isstddata[transition_id]? + isutc = !{nil, 0_u8}.includes? isstddata[transition_id]? + + ZoneTransition.new(time, zone_idx, isstd, isutc) + end + + new(location_name, zones, transitions) + end + + private def self.read_int32(io : IO) + io.read_bytes(Int32, IO::ByteFormat::BigEndian) + end + + private def self.read_buffer(io : IO, size : Int) + buffer = Bytes.new(size) + io.read_fully(buffer) + IO::Memory.new(buffer) + end + + # :nodoc: + CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50 + # :nodoc: + END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x06054b50 + # :nodoc: + ZIP_TAIL_SIZE = 22 + # :nodoc: + LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50 + # :nodoc: + COMPRESSION_METHOD_UNCOMPRESSED = 0_i16 + + # This method loads an entry from an uncompressed zip file. + # See http://www.onicos.com/staff/iz/formats/zip.html for ZIP format layout + private def self.read_zip_file(name : String, file : IO::FileDescriptor) + file.seek -ZIP_TAIL_SIZE, IO::Seek::End + + if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE + raise InvalidTZDataError.new("corrupt zip file") + end + + file.skip 6 + num_entries = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + file.skip 4 + + file.pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian) + + num_entries.times do + break if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != CENTRAL_DIRECTORY_HEADER_SIGNATURE + + file.skip 6 + compression_method = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + file.skip 12 + uncompressed_size = file.read_bytes(Int32, IO::ByteFormat::LittleEndian) + filename_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + file_comment_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + file.skip 8 + local_file_header_pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian) + filename = file.read_string(filename_length) + + unless filename == name + file.skip extra_field_length + file_comment_length + next + end + + unless compression_method == COMPRESSION_METHOD_UNCOMPRESSED + raise InvalidTZDataError.new("Unsupported compression for #{name}") + end + + file.pos = local_file_header_pos + + unless file.read_bytes(Int32, IO::ByteFormat::LittleEndian) == LOCAL_FILE_HEADER_SIGNATURE + raise InvalidTZDataError.new("Invalid Zip file") + end + file.skip 4 + unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == COMPRESSION_METHOD_UNCOMPRESSED + raise InvalidTZDataError.new("Invalid Zip file") + end + file.skip 16 + unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == filename_length + raise InvalidTZDataError.new("Invalid Zip file") + end + extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian) + unless file.gets(filename_length) == name + raise InvalidTZDataError.new("Invalid Zip file") + end + + file.skip extra_field_length + + return yield file + end + end +end diff --git a/src/yaml/from_yaml.cr b/src/yaml/from_yaml.cr index b415d72fe219..195f93b3389f 100644 --- a/src/yaml/from_yaml.cr +++ b/src/yaml/from_yaml.cr @@ -213,7 +213,7 @@ struct Time::Format node.raise "Expected scalar, not #{node.class}" end - parse(node.value) + parse(node.value, Time::Location::UTC) end end diff --git a/src/yaml/to_yaml.cr b/src/yaml/to_yaml.cr index 7ca4c98f4ee7..edeccd8c447b 100644 --- a/src/yaml/to_yaml.cr +++ b/src/yaml/to_yaml.cr @@ -108,7 +108,8 @@ end struct Time def to_yaml(yaml : YAML::Nodes::Builder) - if kind.utc? || kind.unspecified? + case + when utc? if hour == 0 && minute == 0 && second == 0 && millisecond == 0 yaml.scalar Time::Format.new("%F").format(self) elsif millisecond == 0 @@ -116,7 +117,7 @@ struct Time else yaml.scalar Time::Format.new("%F %X.%L").format(self) end - elsif millisecond == 0 + when millisecond == 0 yaml.scalar Time::Format.new("%F %X %:z").format(self) else yaml.scalar Time::Format.new("%F %X.%L %:z").format(self)