Skip to content

Commit

Permalink
Showing 3 changed files with 663 additions and 198 deletions.
640 changes: 499 additions & 141 deletions src/time.cr
Original file line number Diff line number Diff line change
@@ -1,66 +1,207 @@
require "crystal/system/time"

# `Time` represents an instance in incremental time. Here are some examples:
# `Time` represents a date-time instant in
# [incremental time](https://www.w3.org/International/articles/definitions-time/#incremental_time)
# observed in a specific time zone.
#
# ### Basic Usage
# The calendaric calculations are based on the rules of the proleptic Gregorian
# calendar as specified in [ISO 8601](http://xml.coverpages.org/ISO-FDIS-8601.pdf).
# Leap seconds are ignored.
#
# Internally, the time is stored as an `Int64` representing seconds from epoch
# (`0001-01-01 00:00:00.0 UTC`) and an `Int32` representing
# nanosecond-of-second with value range `0..999_999_999`.
#
# The supported date range is `0001-01-01 00:00:00.0` to
# `9999-12-31 23:59:59.999_999_999` in any local time zone.
#
# ### Telling the Time
#
# There are several methods to retrieve a `Time` instance representing the
# current time:
#
# ```crystal
# Time.utc_now # returns the current time in UTC
# Time.now Time::Location.load("Europe/Berlin") # returns the current time in time zone Europe/Berlin
# Time.now # returns the current time in current time zone
# ```
# time = Time.new(2016, 2, 15, 10, 20, 30)
#
# time.year # => 2016
# time.month # => 2
# time.day # => 15
# time.hour # => 10
# time.minute # => 20
# time.second # => 30
# time.monday? # => true
# It is generally recommended to keep instances in UTC and only apply a
# local time zone when formatting for user display, unless the domain logic
# requires having a specific time zone (for example for calendaric operations).
#
# ### Creating a Specific Instant
#
# `Time` instances representing a specific instant can be created by
# `.utc` or `.new` with the date-time specified as individual arguments:
#
# ```
# time = Time.utc(2016, 2, 15, 10, 20, 30)
# time.to_s # => 2016-02-15 10:20:30 UTC
# time = Time.new(2016, 2, 15, 10, 20, 30, location: Time::Location.load("Europe/Berlin"))
# time.to_s # => 2016-02-15 10:20:30 +01:00 Europe/Berlin
# The time-of-day can be omitted and defaults to midnight (start of day):
# time = Time.utc(2016, 2, 15)
# time.to_s # => 2016-02-15 00:00:00 UTC
# ```
#
# ### Retrieving Time Information
#
# Each `Time` instance allows querying calendar data:
#
# # 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
# ```
# time = Time.utc(2016, 2, 15, 10, 20, 30)
# time.year # => 2016
# time.month # => 2
# time.day # => 15
# time.hour # => 10
# time.minute # => 20
# time.second # => 30
# time.millisecond # => 0
# time.nanosecond # => 0
# time.day_of_week # => Monday
# time.day_of_year # => 46
# time.monday? # => true
# time.time_of_day # => 15:10:30
# ```
#
# ### Time Zones
#
# # Specifying a time
# Time.new(2016, 2, 15, 10, 20, 30) # => 2016-02-15 10:20:30 +01:00
# Each time is attached to a specific time zone, represented by a `Location`
# (see `#location`).
# `#zone` returns the time zone observed in this location at the current time
# (i.e. the instant represented by this `Time`).
# `#offset` returns the offset of the current zone in seconds.
#
# # Creating a time instance in UTC
# Time.utc(2016, 2, 15, 10, 20, 30) # => 2016-02-15 10:20:30 UTC
# ```
# time = Time.new(2018, 3, 8, 22, 5, 13, location: Time::Location.load("Europe/Berlin"))
# time # => 2018-03-08 22:05:13 +01:00 Europe/Berlin
# time.location # => #<Time::Location Europe/Berlin>
# time.zone # => #<Time::Location::Zone CET +01:00 (3600s) STD>
# time.offset # => 3600
# ```
#
# ### Formatting Time
# Using `.utc`, the location is `Time::Location::UTC`:
#
# The `to_s` method returns a `String` value in the assigned format.
# ```
# time = Time.utc(2018, 3, 8, 22, 5, 13)
# time # => 2016-02-15 10:20:30 UTC
# time.location # => #<Time::Location UTC>
# time.zone # => #<Time::Location::Zone UTC +00:00 (0s) STD>
# time.offset # => 0
# ```
#
# A `Time` instance can be transformed to a different time zone while retaining
# the same instant using `#in`:
#
# ```
# time = Time.new(2015, 10, 12)
# time.to_s("%Y-%m-%d") # => "2015-10-12"
# time_de = Time.new(2018, 3, 8, 22, 5, 13, location: Time::Location.load("Europe/Berlin"))
# time_ar = time_de.in Time::Location.load("America/Buenos_Aires")
# time_de # => 2018-03-08 22:05:13 +01:00 Europe/Berlin
# time_ar # => 2018-03-08 18:05:13 -03:00 America/Buenos_Aires
# ```
#
# See `Time::Format` for all the directives.
# Both `Time` instances show a different local date-time, but they represent
# the same date-time in the instant time-line, therefore they are considered
# equal:
#
# ### Calculation
# ```
# time_de.to_utc # => 2018-03-08 21:05:13 UTC
# time_ar.to_utc # => 2018-03-08 21:05:13 UTC
# time_de == time_ar # => true
# ```
#
# There are also two special methods for converting to UTC and local time zone:
#
# ```
# time.to_utc # equals time.in(Location::UTC)
# time.to_local # equals time.in(Location.local)
# ```
# 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)
# ### Formatting and Parsing Time
#
# To make date-time instances exchangeable between different computer systems
# or readable to humans, they are usually converted to and from a string
# representation.
#
# The method `#to_s` formats the date-time according to a specified pattern.
#
# ```
# time = Time.utc(2015, 10, 12, 10, 30, 00)
# time.to_s("%Y-%m-%d %H:%m:%s %Z") # => "2015-10-12 10:30:00 +00:00"
# ```
#
# Similarly, `Time.parse` is used to construct a `Time` instance from date-time
# information in a string, according to a specified pattern:
#
# ```
# Time.parse("2015-10-12 10:30:00 +00:00", "%Y-%m-%d %H:%m:%s %Z")
# ```
#
# See `Time::Format` for all directives.
#
# ### Calculations
#
# ```
# Time.utc(2015, 10, 10) - 5.days # => 2015-10-05 00:00:00 +00:00
#
# span = Time.utc(2015, 10, 10) - Time.utc(2015, 9, 10)
# span.days # => 30
# span.total_hours # => 720
# span.total_minutes # => 43200
# ```
#
# ## Measuring Time
#
# The typical time representation provided by the operating system is based on
# a "wall clock" which is subject to changes for clock synchronization.
# This can result in discontinuous jumps in the time-line making it not
# suitable for accurately measuring elapsed time.
#
# Instances of `Time` are focused on telling time – using a "wall clock".
# When `Time.now` is called multiple times, the difference between the
# returned instances is not guranteed to equal to the time elapsed between
# making the calls; even the order of the returned `Time` instances might
# not reflect invocation order.
#
# ```
# t1 = Time.utc_now
# # operation that takes 1 minute
# t2 = Time.utc_now
# t2 - t1 # => ?
# ```
#
# # Calculation between Time::Span instances
# span_a = Time::Span.new(3, 0, 0)
# span_b = Time::Span.new(2, 0, 0)
# span = span_a - span_b
# span # => 01:00:00
# span.class # => Time::Span
# span.hours # => 1
# The resulting `Time::Span` could be anything, even negative, if the
# computer's wall clock has changed between both calls.
#
# As an alternative, the operating system also provides a monotonic clock.
# It's time-line has no specfied starting point but is strictly linearly
# increasing.
#
# This monotonic clock should always be used for measuring elapsed time.
#
# A reading from this clock can be taken using `.monotonic`:
#
# ```
# t1 = Time.monotonic
# # operation that takes 1 minute
# t2 = Time.monotonic
# t2 - t1 # => 1.minute (approximately)
# ```
#
# The execution time of a block can be measured using `.measure`:
#
# ```
# elapsed_time = Time.measure do
# # operation that takes 20 milliseconds
# end
# elapsed_time # => 20.milliseconds (approximately)
# ```
struct Time
class FloatingTimeConversionError < Exception
end

