Skip to content

Commit

Permalink
Supply plurals forms header to Gettext.Plural callbacks
Browse files Browse the repository at this point in the history
Closes #318

This makes it possible to react precisely to the header instead of
using predefined formats.
  • Loading branch information
maennchen committed Jan 16, 2023
1 parent 2ebe806 commit 41e16f2
Show file tree
Hide file tree
Showing 8 changed files with 210 additions and 34 deletions.
40 changes: 32 additions & 8 deletions lib/gettext/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -516,14 +522,16 @@ defmodule Gettext.Compiler do
singular_fun,
plural_fun,
file,
plural_mod,
{plural_forms_fun, nplurals},
interpolation_module
)

messages = block(Enum.map(messages, mapper))

quoted =
quote do
unquote(plural_forms)

unquote(messages)

Kernel.unquote(kind)(unquote(singular_fun)(msgctxt, msgid, bindings)) do
Expand Down Expand Up @@ -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.pluralization_opts(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.pluralization_opts(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")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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([
Expand Down
45 changes: 27 additions & 18 deletions lib/gettext/merger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -270,11 +272,11 @@ defmodule Gettext.Merger do
end
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

Expand All @@ -288,21 +290,28 @@ 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.pluralization_opts(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.pluralization_opts(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
Expand Down
99 changes: 97 additions & 2 deletions lib/gettext/plural.ex
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,51 @@ 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 opts :: locale() | 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 `opts` will be set to the
`locale`.
"""
@callback init(pluralization_context()) :: opts()

@doc """
Returns the number of possible plural forms in the given `locale`.
"""
@callback nplurals(locale :: String.t()) :: pos_integer
@callback nplurals(opts()) :: 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(opts(), count :: integer()) :: non_neg_integer()

@doc """
Returns the plural forms header value for a 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 """
Expand Down Expand Up @@ -434,10 +467,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
Expand Down Expand Up @@ -511,6 +556,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
Expand Down Expand Up @@ -681,4 +728,52 @@ defmodule Gettext.Plural do
_other -> raise UnknownLocaleError, locale
end
end

@doc false
def pluralization_opts(locale, messages_struct, plural_mod) do
{:module, ^plural_mod} = Code.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
{:module, ^plural_mod} = Code.ensure_loaded(plural_mod)

if function_exported?(plural_mod, :plural_forms_header, 1) do
plural_mod.plural_forms_header(locale)
else
nplurals = plural_mod.nplurals(pluralization_opts(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
end
16 changes: 11 additions & 5 deletions lib/mix/tasks/gettext.merge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -94,9 +94,9 @@ defmodule Mix.Tasks.Gettext.Merge do
match. Overrides the global `:fuzzy_threshold` option (see the docs for
`Gettext` for more information on this option).
* `--plural-forms` - an integer strictly greater than `0`. 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.
* `--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.
Expand All @@ -117,6 +117,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
]
Expand Down Expand Up @@ -273,6 +274,7 @@ defmodule Mix.Tasks.Gettext.Merge do
:fuzzy,
:fuzzy_threshold,
:plural_forms,
:plural_forms_header,
:on_obsolete,
:store_previous_message_on_fuzzy_match
])
Expand All @@ -289,6 +291,10 @@ defmodule Mix.Tasks.Gettext.Merge do
Mix.raise("The :fuzzy_threshold option must be a float >= 0.0 and <= 1.0")
end

unless opts[:plural_forms] == nil do
Mix.raise("The :plural_forms option is deprecated in favor of :plural_forms_header")
end

opts
end

Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/single_messages/it/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
msgid ""
msgstr ""
"Language: it\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "Hello world"
msgstr "Ciao mondo"

Expand Down
Loading

0 comments on commit 41e16f2

Please sign in to comment.