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() | AmbiguousDateTime.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/UTC-2" """ @spec parse(binary, binary, atom) :: {:ok, DateTime.t() | NaiveDateTime.t() | AmbiguousDateTime.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)} rescue err in [ParseError] -> {:error, err.message} 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() | AmbiguousDateTime.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: 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!" _ -> case do_parse(date_string, directives, tokenizer) do {:ok, dt} -> dt {:error, reason} when is_binary(reason) -> raise ParseError, message: reason {:error, reason} -> raise ParseError, message: reason 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}) naive = Timex.NaiveDateTime.new!(y, m, d, h, mm, ss, us) with %DateTime{} = datetime <- Timex.Timezone.convert(naive, tzname) do {:ok, datetime} end %{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}) NaiveDateTime.new(y, m, d, h, mm, ss, 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([], datetime, _) do with :ok <- validate_datetime(datetime) do {:ok, datetime} end end 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 defp validate_datetime(%{year: y, month: m, day: d} = datetime) do with {:date, true} <- {:date, :calendar.valid_date(y, m, d)}, {:ok, %Time{}} <- Time.new(datetime.hour, datetime.minute, datetime.second, datetime.microsecond) do :ok else {:date, _} -> {:error, :invalid_date} {:error, _} = err -> err end end defp validate_datetime(%AmbiguousDateTime{before: before_dt, after: after_dt}) do with :ok <- validate_datetime(before_dt), :ok <- validate_datetime(after_dt) do :ok else {:error, _} = err -> err 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] -> date = %{date | :year => value} # 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 Map.get(date, :time_zone) do time_zone when is_binary(time_zone) -> # Need to validate the date/time before doing timezone operations with :ok <- validate_datetime(date) do seconds_from_zeroyear = Timex.to_gregorian_seconds(date) case Timezone.resolve(time_zone, seconds_from_zeroyear) do %TimezoneInfo{} = tz -> Timex.to_datetime(date, tz) %AmbiguousTimezoneInfo{before: b, after: a} -> bd = Timex.to_datetime(date, b) ad = Timex.to_datetime(date, a) %AmbiguousDateTime{before: bd, after: ad} end end nil -> date 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 -> current_dow = Timex.Date.day_of_week(date, :monday) if current_dow == value do date else Timex.shift(date, days: value - current_dow) end # Hours hour when hour in [:hour24, :hour12] -> %{date | :hour => value} :min -> %{date | :minute => value} :sec -> case value do 60 -> Timex.shift(date, 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 -> with :ok <- validate_datetime(date) do case value do <> = zone when sign in [?+, ?-] -> Timex.to_datetime(date, zone) <> = zone when sign in [?+, ?-] -> Timex.to_datetime(date, zone) _ -> {:error, {:invalid_zoffs, value}} end end :zname -> with :ok <- validate_datetime(date) do Timex.to_datetime(date, value) end :zoffs_colon -> with :ok <- validate_datetime(date) do case value do <> = zone when sign in [?+, ?-] -> Timex.to_datetime(date, zone) _ -> {:error, {:invalid_zoffs_colon, value}} end end :zoffs_sec -> with :ok <- validate_datetime(date) do case value do <> = zone when sign in [?+, ?-] -> Timex.to_datetime(date, zone) _ -> {:error, {:invalid_zoffs_sec, value}} end end :force_utc -> with :ok <- validate_datetime(date) do Timex.to_datetime(date, "Etc/UTC") end :literal -> date :week_of_year_iso -> shift_to_week_of_year(:iso, date, value) :week_of_year_mon -> shift_to_week_of_year(:monday, date, value) :week_of_year_sun -> shift_to_week_of_year(:sunday, 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(:iso, %{year: y} = datetime, value) when is_integer(value) do {dow11, _, _} = Timex.Date.day_of_week(y, 1, 1, :monday) {dow14, _, _} = Timex.Date.day_of_week(y, 1, 4, :monday) # See https://en.wikipedia.org/wiki/ISO_week_date#Calculating_an_ordinal_or_month_date_from_a_week_date ordinal = value * 7 + dow11 - (dow14 + 3) {year, month, day} = Timex.Helpers.iso_day_to_date_tuple(y, ordinal) %Date{year: year, month: month, day: day} = Timex.Date.beginning_of_week(Timex.Date.new!(year, month, day)) %{datetime | year: year, month: month, day: day} end defp shift_to_week_of_year(weekstart, %{year: y} = datetime, value) when is_integer(value) do new_year = Timex.Date.new!(y, 1, 1) week_start = Timex.Date.beginning_of_week(new_year, weekstart) # This date can be calculated by taking the day number of the year, # shifting the day number of the year down by the number of days which # occurred in the previous year, then dividing by 7 day_num = if Date.compare(week_start, new_year) == :lt do prev_year_day_start = Date.day_of_year(week_start) prev_year_day_end = Date.day_of_year(Timex.Date.new!(week_start.year, 12, 31)) shift = prev_year_day_end - prev_year_day_start shift + value * 7 else value * 7 end datetime = Timex.to_naive_datetime(datetime) Timex.shift(%{datetime | month: 1, day: 1}, days: day_num) end end