diff --git a/CHANGELOG.md b/CHANGELOG.md index be97a394..a82f28db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased + * Reduce the compile time of backends that use the default interpolation module by storing translations without interpolation in a literal map instead of generating one function clause each. * Add experimental `mix gettext.generate` task: messages are persisted as module attributes during normal compilation (for backends with `automatic_extraction: true` in the application environment, for example `config :gettext, MyApp.Gettext, automatic_extraction: true` in `config/dev.exs`) and read back from the compiled BEAM files, so extraction no longer needs to force-recompile the project. ## v1.1.0 diff --git a/lib/gettext/compiler.ex b/lib/gettext/compiler.ex index 712f632c..5a0575b7 100644 --- a/lib/gettext/compiler.ex +++ b/lib/gettext/compiler.ex @@ -505,6 +505,7 @@ defmodule Gettext.Compiler do plural_fun = :"#{locale}_#{domain}_lngettext" messages = Enum.filter(messages, &match?(%{obsolete: false}, &1)) + {static_messages, messages} = split_static_messages(messages, interpolation_module) mapper = &compile_message( @@ -527,13 +528,7 @@ defmodule Gettext.Compiler do unquote(messages) Kernel.unquote(kind)(unquote(singular_fun)(msgctxt, msgid, bindings)) do - unquote(env.module).handle_missing_translation( - unquote(locale), - unquote(domain), - msgctxt, - msgid, - bindings - ) + unquote(static_lookup(static_messages, env.module, locale, domain)) end Kernel.unquote(kind)(unquote(plural_fun)(msgctxt, msgid, msgid_plural, n, bindings)) do @@ -552,6 +547,67 @@ defmodule Gettext.Compiler do {locale, domain, singular_fun, plural_fun, quoted} end + # Singular messages whose translation contains no "%{" cannot need + # interpolation, so they are kept in a literal map that the catch-all clause + # of the singular function looks up, instead of becoming one function clause + # each. The map holds the {:ok, msgstr} results themselves, so a hit returns + # a literal without allocating a result tuple. Only the default interpolation + # module is handled, since what counts as interpolation depends on the + # interpolation syntax. The partition only scans for "%{" and never parses + # interpolation keys: a translation containing "%{" (a binding, or a literal + # "%{}") is compiled into a clause as before, and empty translations stay out + # so that they still reach handle_missing_translation/5. + defp split_static_messages(messages, Gettext.Interpolation.Default) do + {static, remaining} = + Enum.reduce(messages, {%{}, []}, fn + %Message.Singular{} = message, {static, remaining} -> + msgstr = IO.iodata_to_binary(message.msgstr) + + if msgstr != "" and not String.contains?(msgstr, "%{") do + {Map.put(static, static_key(message), {:ok, msgstr}), remaining} + else + {static, [message | remaining]} + end + + %Message.Plural{} = message, {static, remaining} -> + {static, [message | remaining]} + end) + + {static, Enum.reverse(remaining)} + end + + defp split_static_messages(messages, _interpolation_module), do: {%{}, messages} + + defp static_key(message) do + {message.msgctxt && IO.iodata_to_binary(message.msgctxt), IO.iodata_to_binary(message.msgid)} + end + + defp static_lookup(static_messages, module, locale, domain) do + missing_translation = + quote do + unquote(module).handle_missing_translation( + unquote(locale), + unquote(domain), + msgctxt, + msgid, + bindings + ) + end + + if static_messages == %{} do + missing_translation + else + quote do + key = {msgctxt, msgid} + + case unquote(Macro.escape(static_messages)) do + %{^key => translation} -> translation + %{} -> unquote(missing_translation) + end + end + end + end + defp nplurals(locale, messages_struct, plural_mod) do plural_mod.nplurals(Plural.plural_info(locale, messages_struct, plural_mod)) end diff --git a/test/fixtures/single_messages/it/LC_MESSAGES/default.po b/test/fixtures/single_messages/it/LC_MESSAGES/default.po index 1b44345c..3239ae1a 100644 --- a/test/fixtures/single_messages/it/LC_MESSAGES/default.po +++ b/test/fixtures/single_messages/it/LC_MESSAGES/default.po @@ -35,6 +35,9 @@ msgid_plural "%{count}" " friends" msgstr[0] "Un" " amico" msgstr[1] "%{count}" " amici" +msgid "Literal braces" +msgstr "Parentesi %{} letterali" + msgid "Empty msgstr!" msgstr "" "" diff --git a/test/gettext/backend_test.exs b/test/gettext/backend_test.exs index edf81ca9..ce4a32a8 100644 --- a/test/gettext/backend_test.exs +++ b/test/gettext/backend_test.exs @@ -122,6 +122,102 @@ defmodule Gettext.BackendTest do end end + describe "compiling catalogs of different shapes" do + @tag :tmp_dir + test "compiles without warnings and looks translations up by context", %{tmp_dir: tmp_dir} do + catalogs = [ + static_only: ~S""" + msgid "Hello world" + msgstr "Ciao mondo" + + msgctxt "" + msgid "Empty context" + msgstr "Contesto vuoto" + + msgctxt "greeting" + msgid "Hello world" + msgstr "Ciao mondo (saluto)" + + #~ msgid "Gone" + #~ msgstr "Andato" + """, + interpolated_only: ~S""" + msgid "Hello %{name}" + msgstr "Ciao %{name}" + """, + plural_only: ~S""" + msgid "One new email" + msgid_plural "%{count} new emails" + msgstr[0] "Una nuova email" + msgstr[1] "%{count} nuove email" + """ + ] + + backends = + for {shape, contents} <- catalogs, into: %{} do + priv = Path.join(tmp_dir, "#{shape}") + po_file = Path.join([priv, "it", "LC_MESSAGES", "default.po"]) + File.mkdir_p!(Path.dirname(po_file)) + File.write!(po_file, contents) + + module = + Module.concat(__MODULE__, "Backend#{shape}#{System.unique_integer([:positive])}") + + source = Path.join(tmp_dir, "#{shape}.ex") + + File.write!(source, """ + defmodule #{inspect(module)} do + use Gettext.Backend, otp_app: :test_application, priv: #{inspect(priv)} + end + """) + + # Compiled through the parallel compiler like a Mix project is, so that + # warnings from the type checker are reported too. + assert {:ok, [^module], warnings} = + Kernel.ParallelCompiler.compile([source], return_diagnostics: true) + + assert warnings == %{compile_warnings: [], runtime_warnings: []}, + "compiling the #{shape} catalog warned: #{inspect(warnings)}" + + {shape, module} + end + + backend = backends.static_only + assert backend.lgettext("it", "default", nil, "Hello world", %{}) == {:ok, "Ciao mondo"} + + # An empty context is not the same as no context, as with function clauses. + assert backend.lgettext("it", "default", "", "Empty context", %{}) == + {:ok, "Contesto vuoto"} + + assert backend.lgettext("it", "default", nil, "Empty context", %{}) == + {:default, "Empty context"} + + assert backend.lgettext("it", "default", "greeting", "Hello world", %{}) == + {:ok, "Ciao mondo (saluto)"} + + assert backend.lgettext("it", "default", "other", "Hello world", %{}) == + {:default, "Hello world"} + + assert backend.lgettext("it", "default", nil, "Gone", %{}) == {:default, "Gone"} + + backend = backends.interpolated_only + + assert backend.lgettext("it", "default", nil, "Hello %{name}", %{name: "Jane"}) == + {:ok, "Ciao Jane"} + + assert backend.lgettext("it", "default", nil, "Hello world", %{}) == + {:default, "Hello world"} + + backend = backends.plural_only + + assert backend.lgettext("it", "default", nil, "One new email", %{}) == + {:ok, "Una nuova email"} + + assert backend.lgettext("it", "default", nil, "Hello world", %{}) == + {:default, "Hello world"} + end + end + describe "__gettext__/1 (generated)" do test "with :priv returns the directory where messages are stored" do assert Backend.__gettext__(:priv) == "test/fixtures/single_messages" @@ -159,6 +255,24 @@ defmodule Gettext.BackendTest do {:default, "Hello world"} end + test "returns {:ok, translation} for translations with a context" do + assert Backend.lgettext("it", "default", "test", "Hello world", %{}) == {:ok, "Ciao mondo"} + + assert Backend.lgettext("it", "default", "test", "Hello %{name}", %{name: "Jane"}) == + {:ok, "Ciao Jane"} + + assert Backend.lgettext("it", "default", "other", "Hello world", %{}) == + {:default, "Hello world"} + end + + test "returns translations with literal braces as they are" do + assert Backend.lgettext("it", "default", nil, "Literal braces", %{}) == + {:ok, "Parentesi %{} letterali"} + + assert Backend.lgettext("it", "default", nil, "Literal braces", %{name: "Jane"}) == + {:ok, "Parentesi %{} letterali"} + end + test "returns {:default, msgid} if the msgstr is an empty string" do assert Backend.lgettext("it", "default", nil, "Empty msgstr!", %{}) == {:default, "Empty msgstr!"} diff --git a/test/gettext_test.exs b/test/gettext_test.exs index 249dad78..078c4adc 100644 --- a/test/gettext_test.exs +++ b/test/gettext_test.exs @@ -276,6 +276,16 @@ defmodule GettextTest do test "uses custom interpolator" do assert Gettext.gettext(GettextTest.TranslatorWithDuckInterpolator, "foo") == "quack foo %{} quack" + + # Translations without interpolation go through the custom interpolator + # too, instead of the literal map used with the default interpolator. + assert GettextTest.TranslatorWithDuckInterpolator.lgettext( + "it", + "default", + nil, + "Hello world", + %{} + ) == {:ok, "quack Ciao mondo %{} quack"} end test "use Gettext for defining backends is deprecated" do