include Comparable(self)
include Comparable(Time)

# :nodoc:
DAYS_MONTH = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
@@ -110,7 +251,7 @@ struct Time
# :nodoc:
MAX_SECONDS = 315537897599_i64

# `DayOfWeek` represents the day.
# `DayOfWeek` represents a day-of-week in the Gregorian calendar.
#
# ```
# time = Time.new(2016, 2, 15)
@@ -135,34 +276,113 @@ struct Time

@seconds : Int64
@nanoseconds : Int32
@location : Location

# Returns a clock from an unspecified starting point, but strictly linearly
# increasing. This clock should be independent from discontinuous jumps in the
# Returns `Location` representing the time-zone observed by this `Time`.
getter location : Location

# Returns a reading from the monotonic clock to measure elapsed time.
#
# Values from the monotonic clock and wall clock are not comparable.
# This method does not return a `Time` instance but a `Time::Span` amounting
# to the number of nanoseconds elapsed since the unspecified starting point
# of the monotonic clock.
# The returned values are strictly linearly increasing.
#
# This clock should be independent from discontinuous jumps in the
# system time, such as leap seconds, time zone adjustments or manual changes
# to the computer's time.
# to the computer's clock.
#
# Subtracting two results from this method equals to the time elapsed between
# both readings:
#
# ```
# start = Time.monotonic
# # operation that takes 20 milliseconds
# elapsed = Time.monotonic - start # => 20.milliseconds (approximately)
# # operation that takes 50 milliseconds
# elapsed_total = Time.monotonic - start # => 70.milliseconds (approximately)
# ```
#
# For example, the monotonic clock must always be used to measure an elapsed
# time.
# The execution time of a block can be measured using `.measure`.
def self.monotonic : Time::Span
seconds, nanoseconds = Crystal::System::Time.monotonic
Time::Span.new(seconds: seconds, nanoseconds: nanoseconds)
end

# Measures how long the block took to run. Relies on `monotonic` to not be
# affected by time fluctuations.
def self.measure : Time::Span
# Measures the execution time of *block*.
#
# The measurement relies on the monotonic clock and is not
# affected by fluctuations of the system clock (see `#monotonic`).
#
# ```
# elapsed_time = Time.measure do
# # operation that takes 20 milliseconds
# end
# elapsed_time # => 20.milliseconds (approximately)
# ```
def self.measure(&block : ->) : Time::Span
start = monotonic
yield
monotonic - start
end

def self.new(location = Location.local)
# Creates a new `Time` instance representing the current time from the
# system clock observed in *location* (defaults to local time zone).
def self.new(location : Location = Location.local) : Time
seconds, nanoseconds = Crystal::System::Time.compute_utc_seconds_and_nanoseconds
new(seconds: seconds, nanoseconds: nanoseconds, location: location)
end

