defmodule Gettext.Interpolation.Default do
@moduledoc """
Default implementation for the `Gettext.Interpolation` behaviour.
Replaces `%{binding_name}` with the string value of the `binding_name` binding.
"""
@behaviour Gettext.Interpolation
@typedoc """
Something that can be interpolated.
It's either a string (a literal) or an atom (representing a binding name).
"""
@type interpolatable() :: [String.t() | atom()]
# Extracts interpolations from a given string.
# This function extracts all interpolations in the form `%{interpolation}`
# contained inside `str`, converts them to atoms and then returns a list of
# string and interpolation keys.
@doc false
@spec to_interpolatable(String.t()) :: interpolatable()
def to_interpolatable(string) when is_binary(string) do
start_pattern = :binary.compile_pattern("%{")
end_pattern = :binary.compile_pattern("}")
string
|> to_interpolatable(_current = "", _acc = [], start_pattern, end_pattern)
|> Enum.reverse()
end
defp to_interpolatable(string, current, acc, start_pattern, end_pattern) do
case :binary.split(string, start_pattern) do
# If we have one element, no %{ was found so this is the final part of the
# string.
[rest] ->
prepend_if_not_empty(current <> rest, acc)
# If we found a %{ but it's followed by an immediate }, then we just
# append %{} to the current string and keep going.
[before, "}" <> rest] ->
new_current = current <> before <> "%{}"
to_interpolatable(rest, new_current, acc, start_pattern, end_pattern)
# Otherwise, we found the start of a binding.
[before, binding_and_rest] ->
case :binary.split(binding_and_rest, end_pattern) do
# If we don't find the end of this binding, it means we're at a string
# like "foo %{ no end". In this case we consider no bindings to be
# there.
[_] ->
[current <> string | acc]
# This is the case where we found a binding, so we put it in the acc
# and keep going.
[binding, rest] ->
new_acc = [String.to_atom(binding) | prepend_if_not_empty(before, acc)]
to_interpolatable(rest, "", new_acc, start_pattern, end_pattern)
end
end
end
defp prepend_if_not_empty("", list), do: list
defp prepend_if_not_empty(string, list), do: [string | list]
@doc """
Interpolate a message or interpolatable with the given bindings.
Implementation of the `c:Gettext.Interpolation.runtime_interpolate/2` callback.
This function takes a message and some bindings and returns an `{:ok,
interpolated_string}` tuple if interpolation is successful. If it encounters
a binding in the message that is missing from `bindings`, it returns
`{:missing_bindings, incomplete_string, missing_bindings}` where
`incomplete_string` is the string with only the present bindings interpolated
and `missing_bindings` is a list of atoms representing bindings that are in
`interpolatable` but not in `bindings`.
## Examples
iex> msgid = "Hello %{name}, you have %{count} unread messages"
iex> good_bindings = %{name: "José", count: 3}
iex> Gettext.Interpolation.Default.runtime_interpolate(msgid, good_bindings)
{:ok, "Hello José, you have 3 unread messages"}
iex> Gettext.Interpolation.Default.runtime_interpolate(msgid, %{name: "José"})
{:missing_bindings, "Hello José, you have %{count} unread messages", [:count]}
iex> msgid = "Hello %{name}, you have %{count} unread messages"
iex> interpolatable = Gettext.Interpolation.Default.to_interpolatable(msgid)
iex> good_bindings = %{name: "José", count: 3}
iex> Gettext.Interpolation.Default.runtime_interpolate(interpolatable, good_bindings)
{:ok, "Hello José, you have 3 unread messages"}
iex> Gettext.Interpolation.Default.runtime_interpolate(interpolatable, %{name: "José"})
{:missing_bindings, "Hello José, you have %{count} unread messages", [:count]}
"""
@impl true
def runtime_interpolate(message, bindings)
def runtime_interpolate(message, %{} = bindings) when is_binary(message) do
message |> to_interpolatable() |> runtime_interpolate(bindings)
end
def runtime_interpolate(interpolatable, %{} = bindings) when is_list(interpolatable) do
interpolate(interpolatable, bindings, [], [])
end
defp interpolate([string | segments], bindings, strings, missing) when is_binary(string) do
interpolate(segments, bindings, [string | strings], missing)
end
defp interpolate([atom | segments], bindings, strings, missing) when is_atom(atom) do
case bindings do
%{^atom => value} ->
interpolate(segments, bindings, [to_string(value) | strings], missing)
%{} ->
strings = ["%{" <> Atom.to_string(atom) <> "}" | strings]
interpolate(segments, bindings, strings, [atom | missing])
end
end
defp interpolate([], _bindings, strings, []) do
{:ok, IO.iodata_to_binary(Enum.reverse(strings))}
end
defp interpolate([], _bindings, strings, missing) do
missing = missing |> Enum.reverse() |> Enum.uniq()
{:missing_bindings, IO.iodata_to_binary(Enum.reverse(strings)), missing}
end
# Returns all the interpolation keys contained in the given string or list of
# segments.
# This function returns a list of all the interpolation keys (patterns in the
# form `%{interpolation}`) contained in its argument.
# If the argument is a segment list, that is, a list of strings and atoms where
# atoms represent interpolation keys, then only the atoms in the list are
# returned.
@doc false
@spec keys(String.t() | interpolatable()) :: [atom()]
def keys(string_or_interpolatable)
def keys(string) when is_binary(string), do: string |> to_interpolatable() |> keys()
def keys(interpolatable) when is_list(interpolatable),
do: interpolatable |> Enum.filter(&is_atom/1) |> Enum.uniq()
@doc """
Compiles a static message to interpolate with dynamic bindings.
Implementation of the `c:Gettext.Interpolation.compile_interpolate/3` macro callback.
Takes a static message and some dynamic bindings. The generated
code will return an `{:ok, interpolated_string}` tuple if the interpolation
is successful. If it encounters a binding in the message that is missing from
`bindings`, it returns `{:missing_bindings, incomplete_string, missing_bindings}`,
where `incomplete_string` is the string with only the present bindings interpolated
and `missing_bindings` is a list of atoms representing bindings that are in
`interpolatable` but not in `bindings`.
"""
@impl true
defmacro compile_interpolate(message_type, message, bindings) do
unless is_binary(message) do
raise """
#{inspect(__MODULE__)}.compile_interpolate/2 can only be used at compile time with \
static messages. Alternatively, use #{inspect(__MODULE__)}.runtime_interpolate/2.
"""
end
interpolatable = to_interpolatable(message)
keys = keys(interpolatable)
match_clause = match_clause(keys)
compile_string = compile_string(interpolatable)
case {keys, message_type} do
# If no keys are in the message, the message can be returned without interpolation
{[], _message_type} ->
quote do: {:ok, unquote(message)}
# If the message only contains the key `count` and it is a plural message,
# gettext ensures that `count` is always set. Therefore the dynamic interpolation
# will never be needed.
{[:count], :plural_translation} ->
quote do
unquote(match_clause) = unquote(bindings)
{:ok, unquote(compile_string)}
end
{_keys, _message_type} ->
quote do
case unquote(bindings) do
unquote(match_clause) ->
{:ok, unquote(compile_string)}
%{} = other_bindings ->
unquote(__MODULE__).runtime_interpolate(unquote(interpolatable), other_bindings)
end
end
end
end
# Compiles a list of atoms into a "match" map. For example `[:foo, :bar]` gets
# compiled to `%{foo: foo, bar: bar}`. All generated variables are under the
# current `__MODULE__`.
defp match_clause(keys) do
{:%{}, [], Enum.map(keys, &{&1, Macro.var(&1, __MODULE__)})}
end
# Compiles a string into a binary with `%{var}` patterns turned into `var`
# variables, namespaced inside the current `__MODULE__`.
defp compile_string(interpolatable) do
parts =
Enum.map(interpolatable, fn
key when is_atom(key) ->
quote do: to_string(unquote(Macro.var(key, __MODULE__))) :: binary
str ->
str
end)
{:<<>>, [], parts}
end
@doc """
Implementation of `c:Gettext.Interpolation.message_format/0`.
## Examples
iex> Gettext.Interpolation.Default.message_format()
"elixir-format"
"""
@impl true
def message_format, do: "elixir-format"
end