Repository: elixir-gettext/gettext
Pull request #291 (2021-10-26 13:59:38)

Pluggable Interpolation
Implementation of #290

Depends on #292  merge that PR first.

josevalim: @maennchen I have a suggestion on how to best approach this problem. Instead of trying to make it generic and refactor at the same time, do this. Introduce `Gettext.Interpolation.compile_interpolate/3` and `Gettext.Interpolation.runtime_interpolate/2`, as mentioned in another comment, but without making it configurable. Move all of the code that you need inside those two functions and refactor the code as necessary.

Then, in another PR, you introduce the customization. This should reduce the amount of moving parts and therefore you should be able to leave most macros intact. WDYT? :)
maennchen: @josevalim that's a good suggestion. Expect a pr soonish 🙂

Patch:
diff --git a/lib/gettext.ex b/lib/gettext.ex
index dfcfc3a..b09446f 100644
--- a/lib/gettext.ex
+++ b/lib/gettext.ex
@@ -496,6 +496,9 @@ defmodule Gettext do
       This option can be useful in development to reduce compile-time
       by compiling only a subset of all available locales.
 
+    * `:interpolation` - the name of a module that implements the
+      `Gettext.Interpolation` behaviour. Default: `Gettext.Interpolation.Default`
+
   ### Mix tasks configuration
 
   You can configure Gettext Mix tasks under the `:gettext` key in the
@@ -595,7 +598,13 @@ defmodule Gettext do
 
       opts = unquote(opts)
       otp_app = Keyword.fetch!(opts, :otp_app)
-      @gettext_opts Keyword.merge(opts, Application.unquote(env_fun)(otp_app, __MODULE__, []))
+
+      @gettext_opts opts
+                    |> Keyword.merge(Application.unquote(env_fun)(otp_app, __MODULE__, []))
+                    |> Keyword.put_new(:interpolation, Gettext.Interpolation.Default)
+
+      @interpolation Keyword.fetch!(@gettext_opts, :interpolation)
+
       @before_compile Gettext.Compiler
 
       def handle_missing_bindings(exception, incomplete) do
@@ -606,22 +615,18 @@ defmodule Gettext do
       defoverridable handle_missing_bindings: 2
 
       def handle_missing_translation(_locale, domain, msgid, bindings) do
-        import Gettext.Interpolation, only: [runtime_interpolate: 2]
-
         Gettext.Compiler.warn_if_domain_contains_slashes(domain)
 
-        with {:ok, interpolated} <- runtime_interpolate(msgid, bindings),
+        with {:ok, interpolated} <- @interpolation.runtime_interpolate(msgid, bindings),
              do: {:default, interpolated}
       end
 
       def handle_missing_plural_translation(_locale, domain, msgid, msgid_plural, n, bindings) do
-        import Gettext.Interpolation, only: [runtime_interpolate: 2]
-
         Gettext.Compiler.warn_if_domain_contains_slashes(domain)
         string = if n == 1, do: msgid, else: msgid_plural
         bindings = Map.put(bindings, :count, n)
 
-        with {:ok, interpolated} <- runtime_interpolate(string, bindings),
+        with {:ok, interpolated} <- @interpolation.runtime_interpolate(string, bindings),
              do: {:default, interpolated}
       end
 
diff --git a/lib/gettext/compiler.ex b/lib/gettext/compiler.ex
index 0965923..8ed109d 100644
--- a/lib/gettext/compiler.ex
+++ b/lib/gettext/compiler.ex
@@ -378,7 +378,10 @@ defmodule Gettext.Compiler do
 
     case List.wrap(opts[:split_module_by]) do
       [] ->
-        Enum.map(known_po_files, &compile_unified_po_file(env, &1, plural_mod))
+        Enum.map(
+          known_po_files,
+          &compile_unified_po_file(env, &1, plural_mod, opts[:interpolation])
+        )
 
       split ->
         grouped = Enum.group_by(known_po_files, &split_module_name(env, &1, split))