def self.new(year : Int32, month : Int32, day : Int32, hour = 0, minute = 0, second = 0, *, nanosecond = 0, location = Location.local)
# Creates a new `Time` instance representing the current time from the
# system clock observed in *location* (defaults to local time zone).
def self.now(location : Location = Location.local) : Time
new(location)
end

# Creates a new `Time` instance representing the current time from the
# system clock in UTC.
def self.utc_now : Time
now(Location::UTC)
end

# Creates a new `Time` instance representing the given local date-time in
# *location* (defaults to local time zone).
#
# ```
# time = Time.new(2016, 2, 15, 10, 20, 30, location: Time::Location.load("Europe/Berlin"))
# time.to_s # => 2016-02-15 10:20:30 +01:00 Europe/Berlin
# ```
#
# Valid value ranges for the individual fields:
#
# * `year`: `1..9999`
# * `month`: `1..12`
# * `day`: `1` - `28`/`29`/`30`/`31` (depending on month and year)
# * `hour`: `0..23`
# * `minute`: `0..59`
# * `second`: `0..59`
# * `nanosecond`: `0..999_999_999`
#
# The time-of-day can be omitted and defaults to midnight (start of day):
#
# ```
# time = Time.new(2016, 2, 15)
# time.to_s # => 2016-02-15 00:00:00 +00:00 Local
# ```
#
# The local date-time representation is resolved to a single instant based on
# the offset observed in the *location* at this time.
#
# This process can sometimes be ambiguous, mostly due skipping or repeating
# times at time zone transitions. For example, in `America/New_York` the
# date-time `2011-03-13 02:15:00` never occured, there is a gap between time
# zones. In return, `2011-11-06 01:15:00` occured twice because of overlapping
# time zones.
#
# In such cases, the choice of time zone, and therefore the time, is not
# well-defined. This method returns a time that is correct in one of the two
# zones involved in the transition, but it does not guarantee which.
def self.new(year : Int32, month : Int32, day : Int32, hour : Int32 = 0, minute : Int32 = 0, second : Int32 = 0, *, nanosecond : Int32 = 0, location : Location = Location.local) : Time
unless 1 <= year <= 9999 &&
1 <= month <= 12 &&
1 <= day <= Time.days_in_month(year, month) &&
@@ -187,15 +407,42 @@ struct Time
new(seconds: seconds, nanoseconds: nanosecond.to_i, location: location)
end

{% unless flag?(:win32) %}
# :nodoc:
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, location: location)
end
{% end %}
# Creates a new `Time` instance representing the given date-time in UTC.
#
# ```
# time = Time.utc(2016, 2, 15, 10, 20, 30)
# time.to_s # => 2016-02-15 10:20:30 UTC
# ```
#
# Valid value ranges for the individual fields:
#
# * `year`: `1..9999`
# * `month`: `1..12`
# * `day`: `1` - `28`/`29`/`30`/`31` (depending on month and year)
# * `hour`: `0..23`
# * `minute`: `0..59`
# * `second`: `0..59`
# * `nanosecond`: `0..999_999_999`
#
# The time-of-day can be omitted and defaults to midnight (start of day):
#
# ```
# time = Time.utc(2016, 2, 15)
# time.to_s # => 2016-02-15 00:00:00 UTC
# ```
#
# Since UTC does not have any time zone transitions, each date-time is
# unambiguously resolved.
def self.utc(year : Int32, month : Int32, day : Int32, hour : Int32 = 0, minute : Int32 = 0, second : Int32 = 0, *, nanosecond : Int32 = 0) : Time
new(year, month, day, hour, minute, second, nanosecond: nanosecond, location: Location::UTC)
end

# Creates a new `Time` instance that corresponds to the number of *seconds*
# and *nanoseconds* elapsed from epoch (`0001-01-01 00:00:00.0 UTC`)
# observed in *location*.
#
# Valid range for *seconds* is `0..315_537_897_599`.
# For *nanoseconds* it is `0..999_999_999`.
def initialize(*, @seconds : Int64, @nanoseconds : Int32, @location : Location)
unless 0 <= offset_seconds <= MAX_SECONDS
raise ArgumentError.new "Invalid time: seconds out of range"
@@ -206,8 +453,29 @@ struct Time
end
end

# Returns a new `Time` instance that corresponds to the number
# seconds elapsed since the unix epoch (00:00:00 UTC on 1 January 1970).
# Creates a new `Time` instance that corresponds to the number of *seconds*
# and *nanoseconds* elapsed from epoch (`0001-01-01 00:00:00.0 UTC`)
# in UTC.
#
# Valid range for *seconds* is `0..315_537_897_599`.
# For *nanoseconds* it is `0..999_999_999`.
def self.utc(*, seconds : Int64, nanoseconds : Int32) : Time
new(seconds: seconds, nanoseconds: nanoseconds, location: Location::UTC)
end

{% unless flag?(:win32) %}
# :nodoc:
def self.new(time : LibC::Timespec, location : Location = Location.local)
seconds = UNIX_SECONDS + time.tv_sec
nanoseconds = time.tv_nsec.to_i
new(seconds: seconds, nanoseconds: nanoseconds, location: location)
end
{% end %}

# Creates a new `Time` instance that corresponds to the number of
# *seconds* elapsed since the Unix epoch (`1970-01-01 00:00:00 UTC`).
#
# The time zone is always UTC.
#
# ```
# Time.epoch(981173106) # => 2001-02-03 04:05:06 UTC
@@ -216,8 +484,10 @@ struct Time
utc(seconds: UNIX_SECONDS + seconds, nanoseconds: 0)
end

