Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supply plurals forms header to Gettext.Plural callbacks #343

Merged
merged 1 commit into from
Jan 20, 2023
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
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.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")
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
44 changes: 26 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 @@ -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

Expand All @@ -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
Expand Down
117 changes: 115 additions & 2 deletions lib/gettext/plural.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When does this return nil, and what does that mean semantically?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means that it does not know a rule for that locale. The caller of the function will then just set a partial header with only nplurals defined.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignore my previous comment, that is not factually correct. That's what's supposed to happen though. I'll have a look.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now it is as I described.


@optional_callbacks init: 1, plural_forms_header: 1

defmodule UnknownLocaleError do
@moduledoc """
Expand Down Expand Up @@ -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
Copy link
Member Author

@maennchen maennchen Jan 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is here to keep gettext.merge --plural-forms 7 working.

It is however not consistent internally. I could for example merge with --plural-forms 1 in a language that has 2+ plural forms.

When using ngettext, the correct plural forms would not be defined and therefore always result in a missing translation.


As an alternate solution, we could remove the option entirely and make the Gettext.Plural implementation only work on known formats. If a user wants to add / change a language, he would need to add his own implementation as decribed here:

defmodule MyApp.Plural do

Even though I personally like option 2 a lot more, I decided to go with the solution that does not cause breaking changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can deprecate the option and guide users towards the new solution.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josevalim That is what I intended to do here: #343 (comment)

Based on the discussion with @whatyouhide I will probably ignore the whole topic for the PR.

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 +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
Expand Down Expand Up @@ -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 "<good error message>"
end
end
end
end
10 changes: 8 additions & 2 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 @@ -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.
maennchen marked this conversation as resolved.
Show resolved Hide resolved
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`.
Expand All @@ -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
]
Expand Down Expand Up @@ -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
])
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