Repository: elixir-gettext/gettext
Pull request #292 (2021-10-31 20:54:11)

Refactor Interpolation to be more self contained
This is a preparation to build pluggable interpolation backends in the future.

Follow up from https://github.com/elixir-gettext/gettext/pull/291#issuecomment-955536435


Patch:
diff --git a/lib/gettext.ex b/lib/gettext.ex
index 9863b50..dfcfc3a 100644
--- a/lib/gettext.ex
+++ b/lib/gettext.ex
@@ -606,22 +606,22 @@ defmodule Gettext do
       defoverridable handle_missing_bindings: 2
 
       def handle_missing_translation(_locale, domain, msgid, bindings) do
-        import Gettext.Interpolation, only: [to_interpolatable: 1, interpolate: 2]
+        import Gettext.Interpolation, only: [runtime_interpolate: 2]
 
         Gettext.Compiler.warn_if_domain_contains_slashes(domain)
 
-        with {:ok, interpolated} <- interpolate(to_interpolatable(msgid), bindings),
+        with {:ok, interpolated} <- 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: [to_interpolatable: 1, interpolate: 2]
+        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} <- interpolate(to_interpolatable(string), bindings),
+        with {:ok, interpolated} <- runtime_interpolate(string, bindings),
              do: {:default, interpolated}
       end
 