# Returns a new `Time` instance that corresponds to the number
# milliseconds elapsed since the unix epoch (00:00:00 UTC on 1 January 1970).
# Creates a new `Time` instance that corresponds to the number of
# *milliseconds* elapsed since the Unix epoch (`1970-01-01 00:00:00 UTC`).
#
# The time zone is always UTC.
#
# ```
# time = Time.epoch_ms(981173106789) # => 2001-02-03 04:05:06.789 UTC
@@ -230,38 +500,58 @@ struct Time
utc(seconds: seconds, nanoseconds: nanoseconds.to_i)
end

# Returns a new `Time` instance at the specified time in UTC time zone.
def self.utc(year : Int32, month : Int32, day : Int32, hour = 0, minute = 0, second = 0, *, nanosecond = 0) : Time
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, location: Location::UTC)
end

def clone : self
def clone : Time
self
end

# Returns a `Time` that is later than this `Time` by the `Time::Span` *span*.
# Returns a copy of this `Time` with *span* added.
#
# See `#add_span` for details.
def +(span : Time::Span) : Time
add_span span.to_i, span.nanoseconds
end

# Returns a `Time` that is earlier than this `Time` by the `Time::Span` *span*.
# Returns a copy of this `Time` with *span* subtracted.
#
# See `#add_span` for details.
def -(span : Time::Span) : Time
add_span -span.to_i, -span.nanoseconds
end

# Adds a number of months specified by *other*'s value.
def +(other : Time::MonthSpan) : Time
add_months other.value
# Returns a copy of this `Time` with *span* added.
#
# It adds the number of months with overflow increasing the year.
# If the resulting day-of-month would be invalid, it is adjusted to the last
# valid day of the moneth.
#
# For example, adding `1.month` to `2007-03-31` would result in the invalid
# date `2007-04-31` which will be adjusted to `2007-04-30`.
#
# This operates on the local time-line, such that the local date-time
# represenations of month and year are increased by the specified amount.
#
# If the resulting date-time is ambiguous due to time zone transitions,
# a correct time will be returned, but it does not guarantee which.
def +(span : Time::MonthSpan) : Time
add_months span.value
end

# Subtracts a number of months specified by *other*'s value.
def -(other : Time::MonthSpan) : Time
add_months -other.value
# Returns a copy of this `Time` with *span* subtracted.
#
# It adds the number of months with overflow decreasing the year.
# If the resulting day-of-month would be invalid, it is adjusted to the last
# valid day of the moneth.
#
# For example, subtracting `1.month` from `2007-05-31` would result in the invalid
# date `2007-04-31` which will be adjusted to `2007-04-30`.
#
# This operates on the local time-line, such that the local date-time
# represenations of month and year are decreased by the specified amount.
#
# If the resulting date-time is ambiguous due to time zone transitions,
# a correct time will be returned, but it does not guarantee which.
def -(span : Time::MonthSpan) : Time
add_months -span.value
end

private def add_months(months)
@@ -286,9 +576,21 @@ struct Time
temp + time_of_day
end

# Returns a `Time` that is this number of *seconds* and *nanoseconds* later.
# Returns a copy of this `Time` with the number of *seconds* and
# *nanoseconds* added.
#
# Negative values are subtracted, meaning an earlier point in time.
# Positive values result in a later time, negative values in an earlier time.
#
# This operates on the instant time-line, such that adding the eqivalent of
# one hour will always be a duration of one hour later.
# The local date-time representation may change by a different amount,
# depending on time zone transitions.
#
# Overflow in *nanoseconds* will be transferred to *seconds*.
#
# There is no explicit limit on the input values but the addition must result
# in a valid time between `0001-01-01 00:00:00.0` and
# `9999-12-31 23:59:59.999_999_999`. Otherwise `ArgumentError` is raised.
def add_span(seconds : Int, nanoseconds : Int) : Time
if seconds == 0 && nanoseconds == 0
return self
@@ -310,43 +612,41 @@ struct Time
Time.new(seconds: seconds, nanoseconds: nanoseconds.to_i, location: location)
end

# Returns the amount of time between *other* and `self`.
# Returns a `Time::Span` amounting to the duration between *other* and `self`.
#
# The amount can be negative if `self` is a `Time` that happens before *other*.
# The time span is negative if `self` is before *other*.
#
# The duration amounts to the actual time elapsed between both instances, on
# the instant time-line.
# The difference between local date-time representations may equal to a
# different duration, depending on time zone transitions.
def -(other : Time) : Time::Span
Span.new(
seconds: total_seconds - other.total_seconds,
nanoseconds: nanosecond - other.nanosecond,
)
end

# 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
now(Location::UTC)
end

# Returns a copy of `self` with time-of-day components (hour, minute, ...) set to zero.
# Returns a copy of `self` with time-of-day components (hour, minute, second,
# nanoseconds) set to zero.
#
# This equals `at_beginning_of_day` or
# `Time.new(year, month, day, 0, 0, 0, nanoseconds: 0, location: location)`.
def date : Time
Time.new(year, month, day, location: location)
end

# Returns the year number (in the Common Era).
# Returns the year of the proleptic Georgian Calendar (`0..9999`).
def year : Int32
year_month_day_day_year[0]
end

# Returns the month number of the year (`1..12`).
# Returns the month of the year (`1..12`).
def month : Int32
year_month_day_day_year[1]
end

