lib/cldr/messages/parser/parser.ex

defmodule Cldr.Message.Parser do
  @moduledoc """
  Implements a parser for the [ICU Message format](http://userguide.icu-project.org/formatparse/messages)

  """

  import NimbleParsec
  import Cldr.Message.Parser.Combinator

  # To ensure these atoms are instantiated in the VM before
  # parsing

  _plural_types = Cldr.Number.PluralRule.known_plural_types()

  @rule :message

  def parse(input, allow_positional_args? \\ true) when is_binary(input) do
    with {:ok, parsed} <- unwrap(apply(__MODULE__, @rule, [input])) do
      parsed
      |> remove_nested_whitespace()
      |> maybe_allow_positional_args(allow_positional_args?)
    else
      {:ok, _result, rest} ->
        {:error,
         {Cldr.Message.ParseError, "Couldn't parse message. Error detected at #{inspect(rest)}"}}
    end
  rescue
    exception in [Cldr.Message.ParseError] ->
      {:error, {Cldr.Message.ParseError, exception.message}}
  end

  def parse!(input, allow_positional_args? \\ true) when is_binary(input) do
    case parse(input, allow_positional_args?) do
      {:ok, parsed} -> parsed
      {:error, {exception, reason}} -> raise exception, reason
    end
  end

  defparsec :message, message()
  defparsec :plural_message, plural_message()

  defp unwrap({:ok, acc, "", _, _, _}) when is_list(acc) do
    {:ok, acc}
  end

  defp unwrap({:ok, acc, rest, _, _, _}) when is_list(acc) do
    {:ok, acc, rest}
  end

  defp unwrap({:error, <<first::binary-size(1), reason::binary>>, rest, _, _, offset}) do
    {:error,
     {Cldr.Message.ParseError,
      "#{String.capitalize(first)}#{reason}. Could not parse the remaining #{inspect(rest)} " <>
        "starting at position #{offset + 1}"}}
  end

  defp remove_nested_whitespace([maybe_complex]) do
    [remove_nested_whitespace(maybe_complex)]
  end

  defp remove_nested_whitespace([{complex_arg, _arg, _selects} = complex | rest])
       when complex_arg in [:plural, :select, :select_ordinal] do
    [remove_nested_whitespace(complex), remove_nested_whitespace(rest)]
  end

  defp remove_nested_whitespace([head | rest]) do
    [head | remove_nested_whitespace(rest)]
  end

  defp remove_nested_whitespace({complex_arg, arg, selects})
       when complex_arg in [:select, :plural, :select_ordinal] do
    selects =
      Enum.map(selects, fn
        {k, [{:literal, string} = literal, {:select, _, _} = select | other]} ->
          if is_whitespace?(string), do: {k, [select | other]}, else: [literal, select | other]

        {k, [{:literal, string} = literal, {:plural, _, _, _} = plural | other]} ->
          if is_whitespace?(string), do: {k, [plural | other]}, else: [literal, plural | other]

        {k, [{:literal, string} = literal, {:select_ordinal, _, _} = select | other]} ->
          if is_whitespace?(string), do: {k, [select | other]}, else: [literal, select | other]

        other ->
          other
      end)
      |> Map.new()

    {complex_arg, arg, selects}
  end

  defp remove_nested_whitespace(other) do
    other
  end

  defp is_whitespace?(<<" ", rest::binary>>) do
    is_whitespace?(rest)
  end

  defp is_whitespace?(<<"\n", rest::binary>>) do
    is_whitespace?(rest)
  end

  defp is_whitespace?(<<"\t", rest::binary>>) do
    is_whitespace?(rest)
  end

  defp is_whitespace?(<<_char::bytes-1, _rest::binary>>) do
    false
  end

  defp is_whitespace?("") do
    true
  end

  defp maybe_allow_positional_args(parsed, allow_positional_args?)
       when allow_positional_args? != false do
    {:ok, parsed}
  end

  defp maybe_allow_positional_args(parsed, _allow_positional_args?) do
    has_positional_args? =
      parsed
      |> Cldr.Message.bindings()
      |> Enum.any?(&is_integer/1)

    if has_positional_args? do
      {:error,
       {Cldr.Message.PositionalArgsNotPermitted, "Positional arguments are not permitted"}}
    else
      {:ok, parsed}
    end
  end
end