Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 63 additions & 7 deletions lib/gettext/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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)
Comment thread
josevalim marked this conversation as resolved.
Resolved

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
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/single_messages/it/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 "" ""

Expand Down
114 changes: 114 additions & 0 deletions test/gettext/backend_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!"}
Expand Down
10 changes: 10 additions & 0 deletions test/gettext_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading