defmodule Datix do @moduledoc """ A date-time parser using `Calendar.strftime/3` format strings. """ @type t :: %{ optional(:am_pm) => :am | :pm, optional(:day) => pos_integer(), optional(:day_of_week) => pos_integer(), optional(:day_of_year) => pos_integer(), optional(:hour) => pos_integer(), optional(:hour_12) => pos_integer(), optional(:microsecond) => pos_integer(), optional(:minute) => pos_integer(), optional(:month) => pos_integer(), optional(:quarter) => pos_integer(), optional(:second) => pos_integer(), optional(:year) => pos_integer(), optional(:year_2_digit) => pos_integer(), optional(:zone_abbr) => String.t(), optional(:zone_offset) => integer() } @typedoc """ An **opaque** type representing a compiled format. The struct representation is internal and could change in the future without notice. """ @typedoc since: "0.2.0" @opaque compiled :: %__MODULE__{ format: [ {:exact, binary()} | {:modifier, {char(), padder :: char(), width :: non_neg_integer()}} ], original: String.t() } @doc false defstruct format: [], original: nil defimpl Inspect do def inspect(%@for{original: original}, opts) do Inspect.Algebra.concat(["Datix.compile!(", Inspect.BitString.inspect(original, opts), ")"]) end end @doc """ Parses a date-time string according to the given `format`. See the `Calendar.strftime/3` documentation for how to specify a format string. `format` can be a format string or, since v0.2.0 of the library, a **compiled format** as returned by `compile/1`. ## Options * `:preferred_date` - a string for the preferred format to show dates, it can't contain the `%x` format and defaults to `"%Y-%m-%d"` if the option is not received * `:month_names` - a list of the month names, if the option is not received it defaults to a list of month names in English * `:abbreviated_month_names` - a list of abbreviated month names, if the option is not received it defaults to a list of abbreviated month names in English * `:day_of_week_names` - a list of day names, if the option is not received it defaults to a list of day names in English * `:abbreviated_day_of_week_names` - a list of abbreviated day names, if the option is not received it defaults to a list of abbreviated day names in English * `:preferred_time` - a string for the preferred format to show times, it can't contain the `%X` format and defaults to `"%H:%M:%S"` if the option is not received * `:am_pm_names` - a keyword list with the names of the period of the day, defaults to `[am: "am", pm: "pm"]`. ## Examples iex> Datix.strptime("2021/01/10", "%Y/%m/%d") {:ok, %{day: 10, month: 1, year: 2021}} iex> Datix.strptime("21/01/10", "%y/%m/%d") {:ok, %{day: 10, month: 1, year_2_digit: 21}} iex> Datix.strptime("13/14/15", "%H/%M/%S") {:ok, %{hour: 13, minute: 14, second: 15}} iex> Datix.strptime("1 PM", "%-I %p") {:ok, %{am_pm: :pm, hour_12: 1}} iex> Datix.strptime("Tuesday", "%A") {:ok, %{day_of_week: 2}} iex> Datix.strptime("Tue", "%a") {:ok, %{day_of_week: 2}} iex> Datix.strptime("Di", "%a", ...> abbreviated_day_of_week_names: ~w(Mo Di Mi Do Fr Sa So)) {:ok, %{day_of_week: 2}} iex> compiled = Datix.compile!("%Y/%m/%d") iex> Datix.strptime("2021/01/10", compiled) {:ok, %{day: 10, month: 1, year: 2021}} """ @spec strptime(String.t(), String.t() | compiled(), keyword()) :: {:ok, Datix.t()} | {:error, :invalid_input} | {:error, {:parse_error, expected: String.t(), got: String.t()}} | {:error, {:conflict, [expected: term(), got: term(), modifier: String.t()]}} | {:error, {:invalid_string, [modifier: String.t()]}} | {:error, {:invalid_integer, [modifier: String.t()]}} | {:error, {:invalid_modifier, [modifier: String.t()]}} def strptime(date_time_str, format, opts \\ []) def strptime(date_time_str, %__MODULE__{format: format}, opts) do with {:ok, options} <- options(opts) do case parse(format, date_time_str, options, %{}) do {:ok, result, ""} -> {:ok, result} {:ok, _result, _rest} -> {:error, :invalid_input} error -> error end end end def strptime(date_time_str, format_str, opts) when is_binary(format_str) do with {:ok, compiled} <- compile(format_str) do strptime(date_time_str, compiled, opts) end end @doc """ Parses a date-time string according to the given `format`, erroring out for invalid arguments. """ @spec strptime!(String.t(), String.t() | compiled(), keyword()) :: Datix.t() def strptime!(date_time_str, format, opts \\ []) do case strptime(date_time_str, format, opts) do {:ok, data} -> data {:error, :invalid_input} -> raise ArgumentError, "invalid input" {:error, {:parse_error, expected: exp, got: got}} -> raise ArgumentError, "parse error: expected #{inspect(exp)}, got #{inspect(got)}" {:error, {:conflict, [expected: exp, got: got, modifier: mod]}} -> raise ArgumentError, "expected #{inspect(exp)}, got #{inspect(got)} for #{mod}" {:error, {:invalid_string, [modifier: mod]}} -> raise ArgumentError, "invalid string for #{mod}" {:error, {:invalid_integer, [modifier: mod]}} -> raise ArgumentError, "invalid integer for #{mod}" {:error, {:invalid_modifier, [modifier: mod]}} -> raise ArgumentError, "invalid format: #{mod}" end end @doc false @spec calendar(keyword()) :: module() def calendar(opts), do: Keyword.get(opts, :calendar, Calendar.ISO) @doc false def assume(data, Date) do case Map.has_key?(data, :year) || Map.has_key?(data, :year_2_digit) do true -> Map.merge(%{month: 1, day: 1}, data) false -> Map.merge(%{year: 0, month: 1, day: 1}, data) end end def assume(data, Time) do case Map.has_key?(data, :hour) || Map.has_key?(data, :hour_12) do true -> Map.merge(%{minute: 0, second: 0, microsecond: {0, 0}}, data) false -> Map.merge(%{hour: 0, minute: 0, second: 0, microsecond: {0, 0}}, data) end end @doc """ Compiles the given `format` string. If the `format` string is a valid format string, then this function returns `{:ok, compiled}`. `compiled` is a term that represents a compiled format (its internal representation is private). You can pass a `t:compiled/0` term to `strptime/3` and such. If the `format` string is invalid, this function returns `{:error, reason}`. You can use this function for two reasons: * You have the same format string that you want to compile once and then use to parse over and over * You want to *validate* a format string """ @doc since: "0.2.0" @spec compile(String.t()) :: {:ok, compiled()} | {:error, reason :: term()} def compile(format) when is_binary(format) do case compile(format, _acc = []) do {:ok, compiled_format} -> {:ok, %__MODULE__{format: compiled_format, original: format}} {:error, reason} -> {:error, reason} end end @doc """ Like `compile/1`, but returns the compiled struct directly or raises in case of errors. ## Examples iex> Datix.compile!("%Y-%m-%d") Datix.compile!("%Y-%m-%d") iex> Datix.compile!("%l") ** (ArgumentError) invalid format: %l """ @doc since: "0.2.0" @spec compile!(String.t()) :: compiled() def compile!(format) do case compile(format) do {:ok, compiled} -> compiled {:error, {:invalid_modifier, [modifier: mod]}} -> raise ArgumentError, "invalid format: #{mod}" end end defp compile("", acc), do: {:ok, Enum.reverse(acc)} defp compile("%" <> rest, acc) do with {:ok, modifier, rest} <- compile_modifier(rest, nil, nil) do compile(rest, [{:modifier, modifier} | acc]) end end defp compile(<<_, _::binary>> = rest, acc) do {exact, rest} = take_until_modifier(rest, _acc = "") compile(rest, [{:exact, exact} | acc]) end defp take_until_modifier(<<>> = rest, acc), do: {acc, rest} defp take_until_modifier(<> = rest, acc), do: {acc, rest} defp take_until_modifier(<>, acc), do: take_until_modifier(rest, <>) defp compile_modifier("-" <> rest, _padding, nil = width) do compile_modifier(rest, _padding = "", width) end defp compile_modifier("_" <> rest, _padding, nil = width) do compile_modifier(rest, _padding = ?\s, width) end defp compile_modifier("0" <> rest, _padding, nil = width) do compile_modifier(rest, _padding = ?0, width) end defp compile_modifier(<>, padding, width) when digit in ?0..?9 do compile_modifier(rest, padding, (width || 0) * 10 + (digit - ?0)) end defp compile_modifier(<>, padding, width) do modifier = {format, padding || default_padding(format), width || default_width(format)} if format in 'aAbBpPcxXdHIjmMqSufzZyY%' do {:ok, modifier, rest} else {:error, {:invalid_modifier, modifier: modifier_to_string(modifier)}} end end defp parse([], date_time_rest, _opts, acc), do: {:ok, acc, date_time_rest} defp parse(_format, "", _opts, _acc), do: {:error, :invalid_input} defp parse([{:modifier, modifier} | format_rest], date_time_str, opts, acc) do with {:ok, new_acc, date_time_rest} <- parse_date_time(modifier, date_time_str, opts, acc) do parse(format_rest, date_time_rest, opts, new_acc) end end defp parse([{:exact, exact} | format_rest], date_time_str, opts, acc) do expected_size = byte_size(exact) case date_time_str do <> when got == exact -> parse(format_rest, date_time_rest, opts, acc) _other -> {:error, {:parse_error, expected: exact, got: date_time_str}} end end defp parse_date_time({format, padding, _width} = modifier, date_time_str, opts, acc) when format in 'aAbBpP' do with {:ok, value, rest} <- parse_string(date_time_str, padding, enumeration(format, opts)), {:ok, new_acc} <- put(acc, format, value) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time({format, padding, width} = modifier, date_time_str, _opts, acc) when format in 'dHIjmMqSu' do with {:ok, value, rest} <- parse_pos_integer(date_time_str, padding, width), {:ok, new_acc} <- put(acc, format, value) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time({format, padding, width} = modifier, date_time_str, _opts, acc) when format in 'yY' do with {:ok, value, rest} <- parse_integer(date_time_str, padding, width), {:ok, new_acc} <- put(acc, format, value) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time({?f, _padding, _width} = modifier, date_time_str, _opts, acc) do with {:ok, microsecond, rest} <- parse_pos_integer(date_time_str), {:ok, new_acc} <- put(acc, :microsecond, microsecond) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time({?z, padding, width} = modifier, date_time_str, _opts, acc) do with {:ok, zone_offset, rest} <- parse_signed_integer(date_time_str, padding, width), {:ok, new_acc} <- put(acc, :zone_offset, zone_offset(zone_offset)) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time({?Z, padding, _width} = modifier, date_time_str, _opts, acc) do with {:ok, zone_abbr, rest} <- parse_upcase_string(date_time_str, padding), {:ok, new_acc} <- put(acc, :zone_abbr, zone_abbr) do {:ok, new_acc, rest} else error -> error(error, modifier) end end defp parse_date_time( {format, _padding, _width} = modifier, _date_time_str, %{preferred: format}, _acc ) do {:error, {:cycle, modifier_to_string(modifier)}} end defp parse_date_time({format, _padding, _width}, date_time_str, opts, acc) when format in 'cxX' do {:ok, %__MODULE__{format: compiled_format}} = compile(preferred_format(format, opts)) parse(compiled_format, date_time_str, Map.put(opts, :preferred, format), acc) end defp parse_date_time({?%, _padding, _width}, "%" <> date_time_rest, _opts, acc) do {:ok, acc, date_time_rest} end defp parse_date_time({?%, _padding, _width}, _date_time_rest, _opts, _acc) do {:error, {:invalid_string, modifier: "%%"}} end defp parse_date_time(modifier, _date_time_str, _opts, _acc) do {:error, {:invalid_modifier, modifier: modifier_to_string(modifier)}} end defp parse_integer(str, padding, width, int \\ nil) defp parse_integer("-" <> int_str, padding, width, nil) do with {:ok, int, rest} <- parse_pos_integer(int_str, padding, width, 0) do {:ok, int * -1, rest} end end defp parse_integer(int_str, padding, width, nil) do parse_pos_integer(int_str, padding, width, nil) end defp parse_pos_integer(str, int \\ nil) defp parse_pos_integer(<>, int) when digit in ?0..?9 do parse_pos_integer(rest, (int || 0) * 10 + (digit - ?0)) end defp parse_pos_integer(_rest, nil), do: {:error, :invalid_integer} defp parse_pos_integer(rest, int), do: {:ok, int, rest} defp parse_pos_integer(str, padding, width, int \\ nil) defp parse_pos_integer(rest, _padding, width, nil) when width < 1, do: {:ok, 0, rest} defp parse_pos_integer(rest, _padding, width, int) when width < 1, do: {:ok, int, rest} defp parse_pos_integer(<>, "" = padding, width, int) when digit in ?0..?9 do parse_pos_integer(rest, padding, width - 1, (int || 0) * 10 + (digit - ?0)) end defp parse_pos_integer(rest, "" = _padding, _width, int), do: {:ok, int, rest} defp parse_pos_integer(<>, padding, width, nil = acc) do parse_pos_integer(rest, padding, width - 1, acc) end defp parse_pos_integer(<>, padding, width, int) when digit in ?0..?9 do parse_pos_integer(rest, padding, width - 1, (int || 0) * 10 + (digit - ?0)) end defp parse_pos_integer(_str, _padding, _width, _int), do: {:error, :invalid_integer} defp parse_signed_integer("-" <> str, padding, width) do with {:ok, value, rest} <- parse_pos_integer(str, padding, width) do {:ok, value * -1, rest} end end defp parse_signed_integer("+" <> str, padding, width), do: parse_pos_integer(str, padding, width) defp parse_signed_integer(_str, _padding, _width), do: {:error, :invalid_integer} defp parse_string(str, padding, list, pos \\ 0) defp parse_string(<>, padding, list, 0 = pos) do parse_string(rest, padding, list, pos) end defp parse_string(_str, _padding, [], _pos), do: {:error, :invalid_string} defp parse_string(str, padding, [item | list], pos) do case String.starts_with?(str, item) do false -> parse_string(str, padding, list, pos + 1) true -> {:ok, pos + 1, String.slice(str, String.length(item)..-1)} end end defp parse_upcase_string(str, padding, acc \\ []) defp parse_upcase_string(<>, padding, [] = acc) do parse_upcase_string(rest, padding, acc) end defp parse_upcase_string(<>, padding, acc) when char in ?A..?Z do parse_upcase_string(rest, padding, [char | acc]) end defp parse_upcase_string(_rest, _padding, []), do: {:error, :invalid_string} defp parse_upcase_string(rest, _padding, acc) do {:ok, acc |> Enum.reverse() |> IO.iodata_to_binary(), rest} end defp modifier_to_string({format, padding, width}) do IO.iodata_to_binary([ "%", padding_to_string(padding, format), width_to_string(width, format), format ]) end defp padding_to_string(padding, format) do case padding == default_padding(format) do true -> "" false -> padding end end defp width_to_string(width, format) do case width == default_width(format) do true -> "" false -> to_string(width) end end defp error({:error, {:conflict, {expected, got}}}, modifier) do {:error, {:conflict, expected: expected, got: got, modifier: modifier_to_string(modifier)}} end defp error({:error, reason}, modifier) do {:error, {reason, modifier: modifier_to_string(modifier)}} end defp zone_offset(value) do hour = div(value, 100) minute = rem(value, 100) hour * 3600 + minute * 60 end defp default_padding(format) when format in 'aAbBpPZ', do: ?\s defp default_padding(_format), do: ?0 defp default_width(format) when format in 'Yz', do: 4 defp default_width(?j), do: 3 defp default_width(format) when format in 'dHImMSy', do: 2 defp default_width(format) when format in 'qu', do: 1 defp default_width(_format), do: 0 defp put(acc, key, value) when is_atom(key) do case Map.fetch(acc, key) do {:ok, ^value} -> {:ok, acc} {:ok, expected} -> {:error, {:conflict, {expected, value}}} :error -> {:ok, Map.put(acc, key, value)} end end defp put(acc, format, 1) when format in 'pP', do: put(acc, :am_pm, :am) defp put(acc, format, 2) when format in 'pP', do: put(acc, :am_pm, :pm) defp put(acc, format, value), do: put(acc, key(format), value) defp key(format) when format in 'aA', do: :day_of_week defp key(format) when format in 'bB', do: :month defp key(?d), do: :day defp key(?H), do: :hour defp key(?I), do: :hour_12 defp key(?j), do: :day_of_year defp key(?m), do: :month defp key(?M), do: :minute defp key(?y), do: :year_2_digit defp key(?Y), do: :year defp key(?q), do: :quarter defp key(?S), do: :second defp key(?u), do: :day_of_week defp preferred_format(?c, opts), do: opts.preferred_datetime defp preferred_format(?x, opts), do: opts.preferred_date defp preferred_format(?X, opts), do: opts.preferred_time defp enumeration(?a, opts), do: opts.abbreviated_day_of_week_names defp enumeration(?A, opts), do: opts.day_of_week_names defp enumeration(?b, opts), do: opts.abbreviated_month_names defp enumeration(?B, opts), do: opts.month_names defp enumeration(?p, opts), do: [String.upcase(opts.am_pm_names[:am]), String.upcase(opts.am_pm_names[:pm])] defp enumeration(?P, opts), do: [String.downcase(opts.am_pm_names[:am]), String.downcase(opts.am_pm_names[:pm])] defp options(opts) do defaults = %{ preferred_date: "%Y-%m-%d", preferred_time: "%H:%M:%S", preferred_datetime: "%Y-%m-%d %H:%M:%S", am_pm_names: [am: "am", pm: "pm"], month_names: [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], day_of_week_names: [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ], abbreviated_month_names: [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ], abbreviated_day_of_week_names: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] } opts |> Keyword.delete(:calendar) |> Enum.reduce_while({:ok, defaults}, fn {key, value}, {:ok, acc} -> case Map.has_key?(acc, key) do true -> {:cont, {:ok, %{acc | key => value}}} false -> {:halt, {:error, {:unknown, option: key}}} end end) end end