Skip to content

Commit

Permalink
Add support for CLDR rules in I18n::Backend::Pluralization
Browse files Browse the repository at this point in the history
This is a breaking change. Anyone using the `zero` key in locales that don't require the `zero` key will have to change the key to use the explicit "0" key.
movermeyer committed Jun 9, 2022
1 parent b805537 commit ed22cbd
Showing 2 changed files with 70 additions and 24 deletions.
67 changes: 50 additions & 17 deletions lib/i18n/backend/pluralization.rb
Original file line number Diff line number Diff line change
@@ -16,40 +16,73 @@ module Backend
module Pluralization
# Overwrites the Base backend translate method so that it will check the
# translation meta data space (:i18n) for a locale specific pluralization
# rule and use it to pluralize the given entry. I.e. the library expects
# rule and use it to pluralize the given entry. I.e., the library expects
# pluralization rules to be stored at I18n.t(:'i18n.plural.rule')
#
# Pluralization rules are expected to respond to #call(count) and
# return a pluralization key. Valid keys depend on the translation data
# hash (entry) but it is generally recommended to follow CLDR's style,
# i.e., return one of the keys :zero, :one, :few, :many, :other.
# return a pluralization key. Valid keys depend on the pluralization
# rules for the locale, as defined in the CLDR.
# As of v41, 6 locale-specific plural categories are defined:
# :few, :many, :one, :other, :two, :zero
#
# The :zero key is always picked directly when count equals 0 AND the
# translation data has the key :zero. This way translators are free to
# either pick a special :zero translation even for languages where the
# pluralizer does not return a :zero key.
# n.b., The :one plural category does not imply the number 1.
# Instead, :one is a category for any number that behaves like 1 in
# that locale. For example, in some locales, :one is used for numbers
# that end in "1" (like 1, 21, 151) but that don't end in
# 11 (like 11, 111, 10311).
# Similar notes apply to the :two, and :zero plural categories.
#
# If you want to have different strings for the categories of count == 0
# (e.g. "I don't have any cars") or count == 1 (e.g. "I have a single car")
# use the explicit `"0"` and `"1"` keys.
# https://unicode-org.github.io/cldr/ldml/tr35-numbers.html#Explicit_0_1_rules
def pluralize(locale, entry, count)
return entry unless entry.is_a?(Hash) && count

pluralizer = pluralizer(locale)
if pluralizer.respond_to?(:call)
key = count == 0 && entry.has_key?(:zero) ? :zero : pluralizer.call(count)
raise InvalidPluralizationData.new(entry, count, key) unless entry.has_key?(key)
entry[key]
# "0" and "1" are special cases
# https://unicode-org.github.io/cldr/ldml/tr35-numbers.html#Explicit_0_1_rules
if count == 0 || count == 1
value = entry[symbolic_count(count)]
return value if value
end

# Lateral Inheritance of "count" attribute (http://www.unicode.org/reports/tr35/#Lateral_Inheritance):
# > If there is no value for a path, and that path has a [@count="x"] attribute and value, then:
# > 1. If "x" is numeric, the path falls back to the path with [@count=«the plural rules category for x for that locale»], within that the same locale.
# > 2. If "x" is anything but "other", it falls back to a path [@count="other"], within that the same locale.
# > 3. If "x" is "other", it falls back to the path that is completely missing the count item, within that the same locale.
# Note: We don't yet implement #3 above, since we haven't decided how lateral inheritance attributes should be represented.
plural_rule_category = pluralizer.call(count)
value = entry[plural_rule_category] || entry[:other]
return value if value

raise InvalidPluralizationData.new(entry, count, plural_rule_category)
else
super
end
end

protected

def pluralizers
@pluralizers ||= {}
end
def pluralizers
@pluralizers ||= {}
end

def pluralizer(locale)
pluralizers[locale] ||= I18n.t(:'i18n.plural.rule', :locale => locale, :resolve => false)
end
def pluralizer(locale)
pluralizers[locale] ||= I18n.t(:'i18n.plural.rule', :locale => locale, :resolve => false)
end

private

# Normalizes categories of 0.0 and 1.0
# and returns the symbolic version
def symbolic_count(count)
count = 0 if count == 0
count = 1 if count == 1
count.to_s.to_sym
end
end
end
end
27 changes: 20 additions & 7 deletions test/backend/pluralization_test.rb
Original file line number Diff line number Diff line change
@@ -9,16 +9,22 @@ class Backend < I18n::Backend::Simple
def setup
super
I18n.backend = Backend.new
@rule = lambda { |n| n == 1 ? :one : n == 0 || (2..10).include?(n % 100) ? :few : (11..19).include?(n % 100) ? :many : :other }
@rule = lambda { |n| n % 10 == 1 && n % 100 != 11 ? :one : n == 0 || (2..10).include?(n % 100) ? :few : (11..19).include?(n % 100) ? :many : :other }
store_translations(:xx, :i18n => { :plural => { :rule => @rule } })
@entry = { :zero => 'zero', :one => 'one', :few => 'few', :many => 'many', :other => 'other' }
@entry = { :"0" => 'none', :"1" => 'single', :one => 'one', :few => 'few', :many => 'many', :other => 'other' }
end

test "pluralization picks a pluralizer from :'i18n.pluralize'" do
assert_equal @rule, I18n.backend.send(:pluralizer, :xx)
end

test "pluralization picks :one for 1" do
test "pluralization picks the explicit 1 rule for count == 1, the explicit rule takes priority over the matching :one rule" do
assert_equal 'single', I18n.t(:count => 1, :default => @entry, :locale => :xx)
assert_equal 'single', I18n.t(:count => 1.0, :default => @entry, :locale => :xx)
end

test "pluralization picks :one for 1, since in this case that is the matching rule for 1 (when there is no explicit 1 rule)" do
@entry.delete(:"1")
assert_equal 'one', I18n.t(:count => 1, :default => @entry, :locale => :xx)
end

@@ -30,15 +36,22 @@ def setup
assert_equal 'many', I18n.t(:count => 11, :default => @entry, :locale => :xx)
end

test "pluralization picks zero for 0 if the key is contained in the data" do
assert_equal 'zero', I18n.t(:count => 0, :default => @entry, :locale => :xx)
test "pluralization picks explicit 0 rule for count == 0, since the explicit rule takes priority over the matching :few rule" do
assert_equal 'none', I18n.t(:count => 0, :default => @entry, :locale => :xx)
assert_equal 'none', I18n.t(:count => 0.0, :default => @entry, :locale => :xx)
assert_equal 'none', I18n.t(:count => -0, :default => @entry, :locale => :xx)
end

test "pluralization picks few for 0 if the key is not contained in the data" do
@entry.delete(:zero)
test "pluralization picks :few for 0 (when there is no explicit 0 rule)" do
@entry.delete(:"0")
assert_equal 'few', I18n.t(:count => 0, :default => @entry, :locale => :xx)
end

test "pluralization does Lateral Inheritance to :other to cover missing data" do
@entry.delete(:many)
assert_equal 'other', I18n.t(:count => 11, :default => @entry, :locale => :xx)
end

test "pluralization picks one for 1 if the entry has attributes hash on unknown locale" do
@entry[:attributes] = { :field => 'field', :second => 'second' }
assert_equal 'one', I18n.t(:count => 1, :default => @entry, :locale => :pirate)

0 comments on commit ed22cbd

Please sign in to comment.