lib/cldr/messages/backend.ex

defmodule Cldr.Message.Backend do
  def define_message_module(config) do
    module = __MODULE__
    backend = config.backend
    config = Macro.escape(config)

    quote location: :keep, bind_quoted: [module: module, backend: backend, config: config] do
      defmodule Message do
        @moduledoc false

        if Cldr.Config.include_module_docs?(config.generate_docs) do
          @moduledoc """
          Supports the CLDR Message format

          """
        end

        @doc """
        Returns a map of configured custom message formats

        """
        # TODO remove when we release ex_cldr 2.26
        if config.message_formats == [] do
          def configured_message_formats do
            %{}
          end
        else
          def configured_message_formats do
            unquote(Macro.escape(config.message_formats))
          end
        end

        @doc """
        Returns the requested custom format or `nil`

        """
        def configured_message_format(format) when is_atom(format) do
          configured_message_formats()
          |> Map.get(format, [])
        end

        @doc """
        Formats an ICU message

        This macro parses the message at compile time in
        order to optimize performance at runtime.

        See `Cldr.Message.format/3` for details on the
        arguments and options

        """
        defmacro format(message, bindings \\ [], options \\ []) do
          alias Cldr.Message.Parser
          alias Cldr.Message.Backend

          message = Backend.expand_to_binary!(message, __CALLER__)
          backend = unquote(backend)

          case Parser.parse(message) do
            {:ok, parsed_message} ->
              Backend.validate_bindings!(parsed_message, bindings)

              quote do
                options =
                  unquote(options)
                  |> Keyword.put(:backend, unquote(backend))
                  |> Keyword.put_new(:locale, Cldr.get_locale(unquote(backend)))

                unquote(parsed_message)
                |> Cldr.Message.format_list!(unquote(bindings), options)
                |> :erlang.iolist_to_binary()
              end

            {:error, {exception, reason}} ->
              raise exception, reason
          end
        end
      end
    end
  end

  # Interpolate the message which may be in binary form or
  # parsed form.

  @doc false
  def gettext_interpolate(message, bindings, options) when is_binary(message) do
    case Cldr.Message.format_to_iolist(message, bindings, options) do
      {:ok, iolist, _bound, [] = _unbound} ->
        {:ok, :erlang.iolist_to_binary(iolist)}

      {:error, _iolist, _bound, unbound} ->
        {:missing_bindings, message, unbound}
    end
  end

  @doc false
  def gettext_interpolate(parsed, bindings, options) when is_list(parsed) do
    case Cldr.Message.format_list(parsed, bindings, options) do
      {:ok, iolist, _bound, [] = _unbound} ->
        {:ok, :erlang.iolist_to_binary(iolist)}

      {:error, _iolist, _bound, unbound} ->
        {:missing_bindings, parsed, unbound}
    end
  end

  # Return a keyword list of static bindings, if they
  # are all static. Otherwise return nil.

  @doc false
  def static_bindings(bindings) when is_list(bindings) do
    Enum.reduce_while(bindings, [], fn binding, acc ->
      case binding do
        {_key, value} = term when is_number(value) ->
          {:cont, [term | acc]}

        {_key, value} = term when is_binary(value) ->
          {:cont, [term | acc]}

        {key, {:<<>>, _, pieces}} ->
          if Enum.all?(pieces, &is_binary/1) do
            [{:cont, {key, Enum.join(pieces)}} | acc]
          else
            {:halt, nil}
          end

        _other ->
          {:halt, nil}
      end
    end)
  end

  def static_bindings(_other) do
    nil
  end

  @doc false
  def validate_bindings!(message, bindings) do
    prewalk(message, fn
      {:named_arg, arg} -> validate_binding!(arg, bindings)
      {:pos_arg, arg} -> validate_binding!(arg, bindings)
      other -> other
    end)
  end

  defp validate_binding!(arg, bindings) do
    arg = String.to_atom(arg)

    if has_key?(arg, bindings) do
      :ok
    else
      raise KeyError,
        message: "No argument binding was found for #{inspect(arg)} in #{inspect(bindings)}",
        key: arg,
        term: bindings
    end
  end

  defp has_key?(arg, bindings) when is_list(bindings) do
    Keyword.has_key?(bindings, arg)
  end

  # A map binding. Treat the arg list
  # as a keyword list
  defp has_key?(arg, {:%{}, _, list}) do
    has_key?(arg, list)
  end

  # Dynamic runtime bindngs - we can't check
  defp has_key?(_arg, _bindings) do
    true
  end

  # Walks the parsed message structure and executes
  # a given function
  defp prewalk([], _fun) do
    []
  end

  defp prewalk([head | rest], fun) do
    case head do
      {:plural, arg, _, plurals} ->
        fun.(arg)
        Enum.each(plurals, fn {_k, v} -> prewalk(v, fun) end)

      {:select, arg, selections} ->
        fun.(arg)
        Enum.each(selections, fn {_k, v} -> prewalk(v, fun) end)

      {:select_ordinal, arg, plurals} ->
        fun.(arg)
        Enum.each(plurals, fn {_k, v} -> prewalk(v, fun) end)

      other ->
        fun.(other)
    end

    prewalk(rest, fun)
  end

  @doc """
  Expands the given `message` in the given `env`, raising if it doesn't expand to
  a binary.
  """
  @spec expand_to_binary!(binary, Macro.Env.t()) :: binary | no_return
  def expand_to_binary!(term, env) do
    raiser = fn term ->
      raise ArgumentError, """
      Cldr.Message macros expect translation keys to expand to strings at compile-time, but the
      given doesn't. This is what the macro received:

        #{inspect(term)}

      Dynamic translations should be avoided as they limit the
      ability to extract translations from your source code. If you are
      sure you need dynamic lookup, you can use the functions in the Cldr.Message
      module:

        string = "hello world"
        Cldr.Message.format(string)

      """
    end

    case Macro.expand(term, env) do
      term when is_binary(term) ->
        term

      {:<<>>, _, pieces} = term ->
        if Enum.all?(pieces, &is_binary/1), do: Enum.join(pieces), else: raiser.(term)

      other ->
        raiser.(other)
    end
  end
end