lib/parse/datetime/tokenizers/strftime.ex

defmodule Timex.Parse.DateTime.Tokenizers.Strftime do
  @moduledoc """
  Implements the parser for strftime-style datetime format strings.
  """
  import Combine.Parsers.Base
  import Combine.Parsers.Text

  use Timex.Parse.DateTime.Tokenizer

  @doc """
  Tokenizes the given format string and returns an error or a list of directives.
  """
  @spec tokenize(String.t()) :: {:ok, [Directive.t()]} | {:error, term}
  def tokenize(<<>>), do: {:error, "Format string cannot be empty."}

  def tokenize(str) do
    case Combine.parse(str, strftime_format_parser()) do
      results when is_list(results) ->
        directives = results |> List.flatten() |> Enum.filter(fn x -> x !== nil end)

        case Enum.any?(directives, fn %Directive{type: type} -> type != :literal end) do
          false -> {:error, "Invalid format string, must contain at least one directive."}
          true -> {:ok, directives}
        end

      {:error, _} = err ->
        err
    end
  end

  @doc """
  Applies a given token + value to the DateTime represented by the current input string.
  """
  @spec apply(DateTime.t(), atom, term) :: DateTime.t() | {:error, term} | :unrecognized
  def apply(_, _, _), do: :unrecognized

  defp flags(), do: map(one_of(char(), ["-", "0", "_"]), &map_flag/1)
  defp min_width(), do: integer()
  defp modifiers(), do: map(one_of(char(), ["E", "O"]), &map_modifier/1)

  defp directives() do
    choice([
      one_of(char(), [
        # Years/Centuries
        "Y",
        "y",
        "C",
        "G",
        "g",
        # Months
        "m",
        "B",
        "b",
        "h",
        # Days, Days of Week
        "d",
        "e",
        "j",
        "u",
        "w",
        "A",
        "a",
        # Weeks
        "V",
        "W",
        "U",
        # Time
        "H",
        "k",
        "I",
        "l",
        "M",
        "S",
        "s",
        "P",
        "p",
        "f",
        "L",
        # Timezones
        "Z",
        "z",
        # Compound
        "D",
        "F",
        "R",
        "r",
        "T",
        "v"
      ]),
      string(":z"),
      string("::z")
    ])
  end

  defp strftime_format_parser() do
    many1(
      choice([
        # %<flag><width><modifier><directive>
        pair_right(
          char("%"),
          pipe(
            [option(flags()), option(min_width()), option(modifiers()), directives()],
            &coalesce_token/1
          )
        ),
        map(none_of(char(), ["%"]), &map_literal/1),
        map(pair_left(char("%"), char("%")), &map_literal/1)
      ])
    )
    |> eof
  end

  defp coalesce_token([flags, width, modifiers, directive]) do
    flags = flags || []
    width = width || -1
    modifiers = modifiers || []
    map_directive(directive, flags: flags, min_width: width, modifiers: modifiers)
  end

  defp map_directive(directive, opts) do
    case directive do
      # Years/Centuries
      "Y" ->
        force_width(4, :year4, directive, opts)

      "y" ->
        force_width(2, :year2, directive, opts)

      "C" ->
        force_width(2, :century, directive, opts)

      "G" ->
        force_width(4, :iso_year4, directive, opts)

      "g" ->
        force_width(2, :iso_year2, directive, opts)

      # Months
      "m" ->
        force_width(2, :month, directive, opts)

      "B" ->
        Directive.get(:mfull, directive, opts)

      "b" ->
        Directive.get(:mshort, directive, opts)

      "h" ->
        Directive.get(:mshort, directive, opts)

      # Days
      "d" ->
        force_width(2, :day, directive, opts)

      "e" ->
        force_width(
          2,
          :day,
          directive,
          Keyword.merge(opts, flags: Keyword.merge([padding: :spaces], get_in(opts, [:flags])))
        )

      "j" ->
        force_width(3, :oday, directive, opts)

      # Weeks
      "V" ->
        force_width(2, :iso_weeknum, directive, opts)

      "W" ->
        force_width(2, :week_mon, directive, opts)

      "U" ->
        force_width(2, :week_sun, directive, opts)

      "u" ->
        Directive.get(:wday_mon, directive, opts)

      "w" ->
        Directive.get(:wday_sun, directive, opts)

      "a" ->
        Directive.get(:wdshort, directive, opts)

      "A" ->
        Directive.get(:wdfull, directive, opts)

      # Hours
      "H" ->
        force_width(2, :hour24, directive, opts)

      "k" ->
        force_width(
          2,
          :hour24,
          directive,
          Keyword.merge(opts, flags: Keyword.merge([padding: :spaces], get_in(opts, [:flags])))
        )

      "I" ->
        force_width(2, :hour12, directive, opts)

      "l" ->
        force_width(
          2,
          :hour12,
          directive,
          Keyword.merge(opts, flags: Keyword.merge([padding: :spaces], get_in(opts, [:flags])))
        )

      "M" ->
        force_width(2, :min, directive, opts)

      "S" ->
        force_width(2, :sec, directive, opts)

      "s" ->
        Directive.get(:sec_epoch, directive, opts)

      "P" ->
        Directive.get(:am, directive, opts)

      "p" ->
        Directive.get(:AM, directive, opts)

      "f" ->
        Directive.get(
          :us,
          directive,
          Keyword.merge(opts, flags: Keyword.merge([padding: :zeroes], get_in(opts, [:flags])))
        )

      "L" ->
        force_width(3, :ms, directive, opts)

      # Timezones
      "Z" ->
        Directive.get(:zname, directive, opts)

      "z" ->
        Directive.get(:zoffs, directive, opts)

      ":z" ->
        Directive.get(:zoffs_colon, directive, opts)

      "::z" ->
        Directive.get(:zoffs_sec, directive, opts)

      # Preformatted Directives
      "D" ->
        Directive.get(:slashed, directive, opts)

      "F" ->
        Directive.get(:iso_date, directive, opts)

      "R" ->
        Directive.get(:strftime_iso_clock, directive, opts)

      "r" ->
        Directive.get(:strftime_kitchen, directive, opts)

      "T" ->
        Directive.get(:strftime_iso_clock_full, directive, opts)

      "v" ->
        Directive.get(:strftime_iso_shortdate, directive, opts)

      # Literals
      "n" ->
        %Directive{value: "\n"}

      "t" ->
        %Directive{value: "\t"}
    end
  end

  defp force_width(size, type, directive, opts) do
    flags = Keyword.merge([padding: :zeroes], get_in(opts, [:flags]))
    mods = get_in(opts, [:modifiers])
    Directive.get(type, directive, flags: flags, modifiers: mods, width: [min: size, max: size])
  end

  defp map_literal([]), do: nil

  defp map_literal(literals)
       when is_list(literals),
       do: Enum.map(literals, &map_literal/1)

  defp map_literal(literal), do: %Directive{type: :literal, value: literal, parser: char(literal)}

  defp map_flag(flag) do
    case flag do
      "_" -> [padding: :spaces]
      "-" -> [padding: :none]
      "0" -> [padding: :zeroes]
      "^" -> [transform: &String.upcase/1]
      "#" -> [transform: &swap_case/1]
      _ -> []
    end
  end

  defp swap_case(<<char::utf8, _::binary>> = str)
       when char in ?a..?z,
       do: String.upcase(str)

  defp swap_case(<<char::utf8, _::binary>> = str)
       when char in ?A..?Z,
       do: String.downcase(str)

  defp swap_case(str), do: str

  defp map_modifier(modifier) do
    case modifier do
      "E" -> [:locale_dependent_numerics]
      "O" -> [:alternative_numerics]
      _ -> []
    end
  end
end