diff --git a/lib/gettext/compiler.ex b/lib/gettext/compiler.ex index 5830cba5..d68e9014 100644 --- a/lib/gettext/compiler.ex +++ b/lib/gettext/compiler.ex @@ -6,6 +6,7 @@ defmodule Gettext.Compiler do alias Expo.Message alias Expo.Messages alias Expo.PO + alias Gettext.Plural @default_priv "priv/gettext" @default_domain "default" @@ -501,7 +502,12 @@ defmodule Gettext.Compiler do # lngettext/7 (for plural messages) clauses. defp compile_po_file(kind, po_file, env, plural_mod, interpolation_module) do %{locale: locale, domain: domain, path: path} = po_file - %Messages{messages: messages, file: file} = PO.parse_file!(path) + %Messages{messages: messages, file: file} = messages_struct = PO.parse_file!(path) + + plural_forms_fun = :"{locale}_#{domain}_plural" + + plural_forms = compile_plural_forms(locale, messages_struct, plural_mod, plural_forms_fun) + nplurals = nplurals(locale, messages_struct, plural_mod) singular_fun = :"#{locale}_#{domain}_lgettext" plural_fun = :"#{locale}_#{domain}_lngettext" @@ -516,7 +522,7 @@ defmodule Gettext.Compiler do singular_fun, plural_fun, file, - plural_mod, + {plural_forms_fun, nplurals}, interpolation_module ) @@ -524,6 +530,8 @@ defmodule Gettext.Compiler do quoted = quote do + unquote(plural_forms) + unquote(messages) Kernel.unquote(kind)(unquote(singular_fun)(msgctxt, msgid, bindings)) do @@ -552,6 +560,21 @@ defmodule Gettext.Compiler do {locale, domain, singular_fun, plural_fun, quoted} end + defp nplurals(locale, messages_struct, plural_mod) do + plural_mod.nplurals(Plural.plural_info(locale, messages_struct, plural_mod)) + end + + defp compile_plural_forms(locale, messages_struct, plural_mod, plural_fun) do + quote do + defp unquote(plural_fun)(n) do + unquote(plural_mod).plural( + unquote(Macro.escape(Plural.plural_info(locale, messages_struct, plural_mod))), + n + ) + end + end + end + defp locale_and_domain_from_path(path) do [file, "LC_MESSAGES", locale | _rest] = path |> Path.split() |> Enum.reverse() domain = Path.rootname(file, ".po") @@ -565,7 +588,7 @@ defmodule Gettext.Compiler do singular_fun, _plural_fun, _file, - _plural_mod, + _pluralization, interpolation_module ) do msgid = IO.iodata_to_binary(message.msgid) @@ -602,10 +625,10 @@ defmodule Gettext.Compiler do _singular_fun, plural_fun, file, - plural_mod, + {plural_forms_fun, nplurals}, interpolation_module ) do - warn_if_missing_plural_forms(locale, plural_mod, message, file) + warn_if_missing_plural_forms(locale, nplurals, message, file) msgid = IO.iodata_to_binary(message.msgid) msgid_plural = IO.iodata_to_binary(message.msgid_plural) @@ -652,7 +675,8 @@ defmodule Gettext.Compiler do bindings ) ) do - plural_form = unquote(plural_mod).plural(unquote(locale), n) + plural_form = unquote(plural_forms_fun)(n) + var!(bindings) = Map.put(bindings, :count, n) case plural_form, do: unquote(clauses ++ error_clause) @@ -661,8 +685,8 @@ defmodule Gettext.Compiler do end end - defp warn_if_missing_plural_forms(locale, plural_mod, message, file) do - Enum.each(0..(plural_mod.nplurals(locale) - 1), fn form -> + defp warn_if_missing_plural_forms(locale, nplurals, message, file) do + Enum.each(0..(nplurals - 1), fn form -> unless Map.has_key?(message.msgstr, form) do _ = Logger.error([ diff --git a/lib/gettext/merger.ex b/lib/gettext/merger.ex index 4dba3aad..0c09d1c5 100644 --- a/lib/gettext/merger.ex +++ b/lib/gettext/merger.ex @@ -5,6 +5,7 @@ defmodule Gettext.Merger do alias Expo.Message alias Expo.Messages alias Gettext.Fuzzy + alias Gettext.Plural @new_po_informative_comment """ # "msgid"s in this file come from POT (.pot) files. @@ -53,7 +54,7 @@ defmodule Gettext.Merger do {Messages.t(), map()} def merge(%Messages{} = old, %Messages{} = new, locale, opts) when is_binary(locale) and is_list(opts) do - opts = put_plural_forms_opt(opts, old.headers, locale) + opts = put_plural_forms_opt(opts, old, locale) stats = %{new: 0, exact_matches: 0, fuzzy_matches: 0, removed: 0, marked_as_obsolete: 0} {messages, stats} = merge_messages(old.messages, new.messages, opts, stats) @@ -220,12 +221,13 @@ defmodule Gettext.Merger do """ def new_po_file(po_file, pot_file, locale, opts) when is_binary(locale) and is_list(opts) do pot = PO.parse_file!(pot_file) - opts = put_plural_forms_opt(opts, pot.headers, locale) + opts = put_plural_forms_opt(opts, pot, locale) plural_forms = Keyword.fetch!(opts, :plural_forms) + plural_forms_header = Keyword.fetch!(opts, :plural_forms_header) po = %Messages{ top_comments: String.split(@new_po_informative_comment, "\n", trim: true), - headers: headers_for_new_po_file(locale, plural_forms), + headers: headers_for_new_po_file(locale, plural_forms_header), file: po_file, messages: Enum.map(pot.messages, &prepare_new_message(&1, plural_forms)) } @@ -277,11 +279,11 @@ defmodule Gettext.Merger do Enum.reject(unique_refs, &match?([], &1)) end - defp headers_for_new_po_file(locale, plural_forms) do + defp headers_for_new_po_file(locale, plural_forms_header) do [ "", ~s(Language: #{locale}\n), - ~s(Plural-Forms: nplurals=#{plural_forms}\n) + ~s(Plural-Forms: #{plural_forms_header}\n) ] end @@ -295,21 +297,27 @@ defmodule Gettext.Merger do %{message | comments: Enum.reject(comments, &match?("#" <> _, &1))} end - defp put_plural_forms_opt(opts, headers, locale) do - Keyword.put_new_lazy(opts, :plural_forms, fn -> - read_plural_forms_from_headers(headers) || - Application.get_env(:gettext, :plural_forms, Gettext.Plural).nplurals(locale) - end) - end + defp put_plural_forms_opt(opts, messages, locale) do + plural_mod = Application.get_env(:gettext, :plural_forms, Gettext.Plural) + + opts = + Keyword.put_new_lazy(opts, :plural_forms, fn -> + plural_mod.nplurals(Plural.plural_info(locale, messages, plural_mod)) + end) + + Keyword.put_new_lazy(opts, :plural_forms_header, fn -> + requested_nplurals = Keyword.fetch!(opts, :plural_forms) + + default_nplurals = plural_mod.nplurals(Plural.plural_info(locale, messages, plural_mod)) - defp read_plural_forms_from_headers(headers) do - Enum.find_value(headers, fn header -> - with "Plural-Forms:" <> rest <- header, - "nplurals=" <> rest <- String.trim(rest), - {plural_forms, _rest} <- Integer.parse(rest) do - plural_forms + # If nplurals is overridden to a non-default value by the user the + # implementation will not be able to provide a correct header therefore + # the header is just set to `nplurals=#{n}` and it is up to the user to + # put a complete plural forms header himself. + if requested_nplurals == default_nplurals do + Plural.plural_forms_header_impl(locale, messages, plural_mod) else - _other -> nil + "nplurals=#{requested_nplurals}" end end) end diff --git a/lib/gettext/plural.ex b/lib/gettext/plural.ex index 7c963568..d67a31e7 100644 --- a/lib/gettext/plural.ex +++ b/lib/gettext/plural.ex @@ -122,18 +122,52 @@ defmodule Gettext.Plural do """ + alias Expo.Messages + + # Types + + @type locale :: String.t() + + @type pluralization_context :: %{ + optional(:plural_forms_header) => String.t(), + required(:locale) => locale() + } + + @type plural_info :: term() + # Behaviour definition. + @doc """ + Initialize context for `nplurals/1` / `plurals/2`. + + Perform all preparations (for example parsing the plural forms header) per + for the provided locale to offload into compile time. + + If the `init/1` callback is not defined, the `plural_info` will be set to the + `locale`. + """ + @callback init(pluralization_context()) :: plural_info() + @doc """ Returns the number of possible plural forms in the given `locale`. """ - @callback nplurals(locale :: String.t()) :: pos_integer + @callback nplurals(plural_info()) :: pos_integer() @doc """ Returns the plural form in the given `locale` for the given `count` of elements. """ - @callback plural(locale :: String.t(), count :: integer) :: plural_form :: non_neg_integer + @callback plural(plural_info(), count :: integer()) :: plural_form :: non_neg_integer() + + @doc """ + Returns the plural forms header value for the given `locale`. + + + Fallback if not implemented: `"nplurals={nplurals};"`. + """ + @callback plural_forms_header(locale()) :: String.t() | nil + + @optional_callbacks init: 1, plural_forms_header: 1 defmodule UnknownLocaleError do @moduledoc """ @@ -434,10 +468,22 @@ defmodule Gettext.Plural do "sk" ] + @doc false + def init(%{locale: locale, plural_forms_header: plural_forms_header}) do + case read_plural_forms_from_headers(plural_forms_header) do + nil -> locale + nplurals -> {locale, nplurals} + end + end + + def init(%{locale: locale}), do: locale + # Number of plural forms. def nplurals(locale) + def nplurals({_locale, nplurals}), do: nplurals + # All the groupable forms. for l <- @one_form do @@ -511,6 +557,8 @@ defmodule Gettext.Plural do def plural(locale, count) + def plural({locale, _nplurals}, count), do: plural(locale, count) + # All the `x_Y` languages that have different pluralization rules than `x`. def plural("pt_BR", n) when n in [0, 1], do: 0 @@ -681,4 +729,69 @@ defmodule Gettext.Plural do _other -> raise UnknownLocaleError, locale end end + + @doc false + def plural_info(locale, messages_struct, plural_mod) do + ensure_loaded!(plural_mod) + + if function_exported?(plural_mod, :init, 1) do + pluralization_context = %{locale: locale} + + pluralization_context = + case Messages.get_header(messages_struct, "Plural-Forms") do + [] -> + pluralization_context + + plural_forms -> + Map.put( + pluralization_context, + :plural_forms_header, + IO.iodata_to_binary(plural_forms) + ) + end + + plural_mod.init(pluralization_context) + else + locale + end + end + + @doc false + def plural_forms_header_impl(locale, messages_struct, plural_mod) do + ensure_loaded!(plural_mod) + + plural_forms_header = + if function_exported?(plural_mod, :plural_forms_header, 1) do + plural_mod.plural_forms_header(locale) + end + + if plural_forms_header do + plural_forms_header + else + nplurals = plural_mod.nplurals(plural_info(locale, messages_struct, plural_mod)) + + "nplurals=#{nplurals}" + end + end + + defp read_plural_forms_from_headers(header) do + with "nplurals=" <> rest <- String.trim(header), + {plural_forms, _rest} <- Integer.parse(rest) do + plural_forms + else + _other -> nil + end + end + + # TODO: remove when we depend on Elixir 1.12+ + if function_exported?(Code, :ensure_loaded!, 1) do + defp ensure_loaded!(mod), do: Code.ensure_loaded!(mod) + else + defp ensure_loaded!(mod) do + case Code.ensure_loaded(mod) do + {:module, ^mod} -> :ok + _other -> raise "" + end + end + end end diff --git a/lib/mix/tasks/gettext.merge.ex b/lib/mix/tasks/gettext.merge.ex index 9fc18242..8cd0f228 100644 --- a/lib/mix/tasks/gettext.merge.ex +++ b/lib/mix/tasks/gettext.merge.ex @@ -76,8 +76,8 @@ defmodule Mix.Tasks.Gettext.Merge do by checking the value of `nplurals` in the `Plural-Forms` header in the existing `.po` file. If a `.po` file doesn't already exist and Gettext is creating a new one or if the `Plural-Forms` header is not in the `.po` file, Gettext will use the number of plural forms that - `Gettext.Plural` returns for the locale of the file being created. The number of plural forms - can be forced through the `--plural-forms` option (see below). + `Gettext.Plural` returns for the locale of the file being created. The content of the plural forms + header can be forced through the `--plural-forms-header` option (see below). ## Options @@ -98,6 +98,10 @@ defmodule Mix.Tasks.Gettext.Merge do new messages in the target PO files will have this number of empty plural forms. See the "Plural forms" section above. + * `--plural-forms-header` - `Plural-Forms` header content as string. + If this is passed, new messages in the target PO files will have this + number of empty plural forms. See the "Plural forms" section above. + * `--on-obsolete` - controls what happens when obsolete messages are found. If `mark_as_obsolete`, messages are kept and marked as obsolete. If `delete`, obsolete messages are deleted. Defaults to `delete`. @@ -117,6 +121,7 @@ defmodule Mix.Tasks.Gettext.Merge do fuzzy: :boolean, fuzzy_threshold: :float, plural_forms: :integer, + plural_forms_header: :string, on_obsolete: :string, store_previous_message_on_fuzzy_match: :boolean ] @@ -273,6 +278,7 @@ defmodule Mix.Tasks.Gettext.Merge do :fuzzy, :fuzzy_threshold, :plural_forms, + :plural_forms_header, :on_obsolete, :store_previous_message_on_fuzzy_match ]) diff --git a/test/fixtures/single_messages/it/LC_MESSAGES/default.po b/test/fixtures/single_messages/it/LC_MESSAGES/default.po index 8400c60b..1b44345c 100644 --- a/test/fixtures/single_messages/it/LC_MESSAGES/default.po +++ b/test/fixtures/single_messages/it/LC_MESSAGES/default.po @@ -1,3 +1,8 @@ +msgid "" +msgstr "" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + msgid "Hello world" msgstr "Ciao mondo" diff --git a/test/gettext_test.exs b/test/gettext_test.exs index 9d7b5038..e756fbd2 100644 --- a/test/gettext_test.exs +++ b/test/gettext_test.exs @@ -23,6 +23,13 @@ defmodule GettextTest.TranslatorWithCustomPluralForms do plural_forms: GettextTest.CustomPlural end +defmodule GettextTest.TranslatorWithCustomCompiledPluralForms do + use Gettext, + otp_app: :test_application, + priv: "test/fixtures/single_messages", + plural_forms: GettextTest.CustomCompiledPlural +end + defmodule GettextTest.TranslatorWithDefaultDomain do use Gettext, otp_app: :test_application, @@ -77,6 +84,7 @@ defmodule GettextTest do alias GettextTest.Translator alias GettextTest.TranslatorWithAllowedLocalesString alias GettextTest.TranslatorWithAllowedLocalesAtom + alias GettextTest.TranslatorWithCustomCompiledPluralForms alias GettextTest.TranslatorWithCustomPluralForms alias GettextTest.TranslatorWithDefaultDomain alias GettextTest.HandleMissingMessage @@ -203,6 +211,14 @@ defmodule GettextTest do {:ok, "Una nuova email"} end + test "using a custom Gettext.Plural module with the context parameter" do + alias TranslatorWithCustomCompiledPluralForms, as: T + + assert T.lngettext("it", "default", nil, "One new email", "%{count} new emails", 1, %{}) + + assert_received {:plural_context, %{plural_forms_header: "nplurals=2; plural=(n != 1);"}} + end + test "using a custom Gettext.Plural module from app environment" do Application.put_env(:gettext, :plural_forms, GettextTest.CustomPlural) diff --git a/test/mix/tasks/gettext.merge_test.exs b/test/mix/tasks/gettext.merge_test.exs index 77bc01cd..4ab10c92 100644 --- a/test/mix/tasks/gettext.merge_test.exs +++ b/test/mix/tasks/gettext.merge_test.exs @@ -204,7 +204,7 @@ defmodule Mix.Tasks.Gettext.MergeTest do output = capture_io(fn -> - run([@priv_path, "--locale", "it", "--plural-forms", "3"]) + run([@priv_path, "--locale", "it", "--plural-forms-header", "nplurals=3"]) end) assert output =~ "Wrote tmp/gettext.merge/it/LC_MESSAGES/new.po" diff --git a/test/test_helper.exs b/test/test_helper.exs index 1a30279f..4aa3b423 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -7,4 +7,25 @@ defmodule GettextTest.CustomPlural do def plural("it", _), do: 0 end +defmodule GettextTest.CustomCompiledPlural do + @behaviour Gettext.Plural + + @impl Gettext.Plural + def init(context), do: context + + @impl Gettext.Plural + def nplurals(context) do + send(self(), {:nplurals_context, context}) + + 2 + end + + @impl Gettext.Plural + def plural(context, _count) do + send(self(), {:plural_context, context}) + + 0 + end +end + ExUnit.start()