# Returns the day number of the month (`1..31`).
# Returns the day of the month (`1..31`).
def day : Int32
year_month_day_day_year[2]
end
@@ -376,7 +676,13 @@ struct Time
@nanoseconds
end

# Returns how much time has passed since midnight of this day.
# Returns the duration between this `Time` and midnight of the same day.
#
# This is equivalent to creating a `Time::Span` from the time-of-day fields:
#
# ```
# time.time_of_day == Time::Span.new(time.hour, time.minute, time.second, time.nanosecond)
# ``
def time_of_day : Time::Span
Span.new(nanoseconds: NANOSECONDS_PER_SECOND * (offset_seconds % SECONDS_PER_DAY) + nanosecond)
end
@@ -387,22 +693,20 @@ struct Time
DayOfWeek.new value.to_i
end

# Returns the day number of the year (`1..365`, or `1..366` on leap years).
# Returns the day of the year.
#
# The value range is `1..365` in normal yars and `1..366` in leap years.
def day_of_year : Int32
year_month_day_day_year[3]
end

# 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
# Returns the time zone in effect in `location` at this instant.
def zone : Time::Location::Zone
location.lookup(self)
end

# Returns the offset from UTC (in seconds) in `location` at this point in time.
# Returns the offset from UTC (in seconds) in effect in `location` at
# this instant.
def offset : Int32
zone.offset
end
@@ -412,28 +716,53 @@ struct Time
location.utc?
end

# Returns `true` if this time's `#location` equals to the current
# local location as returned by `Location.local`.
# Returns `true` if `#location` equals to the local time zone
# (`Time::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
location.local?
end

def <=>(other : self)
# Compares this `Time` with *other*.
#
# The comparison is based on the instant time-line, even if the local
# date-time representation (wall clock) would compare differently.
#
# To ensure the comparison is also true for local wall clock, both date-times
# need to be transforemd to the same time zone.
def <=>(other : Time) : Int32
cmp = total_seconds <=> other.total_seconds
cmp = nanosecond <=> other.nanosecond if cmp == 0
cmp
end

def ==(other : self)
# Compares this `Time` with *other* for equality.
#
# Two instances are considered equal if they represent the same date-time in
# the instant time-line, even if they show a different local date-time.
#
# ```
# time_de = Time.new(2018, 3, 8, 22, 5, 13, location: Time::Location.load("Europe/Berlin"))
# time_ar = Time.new(2018, 3, 8, 18, 5, 13, location: Time::Location.load("America/Buenos_Aires"))
# time_de == time_ar # => true
#
# # both times represent the same instant:
# time_de.to_utc # => 2018-03-08 21:05:13 UTC
# time_ar.to_utc # => 2018-03-08 21:05:13 UTC
# ```
def ==(other : Time) : Bool
total_seconds == other.total_seconds && nanosecond == other.nanosecond
end

def_hash total_seconds, nanosecond

# Returns how many days this *month* (`1..12`) of this *year* has (28, 29, 30 or 31).
# Returns the number of days in *month* (value range: `1..12`) taking account
# of the *year*.
#
# The returned value is either `28`, `29`, `30` or `31` depending on the
# month and whether *year* is leap.
#
# ```
# Time.days_in_month(2016, 2) # => 29
@@ -452,7 +781,9 @@ struct Time
days[month]
end

# Returns number of days in *year*.
# Returns the number of days in *year*.
#
# A normal year has `365` days, a leap year `366` days.
#
# ```
# Time.days_in_year(1990) # => 365
@@ -462,7 +793,8 @@ struct Time
leap_year?(year) ? 366 : 365
end

# Returns whether this *year* is leap (February has one more day).
# Returns `true` if *year* is a leap year in the proleptic Gregorian
# calendar.
def self.leap_year?(year : Int) : Bool
unless 1 <= year <= 9999
raise ArgumentError.new "Invalid year"
@@ -516,7 +848,9 @@ struct Time
end
end

# Formats this time using the given format (see `Time::Format`).
# Formats this `Time` according to the pattern in *format*.
#
# See `Time::Format` for details.
#
# ```
# time = Time.new(2016, 4, 5)
@@ -526,60 +860,72 @@ struct Time
Format.new(format).format(self)
end

# Formats this time using the given format (see `Time::Format`)
# into the given *io*.
# Formats this `Time` according to the pattern in *format* to the given *io*.
#
# See `Time::Format for details.
def to_s(format : String, io : IO) : Nil
Format.new(format).format(self, io)
end

# Parses a Time in the given *time* string, using the given *pattern* (see
# `Time::Format`).
# Parses a `Time` from *time* string using the given *pattern*.
#
# See `Time::Format` for details.
#
# ```
# Time.parse("2016-04-05", "%F") # => 2016-04-05 00:00:00 +01:00
# ```
def self.parse(time : String, pattern : String, location = nil) : Time
def self.parse(time : String, pattern : String, location : Location? = nil) : Time
Format.new(pattern, location).parse(time)
end

# Returns the number of seconds since the Epoch for this time.
# Returns the number of seconds since the Unix epoch
# (`1970-01-01 00:00:00 UTC`).
#
# ```
# time = Time.parse("2016-01-12 03:04:05 UTC", "%F %T %z")
# time = Time.utc(2016, 1, 12, 3, 4, 5)
# time.epoch # => 1452567845
# ```
def epoch : Int64
(total_seconds - UNIX_SECONDS).to_i64
end