@@ -386,14 +389,14 @@ defmodule Gettext.Compiler do
         case Keyword.get(opts, :split_module_compilation, :parallel) do
           :serial ->
             Enum.map(grouped, fn {module, files} ->
-              compile_split_po_files(env, module, files, plural_mod)
+              compile_split_po_files(env, module, files, plural_mod, opts[:interpolation])
             end)
 
           :parallel ->
             grouped
             |> Enum.map(fn {module, files} ->
               Kernel.ParallelCompiler.async(fn ->
-                compile_split_po_files(env, module, files, plural_mod)
+                compile_split_po_files(env, module, files, plural_mod, opts[:interpolation])
               end)
             end)
             |> Enum.map(fn task ->
@@ -411,9 +414,9 @@ defmodule Gettext.Compiler do
     )
   end
 
-  defp compile_unified_po_file(env, po_file, plural_mod) do
+  defp compile_unified_po_file(env, po_file, plural_mod, interpolation_module) do
     {locale, domain, singular_fun, plural_fun, quoted} =
-      compile_po_file(:defp, po_file, env, plural_mod)
+      compile_po_file(:defp, po_file, env, plural_mod, interpolation_module)
 
     quote do
       unquote(quoted)
@@ -428,17 +431,21 @@ defmodule Gettext.Compiler do
     end
   end
 
-  defp compile_split_po_files(env, module, files, plural_mod) do
+  defp compile_split_po_files(env, module, files, plural_mod, interpolation_module) do
     {current, split} =
-      Enum.reduce(files, {[], []}, &compile_split_po_file(env, module, plural_mod, &1, &2))
+      Enum.reduce(
+        files,
+        {[], []},
+        &compile_split_po_file(env, module, plural_mod, &1, interpolation_module, &2)
+      )
 
     create_split_module(env, module, split)
     current
   end
 
-  defp compile_split_po_file(env, module, plural_mod, po_file, {acc1, acc2}) do
+  defp compile_split_po_file(env, module, plural_mod, po_file, interpolation_module, {acc1, acc2}) do
     {locale, domain, singular_fun, plural_fun, split_module_quoted} =
-      compile_po_file(:def, po_file, env, plural_mod)
+      compile_po_file(:def, po_file, env, plural_mod, interpolation_module)
 
     current_module_quoted =
       quote do
@@ -462,13 +469,25 @@ defmodule Gettext.Compiler do
 
   # Compiles a .po file into a list of lgettext/5 (for translations) and
   # lngettext/7 (for plural translations) clauses.
-  defp compile_po_file(kind, po_file, env, plural_mod) do
+  defp compile_po_file(kind, po_file, env, plural_mod, interpolation_module) do
     %{locale: locale, domain: domain, path: path} = po_file
     %PO{translations: translations, file: file} = PO.parse_file!(path)
 
     singular_fun = :"#{locale}_#{domain}_lgettext"
     plural_fun = :"#{locale}_#{domain}_lngettext"
-    mapper = &compile_translation(kind, locale, &1, singular_fun, plural_fun, file, plural_mod)
+
+    mapper =
+      &compile_translation(
+        kind,
+        locale,
+        &1,
+        singular_fun,
+        plural_fun,
+        file,
+        plural_mod,
+        interpolation_module
+      )
+
     translations = block(Enum.map(translations, mapper))
 
     quoted =
@@ -512,7 +531,8 @@ defmodule Gettext.Compiler do
          singular_fun,
          _plural_fun,
          _file,
-         _plural_mod
+         _plural_mod,
+         interpolation_module
        ) do
     msgid = IO.iodata_to_binary(t.msgid)
     msgstr = IO.iodata_to_binary(t.msgstr)
@@ -529,8 +549,13 @@ defmodule Gettext.Compiler do
           Kernel.unquote(kind)(
             unquote(singular_fun)(unquote(msgctxt), unquote(msgid), bindings)
           ) do
-            require Gettext.Interpolation
-            Gettext.Interpolation.compile_interpolate(:translation, unquote(msgstr), bindings)
+            require unquote(interpolation_module)
+
+            unquote(interpolation_module).compile_interpolate(
+              :translation,
+              unquote(msgstr),
+              bindings
+            )
           end
         end
     end
@@ -543,7 +568,8 @@ defmodule Gettext.Compiler do
          _singular_fun,
          plural_fun,
          file,
-         plural_mod
+         plural_mod,
+         interpolation_module
        ) do
     warn_if_missing_plural_forms(locale, plural_mod, t, file)
 
