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([ # % 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(<> = str) when char in ?a..?z, do: String.upcase(str) defp swap_case(<> = 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