# Returns the number of milliseconds since the Epoch for this time.
# Returns the number of milliseconds since the Unix epoch
# (`1970-01-01 00:00:00 UTC`).
#
# ```
# time = Time.parse("2016-01-12 03:04:05.678 UTC", "%F %T.%L %z")
# time = Time.utc(2016, 1, 12, 3, 4, 5, nanosecond: 678_000_000)
# time.epoch_ms # => 1452567845678
# ```
def epoch_ms : Int64
epoch * 1_000 + (nanosecond / NANOSECONDS_PER_MILLISECOND)
end

# Returns the number of seconds since the Epoch for this time,
# as a `Float64`.
# Returns the number of seconds since the Unix epoch
# (`1970-01-01 00:00:00 UTC`) as `Float64` with nanosecond precision.
#
# ```
# time = Time.parse("2016-01-12 03:04:05.678 UTC", "%F %T.%L %z")
# time = Time.utc(2016, 1, 12, 3, 4, 5, nanosecond: 678_000_000)
# time.epoch_f # => 1452567845.678
# ```
def epoch_f : Float64
epoch.to_f + nanosecond.to_f / 1e9
end

# Retuns this instance of time represented in `Location` *location*.
# Returns a copy of this `Time` representing the same instant observed in
# *location*.
#
# This method changes the time zone and retains the instant, which will
# usually result in a different representation of local date-time (unless
# both locations have the same offset).
#
# Ambiguous time zone transitions such as gaps and overlaps have no effect on
# the result because it retains the same instant.
#
# ```
# 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
# time_de = Time.new(2018, 3, 8, 22, 5, 13, location: Time::Location.load("Europe/Berlin"))
# time_ar = time_de.in Time::Location.load("America/Buenos_Aires")
# time_de # => 2018-03-08 22:05:13 +01:00 Europe/Berlin
# time_ar # => 2018-03-08 18:05:13 -03:00 America/Buenos_Aires
# ```
def in(location : Location) : Time
return self if location == self.location
@@ -591,7 +937,10 @@ struct Time
)
end

# Returns a copy of this `Time` converted to UTC.
# Returns a copy of this `Time` representing the same instant in UTC
# (`Time::Location::UTC`).
#
# See `#in` for details.
def to_utc : Time
if utc?
self
@@ -603,7 +952,10 @@ struct Time
end
end

# Returns a copy of this `Time` converted to the local time zone.
# Returns a copy of this `Time` representing the same instant in the local
# time zone (`Time::Location.local`).
#
# See `#in` for details.
def to_local : Time
if local?
self
@@ -613,15 +965,15 @@ struct Time
end

private macro def_at_beginning(interval)
# Returns the time when the {{interval.id}} that contains `self` starts.
# Returns a copy of this `Time` representing the beginning of the {{interval.id}}.
def at_beginning_of_{{interval.id}} : Time
year, month, day, day_year = year_month_day_day_year
{{yield}}
end
end

private macro def_at_end(interval)
# Returns the time when the {{interval.id}} that includes `self` ends.
# Returns a copy of this `Time` representing the end of the {{interval.id}}.
def at_end_of_{{interval.id}} : Time
year, month, day, day_year = year_month_day_day_year
{{yield}}
@@ -636,7 +988,9 @@ struct Time
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.
# Returns a copy of this `Time` representing the beginning of the week.
#
# TODO: Ensure correctness in local time-line.
def at_beginning_of_week : Time
dow = day_of_week.value
if dow == 0
@@ -648,7 +1002,7 @@ struct Time

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.
# Returns a copy of this `Time` representing the end of the semester.
def at_end_of_semester : Time
year, month = year_month_day_day_year
if month <= 6
@@ -659,7 +1013,7 @@ struct Time
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.
# Returns a copy of this `Time` representing the end of the quarter.
def at_end_of_quarter : Time
year, month = year_month_day_day_year
if month <= 3
@@ -676,7 +1030,9 @@ struct Time

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.
# Returns a copy of this `Time` representing the end of the week.
#
# TODO: Ensure correctness in local time-line.
def at_end_of_week : Time
dow = day_of_week.value
if dow == 0
@@ -690,14 +1046,16 @@ struct Time
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`.
# Returns a copy of this `Time` representing midday (`12:00`) of the same day.
def at_midday : Time
year, month, day = year_month_day_day_year
Time.new(year, month, day, 12, 0, 0, nanosecond: 0, location: location)
end

{% for name in DayOfWeek.constants %}
# Does `self` happen on {{name.id}}?
# Returns `true` if the day of week is {{name.id}}.
#
# See `#day_of_week` for details.
def {{name.id.downcase}}? : Bool
day_of_week.{{name.id.downcase}}?
end
203 changes: 153 additions & 50 deletions src/time/location.cr
Original file line number Diff line number Diff line change
@@ -1,35 +1,56 @@
require "./location/loader"