@@ -562,9 +588,9 @@ defmodule Gettext.Compiler do
         Enum.flat_map(msgstr, fn {form, str} ->
           quote do
             unquote(form) ->
-              require Gettext.Interpolation
+              require unquote(interpolation_module)
 
-              Gettext.Interpolation.compile_interpolate(
+              unquote(interpolation_module).compile_interpolate(
                 :plural_translation,
                 unquote(str),
                 var!(bindings)
diff --git a/lib/gettext/interpolation.ex b/lib/gettext/interpolation.ex
index 8f2fe21..e113145 100644
--- a/lib/gettext/interpolation.ex
+++ b/lib/gettext/interpolation.ex
@@ -1,235 +1,17 @@
 defmodule Gettext.Interpolation do
-  @moduledoc false
-
-  @type interpolatable :: [String.t() | atom]
-
-  @doc """
-  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.
-
-  ## Examples
-
-      iex> msgid = "Hello %{name}, you have %{count} unread messages"
-      iex> Gettext.Interpolation.to_interpolatable(msgid)
-      ["Hello ", :name, ", you have ", :count, " unread messages"]
-
-      iex> Gettext.Interpolation.to_interpolatable("Empties %{} stay empty")
-      ["Empties %{} stay empty"]
-
-  """
-  @spec to_interpolatable(String.t()) :: interpolatable
-  def to_interpolatable(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.
-
-  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.runtime_interpolate(msgid, good_bindings)
-      {:ok, "Hello José, you have 3 unread messages"}
-      iex> Gettext.Interpolation.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.to_interpolatable(msgid)
-      iex> good_bindings = %{name: "José", count: 3}
-      iex> Gettext.Interpolation.runtime_interpolate(interpolatable, good_bindings)
-      {:ok, "Hello José, you have 3 unread messages"}
-      iex> Gettext.Interpolation.runtime_interpolate(interpolatable, %{name: "José"})
-      {:missing_bindings, "Hello José, you have %{count} unread messages", [:count]}
-
-  """
-  @spec runtime_interpolate(message :: String.t(), bindings :: map) ::
-          {:ok, String.t()} | {:missing_bindings, String.t(), [atom]}
-  def runtime_interpolate(message, %{} = bindings) when is_binary(message),
-    do: message |> to_interpolatable() |> runtime_interpolate(bindings)
-
-  @spec runtime_interpolate(interpolatable :: interpolatable(), bindings :: map) ::
-          {:ok, String.t()} | {:missing_bindings, String.t(), [atom]}
-  def runtime_interpolate(interpolatable, bindings)
-      when is_list(interpolatable) and is_map(bindings) 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
-
-  @doc """
-  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.
-
-  ## Examples
-
-      iex> Gettext.Interpolation.keys("Hey %{name}, I'm %{other_name}")
-      [:name, :other_name]
-
-      iex> Gettext.Interpolation.keys(["Hello ", :name, "!"])
-      [:name]
-
-      iex> Gettext.Interpolation.keys(["Hello ", :name, "! Goodbye", :name])
-      [:name]
-
+  @moduledoc """
+  Behaviour to provide Gettext String Interpolation.
   """
-  @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 """
-  Compile a static message to interpolate with dynamic bindings.
-
-  This macro 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`.
-  """
-  defmacro compile_interpolate(translation_type, message, bindings) do
-    unless is_binary(message) do
-      raise """
-      #{__MODULE__}.compile_interpolate/3 can only be used at compile time with static messages.
-      Alternatively use #{__MODULE__}.runtime_interpolate/2.
-      """
-    end
-
-    interpolatable = to_interpolatable(message)
-    keys = keys(interpolatable)
-    match_clause = match_clause(keys)
-    compile_string = compile_string(interpolatable)
-
-    case {keys, translation_type} do
-      # If no keys are in the message, the message can be returned without interpolation
-      {[], _translation_type} ->
-        quote do: {:ok, unquote(message)}
-
-      # If the message only contains the key `count` and it is a plural translation,
-      # 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, _translation_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
+  @type translation_type :: :translation | :plural_translation
 
-        str ->
-          str
-      end)
+  @callback runtime_interpolate(message :: String.t(), bindings :: map) ::
+              {:ok, String.t()}
+              | {:mssing_bindings, message :: String.t(), missing_bindings :: [atom]}
 
-    {:<<>>, [], parts}
-  end
+  @macrocallback compile_interpolate(
+                   translation_type :: translation_type,
+                   message :: String.t(),
+                   bindings :: map()
+                 ) :: Macro.t()
 end
diff --git a/lib/gettext/interpolation/default.ex b/lib/gettext/interpolation/default.ex
new file mode 100644
index 0000000..797cfad
--- /dev/null
+++ b/lib/gettext/interpolation/default.ex
@@ -0,0 +1,215 @@
+defmodule Gettext.Interpolation.Default do
+  @moduledoc """
+  Default Interpolation Implementation
+
+  Replaces `%{binding_name}` with a string value.
+  """
+
+  @behaviour Gettext.Interpolation
+
+  @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) 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.
+
+  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 Gettext.Interpolation
+  def runtime_interpolate(message, %{} = bindings) when is_binary(message),
+    do: message |> to_interpolatable() |> runtime_interpolate(bindings)
+
+  def runtime_interpolate(interpolatable, bindings)
+      when is_list(interpolatable) and is_map(bindings) 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 """
+  Compile a static message to interpolate with dynamic bindings.
+
+  This macro 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 Gettext.Interpolation
+  defmacro compile_interpolate(translation_type, message, bindings) do
+    unless is_binary(message) do
+      raise """
+      #{__MODULE__}.compile_interpolate/2 can only be used at compile time with static messages.
+      Alternatively use #{__MODULE__}.runtime_interpolate/2.
+      """
+    end
+
+    interpolatable = to_interpolatable(message)
+    keys = keys(interpolatable)
+    match_clause = match_clause(keys)
+    compile_string = compile_string(interpolatable)
+
+    case {keys, translation_type} do
+      # If no keys are in the message, the message can be returned without interpolation
+      {[], _translation_type} ->
+        quote do: {:ok, unquote(message)}
+
+      # If the message only contains the key `count` and it is a plural translation,
+      # 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, _translation_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
+end


Test patch:
diff --git a/test/gettext/interpolation_test.exs b/test/gettext/interpolation/default_test.exs
similarity index 96%
rename from test/gettext/interpolation_test.exs
rename to test/gettext/interpolation/default_test.exs
index 73c16c1..2caf1a8 100644
--- a/test/gettext/interpolation_test.exs
+++ b/test/gettext/interpolation/default_test.exs
@@ -1,9 +1,9 @@
-defmodule Gettext.InterpolationTest do
+defmodule Gettext.Interpolation.DefaultTest do
   use ExUnit.Case, async: true
 
-  doctest Gettext.Interpolation
+  doctest Gettext.Interpolation.Default
 
-  alias Gettext.Interpolation
+  alias Gettext.Interpolation.Default, as: Interpolation
 
   require Interpolation
 
diff --git a/test/gettext_test.exs b/test/gettext_test.exs
index bc1dac7..0cccccc 100644
--- a/test/gettext_test.exs
+++ b/test/gettext_test.exs
@@ -36,6 +36,27 @@ defmodule GettextTest.HandleMissingTranslation do
   end
 end
 
+defmodule GettextTest.TranslatorWithDuckInterpolator.Interpolator do
+  @behaviour Gettext.Interpolation
+
+  @impl Gettext.Interpolation
+  def runtime_interpolate(message, bindings),
+    do: {:ok, "quack #{message} #{inspect(bindings)} quack"}
+
+  @impl Gettext.Interpolation
+  defmacro compile_interpolate(_translation_type, message, bindings) do
+    quote do
+      {:ok, "quack #{unquote(message)} #{inspect(unquote(bindings))} quack"}
+    end
+  end
+end
+
+defmodule GettextTest.TranslatorWithDuckInterpolator do
+  use Gettext,
+    otp_app: :test_application,
+    interpolation: GettextTest.TranslatorWithDuckInterpolator.Interpolator
+end
+
 defmodule GettextTest do
   use ExUnit.Case
 
@@ -862,4 +883,10 @@ defmodule GettextTest do
     assert lgettext("pt_BR", "nonexistent", nil, "Hello world", %{}) ==
              {:default, "Hello world"}
   end
+
+  test "uses custom interpolator" do
+    import GettextTest.TranslatorWithDuckInterpolator, only: [gettext: 1]
+
+    assert "quack foo %{} quack" = gettext("foo")
+  end
 end