lib/cldr/messages/messages.ex

defmodule Cldr.Message do
  @moduledoc """
  Implements the [ICU Message Format](http://userguide.icu-project.org/formatparse/messages)
  with functions to parse and interpolate messages.

  """
  alias Cldr.Message.{Parser, Interpreter, Print}
  import Kernel, except: [to_string: 1, binding: 1]
  defdelegate format_list(message, args, options), to: Interpreter
  defdelegate format_list!(message, args, options), to: Interpreter

  @type message :: binary()
  @type bindings :: list() | map()
  @type arguments :: bindings()
  @type options :: Keyword.t()

  @doc false
  def cldr_backend_provider(config) do
    Cldr.Message.Backend.define_message_module(config)
  end

  @doc """
  Returns the translation of the given ICU-formatted
  message string.

  Any placeholders are replaced with the value of variables
  already in scope at the time of compilation.

  `t/1` is a wrapper around the `gettext/2` macro
  which should therefore be imported from a `Gettext`
  backend prior to calling `t/1`.

  ## Arguments

  * `message` is an ICU format message string.

  ## Returns

  * A translated string.

  """
  defmacro t(message) when is_binary(message) do
    caller = __CALLER__.module
    canonical_message = Cldr.Message.canonical_message!(message, pretty: true)

    bindings =
      Enum.map(bindings(message), fn binding ->
        {String.to_atom(binding), {String.to_atom(binding), [if_undefined: :apply], caller}}
      end)

    quote do
      gettext(unquote(canonical_message), unquote(bindings))
    end
  end

  @doc """
  Returns the translation of the given ICU-formatted
  message string.

  `t/2` is a wrapper around the `gettext/2` macro
  which should therefore be imported from a `Gettext`
  backend prior to calling `t/2`.

  ## Arguments

  * `message` is an ICU format message string.

  * `bindings` is a keyword list or map of bindings used
    to replace placeholders in the message.

  ## Returns

  * A translated string.

  """
  defmacro t(message, bindings) when is_binary(message) do
    canonical_message = Cldr.Message.canonical_message!(message, pretty: true)

    quote do
      gettext(unquote(canonical_message), unquote(bindings))
    end
  end

  @doc """
  Format a message in the [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages)
  into a string.

  The ICU Message Format uses message `"pattern"` strings with
  variable-element placeholders enclosed in {curly braces}. The
  argument syntax can include formatting details, otherwise a
  default format is used.

  ## Arguments

  * `bindings` is a list or map of arguments that
    are used to replace placeholders in the message.

  * `options` is a keyword list of options.

  ## Options

  * `backend` is any `Cldr` backend. That is, any module that
    contains `use Cldr`.

  * `:locale` is any valid locale name returned by `Cldr.known_locale_names/0`
    or a `t:Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`.

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `false`.

  * `:allow_positional_args` determines if position arguments
    are permitted. Positional arguments are in the format
    `{0}` in the message. The default is `true`.

  * All other aptions are passed to the `to_string/2`
    function of a formatting module.

  ## Returns

  * `{:ok, formatted_mesasge}` or

  * `{:error, {module, reason}}`

  ## Examples

      iex> Cldr.Message.format "{greeting} to you!", greeting: "Good morning"
      {:ok, "Good morning to you!"}

  """

  @spec format(String.t(), bindings(), options()) ::
          {:ok, String.t()} | {:error, {module(), String.t()}}

  def format(message, bindings \\ [], options \\ []) when is_binary(message) do
    case format_to_iolist(message, bindings, options) do
      {:ok, iolist, _bound, []} ->
        {:ok, :erlang.iolist_to_binary(iolist)}

      {:error, _iolist, _bound, unbound} ->
        {:error, {Cldr.Message.BindError, "No binding was found for #{inspect(unbound)}"}}

      other ->
        other
    end
  end

  @spec format!(String.t(), bindings(), options()) :: String.t() | no_return

  def format!(message, args \\ [], options \\ []) when is_binary(message) do
    case format(message, args, options) do
      {:ok, binary} ->
        binary

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

  @doc """
  Format a message in the [ICU Message Format](https://unicode-org.github.io/icu/userguide/format_parse/messages)
  into an iolist.

  The ICU Message Format uses message `"pattern"` strings with
  variable-element placeholders enclosed in {curly braces}. The
  argument syntax can include formatting details, otherwise a
  default format is used.

  ## Arguments

  * `bindings` is a list or map of arguments that
    are used to replace placeholders in the message.

  * `options` is a keyword list of options.

  ## Options

  * `backend` is any `Cldr` backend. That is, any module that
    contains `use Cldr`.

  * `:locale` is any valid locale name returned by `Cldr.known_locale_names/0`
    or a `t:Cldr.LanguageTag` struct.  The default is `Cldr.get_locale/0`.

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `false`.

  * `:allow_positional_args` determines if position arguments
    are permitted. Positional arguments are in the format
    `{0}` in the message. The default is `true`.

  * All other aptions are passed to the `to_string/2`
    function of a formatting module.

  ## Returns

  * `{:ok, formatted_mesasge}` or

  * `{:error, {module, reason}}`

  ## Examples

      iex> Cldr.Message.format_to_iolist "{greeting} to you!", greeting: "Good morning"
      {:ok, ["Good morning", " to you!"], ["greeting"], []}

  """

  @spec format_to_iolist(String.t(), bindings(), options()) ::
          {:ok, list(), list(), list()}
          | {:error, list(), list(), list()}
          | {:error, {module(), binary()}}

  def format_to_iolist(message, bindings \\ [], options \\ []) when is_binary(message) do
    {locale, backend} = Cldr.locale_and_backend_from(options)

    options =
      default_options()
      |> Keyword.merge(options)
      |> Keyword.put_new(:locale, locale)
      |> Keyword.put_new(:backend, backend)

    with {:ok, message} <- maybe_trim(message, options[:trim]),
         {:ok, parsed} <- Parser.parse(message, options[:allow_positional_args]) do
      format_list(parsed, bindings, options)
    end
  rescue
    e in [Cldr.FormatCompileError, Cldr.Message.ParseError] ->
      {:error, {e.__struct__, e.message}}
  end

  @doc """
  Returns the [Jaro distance](https://en.wikipedia.org/wiki/Jaro–Winkler_distance)
  between two messages.

  This allows for fuzzy matching of message
  which can be helpful when a message string
  is changed but the semantics remain the same.

  ## Arguments

  * `message1` is a CLDR message in binary form.

  * `message2` is a CLDR message in binary form.

  * `options` is a keyword list of options. The
    default is `[]`.

  ## Options

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `false`.

  ## Returns

  * `{ok, distance}` where `distance` is a float value between 0.0
    (equates to no similarity) and 1.0 (is an
    exact match) representing Jaro distance between `message1`
    and `message2` or

  * `{:error, {exception, reason}}`

  ## Examples

      iex> Cldr.Message.jaro_distance "{greetings} to you!", "{greeting} to you!"
      {:ok, 0.9824561403508771}

  """
  def jaro_distance(message1, message2, options \\ []) do
    with {:ok, message1} <- maybe_trim(message1, options[:trim]),
         {:ok, message2} <- maybe_trim(message2, options[:trim]),
         {:ok, message1_ast} <- Parser.parse(message1),
         {:ok, message2_ast} <- Parser.parse(message2) do
      canonical_message1 = Print.to_string(message1_ast)
      canonical_message2 = Print.to_string(message2_ast)
      {:ok, String.jaro_distance(canonical_message1, canonical_message2)}
    end
  end

  @doc """
  Returns the [Jaro distance](https://en.wikipedia.org/wiki/Jaro–Winkler_distance)
  between two messages or raises.

  This allows for fuzzy matching of message
  which can be helpful when a message string
  is changed but the semantics remain the same.

  ## Arguments

  * `message1` is a CLDR message in binary form.

  * `message2` is a CLDR message in binary form.

  * `options` is a keyword list of options. The
    default is `[]`.

  ## Options

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `false`.

  ## Returns

  * `distance` where `distance` is a float value between 0.0
    (equates to no similarity) and 1.0 (is an
    exact match) representing Jaro distance between `message1`
    and `message2` or

  * raises an exception

  ## Examples

      iex> Cldr.Message.jaro_distance! "{greetings} to you!", "{greeting} to you!"
      0.9824561403508771

  """
  def jaro_distance!(message1, message2, options \\ []) do
    case jaro_distance(message1, message2, options) do
      {:ok, distance} -> distance
      {:error, {exception, reason}} -> raise exception, reason
    end
  end

  @doc """
  Formats a message into a canonical form.

  This allows for messages to be compared
  directly, or using `Cldr.Message.jaro_distance/3`.

  ## Arguments

  * `message` is a CLDR message in binary form.

  * `options` is a keyword list of options. The
    default is `[]`.

  ## Options

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `true`.

  * `:pretty` determines if the message if
    formatted with indentation to aid readability.
    The default is `false`.

  ## Returns

  * `{ok, canonical_message}` where `canonical_message`
    is a string or

  * `{:error, {exception, reason}}`

  ## Examples

      iex> Cldr.Message.canonical_message "{greeting } to you!"
      {:ok, "{greeting} to you!"}

  """
  def canonical_message(message, options \\ []) do
    options = Keyword.put_new(options, :trim, true)

    with {:ok, message} <- maybe_trim(message, options[:trim]),
         {:ok, message_ast} <- Parser.parse(message) do
      {:ok, Print.to_string(message_ast, options)}
    end
  end

  @doc """
  Formats a message into a canonical form
  or raises if the message cannot be parsed.

  This allows for messages to be compared
  directly, or using `Cldr.Message.jaro_distance/3`.

  ## Arguments

  * `message` is a CLDR message in binary form.

  * `options` is a keyword list of options. The
    default is `[]`.

  ## Options

  * `:trim` determines if the message is trimmed
    of whitespace before formatting. The default is
    `true`.

  * `:pretty` determines if the message if
    formatted with indentation to aid readability.
    The default is `false`.

  ## Returns

  * `canonical_message` as a string or

  * raises an exception

  ## Examples

      iex> Cldr.Message.canonical_message! "{greeting } to you!"
      "{greeting} to you!"

  """
  def canonical_message!(message, options \\ []) do
    case canonical_message(message, options) do
      {:ok, message} -> message
      {:error, {exception, reason}} -> raise exception, reason
    end
  end

  @doc """
  Extract the binding names from an ICU message.

  ## Arguments

  * `message` is a CLDR message in binary or
    parsed form.

  ### Returns

  * A list of binding names as strings or

  * `{:error, {exception, reason}}`

  ### Examples

       iex> Cldr.Message.bindings "This {variable} is in the message"
       ["variable"]

  """
  def bindings(message) when is_binary(message) do
    with {:ok, parsed} <- Cldr.Message.Parser.parse(message) do
      bindings(parsed)
    end
  end

  def bindings(message) when is_list(message) do
    Enum.reduce(message, [], fn
      {:named_arg, arg}, acc -> [arg | acc]
      {:pos_arg, arg}, acc -> [arg | acc]
      {:select, {_, arg}, selectors}, acc -> [arg, bindings(selectors) | acc]
      {:plural, {_, arg}, _, selectors}, acc -> [arg, bindings(selectors) | acc]
      {:select_ordinal, {_, arg}, _, selectors}, acc -> [arg, bindings(selectors) | acc]
      _other, acc -> acc
    end)
    |> List.flatten()
    |> Enum.uniq()
  end

  def bindings(message) when is_map(message) do
    Enum.map(message, fn {_selector, message} -> bindings(message) end)
  end

  @doc false
  def default_options do
    [trim: false, allow_positional_args: true]
  end

  if Code.ensure_loaded?(Cldr) and function_exported?(Cldr, :default_backend!, 0) do
    @doc false
    def default_backend do
      Cldr.default_backend!()
    end
  else
    @doc false
    def default_backend do
      Cldr.default_backend()
    end
  end

  defp maybe_trim(message, true) do
    {:ok, String.trim(message)}
  end

  defp maybe_trim(message, _) do
    {:ok, message}
  end
end