# `Location` represents a specific time zone.
# `Location` maps time instants to the zone in use at that time.
# It typically represents the collection of time offsets observed in
# a certain geographical area.
#
# It can be either a time zone from the IANA Time Zone database,
# a fixed offset, or `UTC`.
# It contains a list of zone offsets and rules for transitioning between them.
#
# Creating a location from timezone data:
# ```
# location = Time::Location.load("Europe/Berlin")
# ```
# If a location has only one offset (such as `UTC`) it is considerd
# *fixed*.
#
# Initializing a `Time` instance with specified `Location`:
# A `Location` instance is usually retrieved by name using
# `Time::Location.load`.
# It loads the zone offsets and transitioning rules from the time zone database
# provided by the operating system.
#
# ```
# location = Time::Location.load("Europe/Berlin")
# location # => #<Time::Location Europe/Berlin>
# time = Time.new(2016, 2, 15, 21, 1, 10, location: location)
# time # => 2016-02-15 21:01:10 +01:00 Europe/Berlin
# ```
#
# Alternatively, you can switch the `Location` for any `Time` instance:
# A custom time zone database can be configured through the environment variable
# `ZONEINFO`. See `.load` for details.
#
# ### Fixed Offset
#
# A fixed offset location is created using `Time::Location.fixed`:
#
# ```
# time.location.to_s # => "Europe/Berlin"
# time = time.in(Time::Location.load("Asia/Jerusalem"))
# time.location.to_s # => "Asia/Jerusalem"
# location = Time::Location.fixed(3600)
# location # => #<Time::Location +01:00>
# location.zones # => [#<Time::Location::Zone +01:00 (0s) STD>]
# ```
#
# There are also a few special conversions:
#
# ### Local Time Zone
#
# The local time zone can be accessed as `Time::Location.local`.
#
# It is initially configured according to system environment settings,
# but it's value can be changed:
#
# ```
# time.to_utc # == time.in(Location::UTC)
# time.to_local # == time.in(Location.local)
# location = Time::Location.local
# Time::Location.local = Time::Location.load("America/New_York")
# ```
class Time::Location
# `InvalidLocationNameError` is raised if a location name cannot be found in
# the time zone database.
#
# See `Time::Location.load` for details.
class InvalidLocationNameError < Exception
getter name, source

@@ -40,25 +61,46 @@ class Time::Location
end
end

# `InvalidTimezoneOffsetError` is raised if `Time::Location::Zone.new`
# receives an invalid time zone offset.
class InvalidTimezoneOffsetError < Exception
def initialize(offset : Int)
super "Invalid time zone offset: #{offset}"
end
end

# A `Zone` represents a time zone offset in effect in a specific `Location`.
#
# Some zones have a `name` or abbreviation (such as `PDT`, `CEST`).
# For an unnamed zone the formatted offset should be used as name.
struct Zone
# This is the `UTC` time zone with offset `+00:00`.
#
# It is the only zone offset used in `Time::Location::UTC`.
UTC = new "UTC", 0, false

# Returns the offset from UTC in seconds.
getter offset : Int32

# Returns `true` if this zone offset is daylight savings time.
getter? dst : Bool

# Creates a new `Zone` named *name* with *offset* from UTC in seconds.
# The parameter *dst* is used to declare this zone as daylight savings time.
#
# If `name` is `nil`, the formatted `offset` will be used as `name` (see
# `#format`).
#
# Raises `InvalidTimezoneOffsetError` if *seconds* is outside the supported
# value range `-86_400..86_400` seconds (`-24:00` to `+24:00`).
def initialize(@name : String?, @offset : Int32, @dst : Bool)
# Maximium offets of IANA timezone database are -12:00 and +14:00.
# Maximium offsets of IANA time zone database are -12:00 and +14:00.
# +/-24 hours allows a generous padding for unexpected offsets.
# TODO: Maybe reduce to Int16 (+/- 18 hours).
raise InvalidTimezoneOffsetError.new(offset) if offset >= SECONDS_PER_DAY || offset <= -SECONDS_PER_DAY
end

# Returns the name of the zone.
def name : String
@name || format
end
@@ -149,10 +191,18 @@ class Time::Location
end

# Describes the Coordinated Universal Time (UTC).
#
# The only time zone offset in this location is `Zone::UTC`.
UTC = new "UTC", [Zone::UTC]

property name : String
property zones : Array(Zone)
# Returns the name of this location.
#
# It usually consists of a continent and city name separated by a slash, for
# example `Europe/Berlin`.
getter name : String

# Returns the array of time zone offsets (`Zone`) used in this time zone.
getter zones : Array(Zone)

# Most lookups will be for the current time.
# To avoid the binary search through tx, keep a
@@ -164,37 +214,67 @@ class Time::Location
@cached_range : Tuple(Int64, Int64)
@cached_zone : Zone

# Creates a `Location` instance named *name* with fixed *offset*.
def self.fixed(name : String, offset : Int32)
# Creates a `Location` instance named *name* with fixed *offset* in seconds
# from UTC.
def self.fixed(name : String, offset : Int32) : Location
new name, [Zone.new(name, offset, false)]
end

# Creates a `Location` instance with fixed *offset*.
# Creates a `Location` instance with fixed *offset* in seconds from UTC.
#
# The formatted *offset* is used as name.
def self.fixed(offset : Int32)
zone = Zone.new(nil, offset, false)
new zone.name, [zone]
end