diff --git a/lib/gettext/compiler.ex b/lib/gettext/compiler.ex
index 60185a1..0965923 100644
--- a/lib/gettext/compiler.ex
+++ b/lib/gettext/compiler.ex
@@ -2,7 +2,6 @@ defmodule Gettext.Compiler do
   @moduledoc false
 
   alias Gettext.{
-    Interpolation,
     PO,
     PO.Translation,
     PO.PluralTranslation
@@ -518,7 +517,6 @@ defmodule Gettext.Compiler do
     msgid = IO.iodata_to_binary(t.msgid)
     msgstr = IO.iodata_to_binary(t.msgstr)
     msgctxt = t.msgctxt && IO.iodata_to_binary(t.msgctxt)
-    keys = Interpolation.keys(msgstr)
 
     case msgstr do
       # Only actually generate this function clause if the msgstr is not empty.
@@ -526,23 +524,13 @@ defmodule Gettext.Compiler do
       "" ->
         nil
 
-      # If the msgid is the same as msgstr, simply return it without allocating
-      # a new string.
-      ^msgid when keys == [] ->
-        quote do
-          Kernel.unquote(kind)(
-            unquote(singular_fun)(unquote(msgctxt), unquote(msgid) = msgid, _)
-          ) do
-            {:ok, msgid}
-          end
-        end
-
       _ ->
         quote do
           Kernel.unquote(kind)(
-            unquote(singular_fun)(unquote(msgctxt), unquote(msgid), var!(bindings))
+            unquote(singular_fun)(unquote(msgctxt), unquote(msgid), bindings)
           ) do
-            unquote(compile_interpolation(msgstr, :translation))
+            require Gettext.Interpolation
+            Gettext.Interpolation.compile_interpolate(:translation, unquote(msgstr), bindings)
           end
         end
     end
@@ -572,7 +560,16 @@ defmodule Gettext.Compiler do
       # so when quoted they are a list.
       clauses =
         Enum.flat_map(msgstr, fn {form, str} ->
-          quote do: (unquote(form) -> unquote(compile_interpolation(str, :plural_translation)))
+          quote do
+            unquote(form) ->
+              require Gettext.Interpolation
+
+              Gettext.Interpolation.compile_interpolate(
+                :plural_translation,
+                unquote(str),
+                var!(bindings)
+              )
+          end
         end)
 
       error_clause =
@@ -622,67 +619,6 @@ defmodule Gettext.Compiler do
     {:__block__, [], contents}
   end
 
-  # Compiles a string into a full-blown `case` statement which interpolates the
-  # string based on some bindings or returns an error in case those bindings are
-  # missing. Note that the `bindings` variable is assumed to be in the scope by
-  # the quoted code that is returned.
-  defp compile_interpolation(str, translation_type) do
-    compile_interpolation(str, translation_type, Interpolation.keys(str))
-  end
-
-  defp compile_interpolation(str, _translation_type, [] = _keys) do
-    quote do
-      _ = var!(bindings)
-      {:ok, unquote(str)}
-    end
-  end
-
-  defp compile_interpolation(str, translation_type, keys) do
-    match = compile_interpolation_match(keys)
-    interpolatable = Interpolation.to_interpolatable(str)
-    interpolation = compile_interpolatable_string(interpolatable)
-
-    all_bindings_clause = quote do: (unquote(match) -> {:ok, unquote(interpolation)})
-
-    dynamic_interpolation_clause =
-      quote do
-        %{} -> Gettext.Interpolation.interpolate(unquote(interpolatable), var!(bindings))
-      end
-
-    clauses =
-      if keys == [:count] and translation_type == :plural_translation do
-        all_bindings_clause
-      else
-        all_bindings_clause ++ dynamic_interpolation_clause
-      end
-
-    quote do
-      case var!(bindings), do: unquote(clauses)
-    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 compile_interpolation_match(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_interpolatable_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
-
   # Returns all the PO files in `translations_dir` (under "canonical" paths,
   # that is, `locale/LC_MESSAGES/domain.po`).
   defp po_files_in_dir(dir) do
diff --git a/lib/gettext/interpolation.ex b/lib/gettext/interpolation.ex
index 4997dd8..8f2fe21 100644
--- a/lib/gettext/interpolation.ex
+++ b/lib/gettext/interpolation.ex
@@ -65,12 +65,11 @@ defmodule Gettext.Interpolation do
   defp prepend_if_not_empty(string, list), do: [string | list]
 
   @doc """
-  Interpolate an interpolatable with the given bindings.
+  Interpolate a message or interpolatable with the given bindings.
 
-  This function takes an interpolatable list (like the ones returned by
-  `to_interpolatable/1`) and some bindings and returns an `{:ok,
+  This function takes a message and some bindings and returns an `{:ok,
   interpolated_string}` tuple if interpolation is successful. If it encounters
-  an atom in `interpolatable` that is missing from `bindings`, it returns
+  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
@@ -78,18 +77,30 @@ defmodule Gettext.Interpolation do
 
   ## 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.interpolate(interpolatable, good_bindings)
+      iex> Gettext.Interpolation.runtime_interpolate(interpolatable, good_bindings)
       {:ok, "Hello José, you have 3 unread messages"}
-      iex> Gettext.Interpolation.interpolate(interpolatable, %{name: "José"})
+      iex> Gettext.Interpolation.runtime_interpolate(interpolatable, %{name: "José"})
       {:missing_bindings, "Hello José, you have %{count} unread messages", [:count]}
 
   """
-  @spec interpolate(interpolatable, map) ::
+  @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 interpolate(interpolatable, bindings)
+  def runtime_interpolate(interpolatable, bindings)
       when is_list(interpolatable) and is_map(bindings) do
     interpolate(interpolatable, bindings, [], [])
   end
@@ -148,4 +159,77 @@ defmodule Gettext.Interpolation do
 
   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
+
+        str ->
+          str
+      end)
+
+    {:<<>>, [], parts}
+  end
 end


Test patch:
diff --git a/test/gettext/interpolation_test.exs b/test/gettext/interpolation_test.exs
index 2abbf21..73c16c1 100644
--- a/test/gettext/interpolation_test.exs
+++ b/test/gettext/interpolation_test.exs
@@ -1,23 +1,30 @@
 defmodule Gettext.InterpolationTest do
   use ExUnit.Case, async: true
+
   doctest Gettext.Interpolation
+
   alias Gettext.Interpolation
 
-  test "interpolate/2" do
+  require Interpolation
+
+  test "runtime_interpolate/2" do
     interpolatable = Interpolation.to_interpolatable("%{a} %{b} %{c}")
 
-    assert Interpolation.interpolate(interpolatable, %{a: 1, b: :two, c: "thr ee"}) ==
+    assert Interpolation.runtime_interpolate(interpolatable, %{a: 1, b: :two, c: "thr ee"}) ==
              {:ok, "1 two thr ee"}
 
-    assert Interpolation.interpolate(interpolatable, %{a: "a"}) ==
+    assert Interpolation.runtime_interpolate(interpolatable, %{a: "a"}) ==
              {:missing_bindings, "a %{b} %{c}", [:b, :c]}
 
     interpolatable = Interpolation.to_interpolatable("%{a} %{a} %{a}")
 
-    assert Interpolation.interpolate(interpolatable, %{a: "foo"}) == {:ok, "foo foo foo"}
+    assert Interpolation.runtime_interpolate(interpolatable, %{a: "foo"}) == {:ok, "foo foo foo"}
 
-    assert Interpolation.interpolate(interpolatable, %{b: "bar"}) ==
+    assert Interpolation.runtime_interpolate(interpolatable, %{b: "bar"}) ==
              {:missing_bindings, "%{a} %{a} %{a}", [:a]}
+
+    assert Interpolation.runtime_interpolate("%{a} %{b} %{c}", %{a: "a"}) ==
+             {:missing_bindings, "a %{b} %{c}", [:b, :c]}
   end
 
   test "to_interpolatable/1" do
@@ -56,4 +63,55 @@ defmodule Gettext.InterpolationTest do
     assert Interpolation.keys(["Hello ", :name, " it's ", :time, " goodbye ", :name]) ==
              [:name, :time]
   end
+
+  describe "compile_interpolate/3" do
+    test "interpolates complete bindings" do
+      assert {:ok, "Hello World!"} ==
+               Interpolation.compile_interpolate(:translation, "Hello %{name}!", %{name: "World"})
+    end
+
+    test "interpolates incomplete bindings" do
+      assert {:missing_bindings, "Hello %{name}!", [:name]} ==
+               Interpolation.compile_interpolate(:translation, "Hello %{name}!", %{
+                 unused: "binding"
+               })
+    end
+
+    test "interpolates no bindings" do
+      assert {:missing_bindings, "Hello %{name}!", [:name]} ==
+               Interpolation.compile_interpolate(:translation, "Hello %{name}!", %{})
+    end
+
+    test "rejects dynamic message" do
+      assert_raise RuntimeError, fn ->
+        Code.eval_quoted(
+          quote do
+            require Interpolation
+
+            Interpolation.compile_interpolate(
+              :translation,
+              "dynamic message " <> inspect(make_ref()),
+              %{}
+            )
+          end
+        )
+      end
+    end
+
+    test "optimizes plural translation without count" do
+      translate = fn bindings ->
+        Interpolation.compile_interpolate(
+          :plural_translation,
+          "%{count} shoes",
+          bindings
+        )
+      end
+
+      assert_raise MatchError, fn ->
+        translate.(%{})
+      end
+
+      assert {:ok, "7 shoes"} = translate.(%{count: 7})
+    end
+  end
 end