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

Get closer to full CLDR pluralization support #634

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
75 changes: 58 additions & 17 deletions lib/i18n/backend/pluralization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,81 @@ 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]
# Deprecation: The use of the `zero` key in this way is incorrect.
# Users that want a different string for the case of `count == 0` should use the explicit "0" key instead.
# We keep this incorrect behaviour for now for backwards compatibility until we can remove it.
# Ref: https://github.com/ruby-i18n/i18n/issues/629
return entry[:zero] if count == 0 && entry.has_key?(:zero)

# "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 = if entry.has_key?(plural_rule_category) || entry.has_key?(:other)
entry[plural_rule_category] || entry[:other]
else
raise InvalidPluralizationData.new(entry, count, plural_rule_category)
end
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
30 changes: 24 additions & 6 deletions test/backend/pluralization_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,23 @@ 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' }
@entry_with_zero = @entry.merge( { :zero => 'zero' } )
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

Expand All @@ -31,14 +38,25 @@ def setup
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)
assert_equal 'zero', I18n.t(:count => 0, :default => @entry_with_zero, :locale => :xx)
end

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)
Expand Down