# Returns the `Location` with the given name.
# Loads 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.
# ```
# location = Time::Location.load("Europe/Berlin")
# ```
#
# 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`.
# *name* is understood to be a location name in the IANA Time
# Zone database, such as `"America/New_York"`. As special cases,
# `"UTC"` and empty string (`""`) return `Location::UTC`, and
# `"Local"` returns `Location.local`.
#
# Special names:
# * `"UTC"` and empty string `""` return `Location::UTC`
# * `"Local"` returns `Location.local`
# The implementation uses a list of system-specifc paths to look for a time
# zone database.
# The first time zone database entry matching the given name that is
# successfully loaded and parsed is returned.
# Typical paths on Unix-based operating systems are `/usr/share/zoneinfo/`,
# `/usr/share/lib/zoneinfo/`, or `/usr/lib/locale/TZ/`.
#
# 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.
# A time zone database may not be present on all systems, especially non-Unix
# systems. In this case, you may need to distribute a copy of the database
# with an application that depends on time zone data being available.
#
# A custom lookup path can be set as environment variable `ZONEINFO`.
# The path can point to the root of a directory structure or an
# uncompressed ZIP file, each representing the time zone database using files
# and folders of the expected names.
#
# Example:
# `ZONEINFO=/path/to/zoneinfo.zip crystal eval 'pp Location.load("Custom/Location")'`
#
# ```
# # This tries to load the file `/usr/share/zoneinfo/Custom/Location`
# ENV["ZONEINFO"] = "/usr/share/zoneinfo/"
# Location.load("Custom/Location")
#
# # This tries to load the file `Custom/Location` in the uncompressed ZIP
# # file at `/path/to/zoneinfo.zip`
# ENV["ZONEINFO"] = "/path/to/zoneinfo.zip"
# Location.load("Custom/Location")
# ```
#
# If the location name cannot be found, `InvalidLocationNameError` is raised.
# If the loader encounters a format error in the time zone database,
# `InvalidTZDataError` is raised.
#
# Files are cached based on the modification time, so subsequent request for
# the same location name will most likely return the same instance of
# `Location`, unless the time zone database has been updated in between.
def self.load(name : String) : Location
case name
when "", "UTC"
@@ -222,20 +302,35 @@ class Time::Location
end
end

# Returns the location representing the local time zone.
# Returns the `Location` representing the application's local time zone.
#
# `Time` uses this property as default value for most method arguments
# expecting a `Location`.
#
# The initial value depends on the current application environment, see
# `.load_local` for details.
#
# The value is loaded on first access based on the current application environment (see `.load_local` for details).
# The value can be changed to overwrite the system default:
#
# ```
# Time.now.location # => #<Time::Location America/New_York>
# Time::Location.local = Time::Location.load("Europe/Berlin")
# Time.now.location # => #<Time::Location Europe/Berlin>
# ```
class_property(local : Location) { load_local }

# Loads the local location described by the current application environment.
# Loads the local time zone according to the current application environment.
#
# The environment variable `ENV["TZ"]` is consulted for finding the time zone
# to use.
#
# 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.
# * `"UTC"` and empty string (`""`) return `Location::UTC`
# * Any other value (such as `"Europe/Berlin"`) is tried to be resolved using
# `Location.load`.
# * If `ENV["TZ"]` is not set, the system's local time zone data will be used
# (`/etc/localtime` on unix-based systems).
# * If no time zone data could be found (i.e. the previous methods failed),
# `Location::UTC` is returned.
def self.load_local : Location
case tz = ENV["TZ"]?
when "", "UTC"
@@ -263,6 +358,7 @@ class Time::Location
@transitions
end

# Prints `name` to *io*.
def to_s(io : IO)
io << name
end
@@ -273,14 +369,21 @@ class Time::Location
io << '>'
end

# Returns `true` if *other* is equal to `self`.
#
# Two `Location` instances are considered equal if they have the same name,
# offset zones and transition rules.
def_equals_and_hash name, zones, transitions

# Returns the time zone in use at `time`.
# Returns the time zone offset observed 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).
# Returns the time zone offset observed at *epoch*.
#
# *epoch* expresses the number of seconds since UNIX epoch
# (`1970-01-01 00:00:00 UTC`).
def lookup(epoch : Int) : Zone
unless @cached_range[0] <= epoch < @cached_range[1]
@cached_zone, @cached_range = lookup_with_boundaries(epoch)
@@ -290,7 +393,7 @@ class Time::Location
end

# :nodoc:
def lookup_with_boundaries(epoch : Int)
def lookup_with_boundaries(epoch : Int) : {Zone, {Int64, Int64}}
case
when zones.empty?
return Zone::UTC, {Int64::MIN, Int64::MAX}
@@ -323,7 +426,7 @@ class Time::Location
# 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
private def lookup_first_zone : Zone
unless transitions.any? { |tx| tx.index == 0 }
return zones.first
end
@@ -347,7 +450,7 @@ class Time::Location
self == UTC
end

# Returns `true` if this location equals to `Location.local`.
# Returns `true` if this location equals to `Time::Location.local`.
def local? : Bool
self == Location.local
end
18 changes: 11 additions & 7 deletions src/time/location/loader.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
class Time::Location
@@location_cache = {} of String => NamedTuple(time: Time, location: Location)

# `InvalidTZDataError` is raised if a zoneinfo file contains invalid
# time zone data.
#
# Details on the exact cause can be found in the error message.
class InvalidTZDataError < Exception
def self.initialize(message : String? = "Malformed time zone information", cause : Exception? = nil)
super(message, cause)
@@ -174,11 +178,11 @@ class Time::Location

# 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)
private def self.read_zip_file(name : String, file : File)
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")
raise InvalidTZDataError.new("Corrupt ZIP file #{file.path}")
end

file.skip 6
@@ -207,25 +211,25 @@ class Time::Location
end

unless compression_method == COMPRESSION_METHOD_UNCOMPRESSED
raise InvalidTZDataError.new("Unsupported compression for #{name}")
raise InvalidTZDataError.new("Unsupported compression in ZIP file: #{file.path}")
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")
raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
end
file.skip 4
unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == COMPRESSION_METHOD_UNCOMPRESSED
raise InvalidTZDataError.new("Invalid Zip file")
raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
end
file.skip 16
unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == filename_length
raise InvalidTZDataError.new("Invalid Zip file")
raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
end
extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
unless file.gets(filename_length) == name
raise InvalidTZDataError.new("Invalid Zip file")
raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
end

file.skip extra_field_length

0 comments on commit 75f4a61

Please sign in to comment.