Current section

Files

Jump to
timex lib parse datetime parser.ex
Raw

lib/parse/datetime/parser.ex

defmodule Timex.Parse.DateTime.Parser do
@moduledoc """
This is the base plugin behavior for all Timex date/time string parsers.
"""
import Combine.Parsers.Base, only: [eof: 0, map: 2, pipe: 2]
alias Timex.{Timezone, TimezoneInfo, AmbiguousDateTime, AmbiguousTimezoneInfo}
alias Timex.Parse.ParseError
alias Timex.Parse.DateTime.Tokenizers.{Directive, Default, Strftime}
@doc """
Parses a date/time string using the default parser.
## Examples
iex> use Timex
...> {:ok, dt} = #{__MODULE__}.parse("2014-07-29T00:20:41.196Z", "{ISO:Extended:Z}")
...> dt.year
2014
iex> dt.month
7
iex> dt.day
29
iex> dt.time_zone
"Etc/UTC"
"""
@spec parse(binary, binary) :: {:ok, DateTime.t() | NaiveDateTime.t()} | {:error, term}
def parse(date_string, format_string)
when is_binary(date_string) and is_binary(format_string),
do: parse(date_string, format_string, Default)
def parse(_, _),
do: {:error, :badarg}
@doc """
Parses a date/time string using the provided tokenizer. Tokenizers must implement the
`Timex.Parse.DateTime.Tokenizer` behaviour.
## Examples
iex> use Timex
...> {:ok, dt} = #{__MODULE__}.parse("2014-07-29T00:30:41.196-02:00", "{ISO:Extended}", Timex.Parse.DateTime.Tokenizers.Default)
...> dt.year
2014
iex> dt.month
7
iex> dt.day
29
iex> dt.time_zone
"Etc/GMT+2"
"""
@spec parse(binary, binary, atom) :: {:ok, DateTime.t() | NaiveDateTime.t()} | {:error, term}
def parse(date_string, format_string, tokenizer)
when is_binary(date_string) and is_binary(format_string) do
try do
{:ok, parse!(date_string, format_string, tokenizer)}
catch
_type, %ParseError{:message => msg} ->
{:error, msg}
_type, %{:message => msg} ->
{:error, msg}
_type, reason ->
{:error, reason}
end
end
def parse(_, _, _), do: {:error, :badarg}
@doc """
Same as `parse/2` and `parse/3`, but raises on error.
"""
@spec parse!(String.t(), String.t(), atom | nil) :: DateTime.t() | NaiveDateTime.t() | no_return
def parse!(date_string, format_string, tokenizer \\ Default)
def parse!(date_string, format_string, :strftime),
do: parse!(date_string, format_string, Strftime)
def parse!(date_string, format_string, tokenizer)
when is_binary(date_string) and is_binary(format_string) and is_atom(tokenizer) do
case tokenizer.tokenize(format_string) do
{:error, err} when is_binary(err) ->
raise ParseError, message: err
{:error, err} ->
raise ParseError, message: "#{inspect(err)}"
{:ok, []} ->
raise ParseError,
message: "There were no parsing directives in the provided format string."
{:ok, directives} ->
case date_string do
"" ->
raise ParseError, message: "Input datetime string cannot be empty!"
_ ->
datetime =
case do_parse(date_string, directives, tokenizer) do
{:ok, %DateTime{time_zone: nil} = dt} ->
Timex.to_naive_datetime(dt)
{:ok, dt} ->
dt
{:error, reason} when is_binary(reason) ->
raise ParseError, message: reason
{:error, reason} ->
raise ParseError, message: "#{inspect(reason)}"
end
if :calendar.valid_date(datetime.year, datetime.month, datetime.day) do
datetime
else
raise ParseError, message: "#{inspect(datetime)} is an invalid date!"
end
end
end
end
# Special case iso8601/rfc3339 for performance
defp do_parse(str, [%Directive{:type => type}], _tokenizer)
when type in [:iso_8601_extended, :iso_8601_extended_z, :rfc_3339, :rfc_3339z] do
case Combine.parse(str, Timex.Parse.DateTime.Parsers.ISO8601Extended.parse()) do
{:error, _} = err ->
err
[parts] when is_list(parts) ->
case Enum.into(parts, %{}) do
%{year4: y, month: m, day: d, hour24: h, zname: tzname} = mapped ->
mm = Map.get(mapped, :min, 0)
ss = Map.get(mapped, :sec, 0)
us = Map.get(mapped, :sec_fractional, {0, 0})
tz = Timezone.get(tzname, {{y, m, d}, {h, mm, ss}})
{:ok,
%DateTime{
year: y,
month: m,
day: d,
hour: h,
minute: mm,
second: ss,
microsecond: us,
time_zone: tz.full_name,
zone_abbr: tz.abbreviation,
utc_offset: tz.offset_utc,
std_offset: tz.offset_std
}}
%{year4: y, month: m, day: d, hour24: h} = mapped ->
mm = Map.get(mapped, :min, 0)
ss = Map.get(mapped, :sec, 0)
us = Map.get(mapped, :sec_fractional, {0, 0})
{:ok,
%NaiveDateTime{
year: y,
month: m,
day: d,
hour: h,
minute: mm,
second: ss,
microsecond: us
}}
end
end
end
defp do_parse(str, directives, tokenizer) do
parsers =
directives
|> Stream.map(fn %Directive{weight: weight, parser: parser} ->
map(parser, &{&1, weight})
end)
|> Stream.filter(fn
nil -> false
_ -> true
end)
|> Enum.reverse()
case Combine.parse(str, pipe([eof() | parsers] |> Enum.reverse(), & &1)) do
[results] when is_list(results) ->
results
|> extract_parse_results
|> Stream.with_index()
|> Enum.sort_by(fn
# If :force_utc exists, make sure it is applied last
{{{:force_utc, true}, _}, _} -> 9999
# Timezones must always be applied after other date/time tokens ->
{{{tz, _}, _}, _} when tz in [:zname, :zoffs, :zoffs_colon, :zoffs_sec] -> 9998
# If no weight is set, use the index as its weight
{{{_token, _value}, 0}, i} -> i
# Use the directive weight
{{{_token, _value}, weight}, _} -> weight
end)
|> Stream.flat_map(fn {{token, _}, _} -> [token] end)
|> Enum.filter(&Kernel.is_tuple/1)
|> apply_directives(tokenizer)
{:error, _} = err ->
err
end
end
defp extract_parse_results(parse_results), do: extract_parse_results(parse_results, [])
defp extract_parse_results([], acc), do: Enum.reverse(acc)
defp extract_parse_results([{tokens, weight} | rest], acc) when is_list(tokens) do
extracted =
extract_parse_results(tokens)
|> Enum.map(fn {{token, value}, _weight} -> {{token, value}, weight} end)
|> Enum.reverse()
extract_parse_results(rest, extracted ++ acc)
end
defp extract_parse_results([{{token, value}, weight} | rest], acc) when is_atom(token) do
extract_parse_results(rest, [{{token, value}, weight} | acc])
end
defp extract_parse_results([{token, value} | rest], acc) when is_atom(token) do
extract_parse_results(rest, [{{token, value}, 0} | acc])
end
defp extract_parse_results([[{token, value}] | rest], acc) when is_atom(token) do
extract_parse_results(rest, [{{token, value}, 0} | acc])
end
defp extract_parse_results([h | rest], acc) when is_list(h) do
extracted = Enum.reverse(extract_parse_results(h))
extract_parse_results(rest, extracted ++ acc)
end
defp extract_parse_results([_ | rest], acc) do
extract_parse_results(rest, acc)
end
# Constructs a DateTime from the parsed tokens
defp apply_directives([], _),
do: {:ok, Timex.DateTime.Helpers.empty()}
defp apply_directives(tokens, tokenizer),
do: apply_directives(tokens, Timex.DateTime.Helpers.empty(), tokenizer)
defp apply_directives([], date, _), do: {:ok, date}
defp apply_directives([{token, value} | tokens], date, tokenizer) do
case update_date(date, token, value, tokenizer) do
{:error, _} = error -> error
updated -> apply_directives(tokens, updated, tokenizer)
end
end
# Given a date, a token, and the value for that token, update the
# date according to the rules for that token and the provided value
defp update_date(%AmbiguousDateTime{} = adt, token, value, tokenizer) when is_atom(token) do
bd = update_date(adt.before, token, value, tokenizer)
ad = update_date(adt.after, token, value, tokenizer)
%{adt | :before => bd, :after => ad}
end
defp update_date(%{year: year, hour: hh} = date, token, value, tokenizer) when is_atom(token) do
case token do
# Formats
clock when clock in [:kitchen, :strftime_iso_kitchen] ->
date =
cond do
date == Timex.DateTime.Helpers.empty() ->
{{y, m, d}, _} = :calendar.universal_time()
%{date | :year => y, :month => m, :day => d}
true ->
date
end
case apply_directives(value, date, tokenizer) do
{:error, _} = err ->
err
{:ok, date} when clock == :kitchen ->
%{date | :second => 0, :microsecond => {0, 0}}
{:ok, date} ->
%{date | :microsecond => {0, 0}}
end
# Years
:century ->
century = Timex.century(%{date | :year => year})
year_shifted = year + (value - century) * 100
%{date | :year => year_shifted}
y when y in [:year2, :iso_year2] ->
{{y, _, _}, _} = :calendar.universal_time()
current_century = Timex.century(y)
year_shifted = value + (current_century - 1) * 100
%{date | :year => year_shifted}
y when y in [:year4, :iso_year4] ->
# Special case for UNIX format dates, where the year is parsed after the timezone,
# so we must lookup the timezone again to ensure it's properly set
case date do
%{time_zone: tzname} when is_nil(tzname) == false ->
seconds_from_zeroyear = Timex.to_gregorian_seconds(date)
date = to_datetime(date)
case Timezone.resolve(tzname, seconds_from_zeroyear) do
%TimezoneInfo{} = tz ->
%{
date
| :year => value,
:time_zone => tz.full_name,
:zone_abbr => tz.abbreviation,
:utc_offset => tz.offset_utc,
:std_offset => tz.offset_std
}
%AmbiguousTimezoneInfo{before: b, after: a} ->
bd = %{
date
| :year => value,
:time_zone => b.full_name,
:zone_abbr => b.abbreviation,
:utc_offset => b.offset_utc,
:std_offset => b.offset_std
}
ad = %{
date
| :year => value,
:time_zone => a.full_name,
:zone_abbr => a.abbreviation,
:utc_offset => a.offset_utc,
:std_offset => a.offset_std
}
%AmbiguousDateTime{:before => bd, :after => ad}
end
_ ->
%{date | :year => value}
end
# Months
:month ->
%{date | :month => value}
month when month in [:mshort, :mfull] ->
%{date | :month => Timex.month_to_num(value)}
# Days
:day ->
%{date | :day => value}
:oday when is_integer(value) and value >= 0 ->
Timex.from_iso_day(value, date)
:wday_mon ->
current_day = Timex.weekday(date)
cond do
current_day == value -> date
current_day > value -> Timex.shift(date, days: current_day - value)
current_day < value -> Timex.shift(date, days: value - current_day)
end
:wday_sun ->
current_day = Timex.weekday(date) - 1
cond do
current_day == value -> date
current_day > value -> Timex.shift(date, days: current_day - value)
current_day < value -> Timex.shift(date, days: value - current_day)
end
day when day in [:wdshort, :wdfull] ->
%{date | :day => Timex.day_to_num(value)}
# Weeks
:iso_weeknum ->
{year, _, weekday} = Timex.iso_triplet(date)
%Date{year: y, month: m, day: d} = Timex.from_iso_triplet({year, value, weekday})
%{date | :year => y, :month => m, :day => d}
week_num when week_num in [:week_mon, :week_sun] ->
reset = %{date | :month => 1, :day => 1}
reset |> Timex.shift(weeks: value)
:weekday ->
date |> Timex.shift(days: value - 1)
# Hours
hour when hour in [:hour24, :hour12] ->
%{date | :hour => value}
:min ->
%{date | :minute => value}
:sec ->
case value do
60 ->
date
|> Timex.to_naive_datetime()
|> Timex.set(second: 0)
|> Timex.shift(minutes: 1)
value ->
%{date | :second => value}
end
:sec_fractional ->
case value do
"" ->
date
n when is_number(n) ->
%{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(n, -1)}
{_n, _precision} = us ->
%{date | :microsecond => us}
end
:us ->
%{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(value, -1)}
:ms ->
%{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(value * 1_000, -1)}
:sec_epoch ->
DateTime.from_unix!(value)
am_pm when am_pm in [:am, :AM] ->
cond do
hh == 24 ->
%{date | :hour => 0}
hh == 12 and String.downcase(value) == "am" ->
%{date | :hour => 0}
hh in 1..11 and String.downcase(value) == "pm" ->
%{date | :hour => hh + 12}
true ->
date
end
# Timezones
:zoffs ->
date = to_datetime(date)
case value do
<<sign::utf8, h1::utf8, h2::utf8>> ->
hour = <<h1::utf8, h2::utf8>>
hours = String.to_integer(hour)
minutes = 0
{gmt_sign, total_offset} =
case sign do
?- -> {?+, -1 * (hours * 60 * 60 + minutes * 60)}
?+ -> {?-, hours * 60 * 60 + minutes * 60}
end
case hours do
0 ->
%{
date
| :time_zone => "Etc/GMT+0",
:zone_abbr => "GMT",
:utc_offset => total_offset,
:std_offset => 0
}
h when h < 10 ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>,
:zone_abbr => <<sign::utf8, ?0, h2::utf8>>,
:utc_offset => total_offset,
:std_offset => 0
}
_ ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>,
:zone_abbr => <<sign::utf8, hour::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
end
<<sign::utf8, h1::utf8, h2::utf8, m1::utf8, m2::utf8>> ->
hour = <<h1::utf8, h2::utf8>>
hours = String.to_integer(hour)
minute = <<m1::utf8, m2::utf8>>
minutes = String.to_integer(minute)
{gmt_sign, total_offset} =
case sign do
?- -> {?+, -1 * (hours * 60 * 60 + minutes * 60)}
?+ -> {?-, hours * 60 * 60 + minutes * 60}
end
case {hours, minutes} do
{0, 0} ->
%{
date
| :time_zone => "Etc/GMT+0",
:zone_abbr => "GMT",
:utc_offset => total_offset,
:std_offset => 0
}
{h, 0} when h < 10 ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>,
:zone_abbr => <<sign::utf8, ?0, h2::utf8>>,
:utc_offset => total_offset,
:std_offset => 0
}
{_, 0} ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>,
:zone_abbr => <<sign::utf8, hour::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
_ ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary>>,
:zone_abbr => <<sign::utf8, hour::binary, ?:, minute::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
end
_ ->
{:error, "invalid offset: #{inspect(value)}"}
end
:zname ->
seconds_from_zeroyear = Timex.to_gregorian_seconds(date)
case Timezone.name_of(value) do
{:error, _} = err ->
err
tzname ->
date = to_datetime(date)
case Timezone.resolve(tzname, seconds_from_zeroyear) do
%TimezoneInfo{} = tz ->
%{
date
| :time_zone => tz.full_name,
:zone_abbr => tz.abbreviation,
:utc_offset => tz.offset_utc,
:std_offset => tz.offset_std
}
%AmbiguousTimezoneInfo{before: b, after: a} ->
bd = %{
date
| :time_zone => b.full_name,
:zone_abbr => b.abbreviation,
:utc_offset => b.offset_utc,
:std_offset => b.offset_std
}
ad = %{
date
| :time_zone => a.full_name,
:zone_abbr => a.abbreviation,
:utc_offset => a.offset_utc,
:std_offset => a.offset_std
}
%AmbiguousDateTime{:before => bd, :after => ad}
end
end
:zoffs_colon ->
date = to_datetime(date)
case value do
<<sign::utf8, h1::utf8, h2::utf8, ?:, m1::utf8, m2::utf8>> ->
hour = <<h1::utf8, h2::utf8>>
hours = String.to_integer(hour)
minute = <<m1::utf8, m2::utf8>>
minutes = String.to_integer(minute)
{gmt_sign, total_offset} =
case sign do
?- -> {?+, -1 * (hours * 60 * 60 + minutes * 60)}
?+ -> {?-, hours * 60 * 60 + minutes * 60}
end
case {hours, minutes} do
{0, 0} ->
%{
date
| :time_zone => "Etc/GMT+0",
:zone_abbr => "GMT",
:utc_offset => total_offset,
:std_offset => 0
}
{h, 0} when h < 10 ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>,
:zone_abbr => <<sign::utf8, ?0, h2::utf8>>,
:utc_offset => total_offset,
:std_offset => 0
}
{_, 0} ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>,
:zone_abbr => <<sign::utf8, hour::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
_ ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary>>,
:zone_abbr => <<sign::utf8, hour::binary, ?:, minute::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
end
_ ->
{:error, "invalid offset: #{inspect(value)}"}
end
:zoffs_sec ->
date = to_datetime(date)
case value do
<<sign::utf8, h1::utf8, h2::utf8, ?:, m1::utf8, m2::utf8, ?:, s1::utf8, s2::utf8>> ->
hour = <<h1::utf8, h2::utf8>>
hours = String.to_integer(hour)
minute = <<m1::utf8, m2::utf8>>
minutes = String.to_integer(minute)
second = <<s1::utf8, s2::utf8>>
seconds = String.to_integer(second)
{gmt_sign, total_offset} =
case sign do
?- -> {?+, -1 * (hours * 60 * 60 + minutes * 60)}
?+ -> {?-, hours * 60 * 60 + minutes * 60}
end
case {hours, minutes, seconds} do
{0, 0, 0} ->
%{
date
| :time_zone => "Etc/GMT+0",
:zone_abbr => "GMT",
:utc_offset => total_offset,
:std_offset => 0
}
{h, 0, 0} when h < 10 ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>,
:zone_abbr => <<sign::utf8, ?0, h2::utf8>>,
:utc_offset => total_offset,
:std_offset => 0
}
{_, 0, 0} ->
%{
date
| :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>,
:zone_abbr => <<sign::utf8, hour::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
_ ->
%{
date
| :time_zone =>
<<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary, ?:,
second::binary>>,
:zone_abbr =>
<<sign::utf8, hour::binary, ?:, minute::binary, ?:, second::binary>>,
:utc_offset => total_offset,
:std_offset => 0
}
end
_ ->
{:error, "invalid offset: #{inspect(value)}"}
end
:force_utc ->
date = to_datetime(date)
case date.time_zone do
nil ->
%{
date
| :time_zone => "Etc/UTC",
:zone_abbr => "UTC",
:utc_offset => 0,
:std_offset => 0
}
_ ->
Timezone.convert(date, "UTC")
end
:literal ->
date
:week_of_year ->
shift_to_week_of_year(:mon, date, value)
:week_of_year_sun ->
shift_to_week_of_year(:sun, date, value)
_ ->
case tokenizer.apply(date, token, value) do
{:ok, date} ->
date
{:error, _} = err ->
err
_ ->
{:error, "Unrecognized token: #{token}"}
end
end
end
defp shift_to_week_of_year(:mon, %{year: y} = datetime, value) when is_integer(value) do
shift =
case :calendar.day_of_the_week({y, 1, 1}) do
# Week 1, seek backwards to beginning of week
n when n < 5 ->
[days: -(7 - (7 - (n - 1)))]
# Part of last year's week, seek forwards to beginning of week
n ->
[days: 7 - (n - 1)]
end
datetime = Timex.to_naive_datetime(datetime)
do_shift_to_week_of_year(Timex.shift(%{datetime | month: 1, day: 1}, shift), value)
end
defp shift_to_week_of_year(:sun, %{year: y} = datetime, value) when is_integer(value) do
n = :calendar.day_of_the_week({y, 1, 1})
shift = [days: -1 - (7 - (7 - (n - 1)))]
datetime = Timex.to_naive_datetime(datetime)
do_shift_to_week_of_year(Timex.shift(%{datetime | month: 1, day: 1}, shift), value)
end
defp do_shift_to_week_of_year(%{year: y} = datetime, weeks) do
# On leap years which start on Thursday, week numbers
# are incremented by 1 from March thru the rest of the year
shifted = Timex.shift(datetime, days: 7 * (weeks - 1))
if :calendar.is_leap_year(y) do
case :calendar.day_of_the_week({y, 1, 1}) do
4 ->
case shifted do
%{month: m} when m < 3 ->
shifted
_ ->
Timex.shift(shifted, days: 7)
end
_n ->
shifted
end
else
shifted
end
end
defp to_datetime(%DateTime{} = dt), do: dt
defp to_datetime(%NaiveDateTime{
year: y,
month: m,
day: d,
hour: h,
minute: mm,
second: ss,
microsecond: us
}) do
%DateTime{
year: y,
month: m,
day: d,
hour: h,
minute: mm,
second: ss,
microsecond: us,
time_zone: "Etc/UTC",
zone_abbr: "UTC",
utc_offset: 0,
std_offset: 0
}
end
end