Skip to content

Commit 6290cbb

Browse files
committed
Get closer to full CLDR pluralization support
Adds explicit 0/1 keys and basic lateral inheritance
1 parent 32c957e commit 6290cbb

File tree

2 files changed

+82
-23
lines changed

2 files changed

+82
-23
lines changed

lib/i18n/backend/pluralization.rb

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,40 +16,81 @@ module Backend
1616
module Pluralization
1717
# Overwrites the Base backend translate method so that it will check the
1818
# translation meta data space (:i18n) for a locale specific pluralization
19-
# rule and use it to pluralize the given entry. I.e. the library expects
19+
# rule and use it to pluralize the given entry. I.e., the library expects
2020
# pluralization rules to be stored at I18n.t(:'i18n.plural.rule')
2121
#
2222
# Pluralization rules are expected to respond to #call(count) and
23-
# return a pluralization key. Valid keys depend on the translation data
24-
# hash (entry) but it is generally recommended to follow CLDR's style,
25-
# i.e., return one of the keys :zero, :one, :few, :many, :other.
23+
# return a pluralization key. Valid keys depend on the pluralization
24+
# rules for the locale, as defined in the CLDR.
25+
# As of v41, 6 locale-specific plural categories are defined:
26+
# :few, :many, :one, :other, :two, :zero
2627
#
27-
# The :zero key is always picked directly when count equals 0 AND the
28-
# translation data has the key :zero. This way translators are free to
29-
# either pick a special :zero translation even for languages where the
30-
# pluralizer does not return a :zero key.
28+
# n.b., The :one plural category does not imply the number 1.
29+
# Instead, :one is a category for any number that behaves like 1 in
30+
# that locale. For example, in some locales, :one is used for numbers
31+
# that end in "1" (like 1, 21, 151) but that don't end in
32+
# 11 (like 11, 111, 10311).
33+
# Similar notes apply to the :two, and :zero plural categories.
34+
#
35+
# If you want to have different strings for the categories of count == 0
36+
# (e.g. "I don't have any cars") or count == 1 (e.g. "I have a single car")
37+
# use the explicit `"0"` and `"1"` keys.
38+
# https://unicode-org.github.io/cldr/ldml/tr35-numbers.html#Explicit_0_1_rules
3139
def pluralize(locale, entry, count)
3240
return entry unless entry.is_a?(Hash) && count
3341

3442
pluralizer = pluralizer(locale)
3543
if pluralizer.respond_to?(:call)
36-
key = count == 0 && entry.has_key?(:zero) ? :zero : pluralizer.call(count)
37-
raise InvalidPluralizationData.new(entry, count, key) unless entry.has_key?(key)
38-
entry[key]
44+
# Deprecation: The use of the `zero` key in this way is incorrect.
45+
# Users that want a different string for the case of `count == 0` should use the explicit "0" key instead.
46+
# We keep this incorrect behaviour for now for backwards compatibility until we can remove it.
47+
# Ref: https://github.com/ruby-i18n/i18n/issues/629
48+
return entry[:zero] if count == 0 && entry.has_key?(:zero)
49+
50+
# "0" and "1" are special cases
51+
# https://unicode-org.github.io/cldr/ldml/tr35-numbers.html#Explicit_0_1_rules
52+
if count == 0 || count == 1
53+
value = entry[symbolic_count(count)]
54+
return value if value
55+
end
56+
57+
# Lateral Inheritance of "count" attribute (http://www.unicode.org/reports/tr35/#Lateral_Inheritance):
58+
# > If there is no value for a path, and that path has a [@count="x"] attribute and value, then:
59+
# > 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.
60+
# > 2. If "x" is anything but "other", it falls back to a path [@count="other"], within that the same locale.
61+
# > 3. If "x" is "other", it falls back to the path that is completely missing the count item, within that the same locale.
62+
# Note: We don't yet implement #3 above, since we haven't decided how lateral inheritance attributes should be represented.
63+
plural_rule_category = pluralizer.call(count)
64+
65+
value = if entry.has_key?(plural_rule_category) || entry.has_key?(:other)
66+
entry[plural_rule_category] || entry[:other]
67+
else
68+
raise InvalidPluralizationData.new(entry, count, plural_rule_category)
69+
end
3970
else
4071
super
4172
end
4273
end
4374

4475
protected
4576

46-
def pluralizers
47-
@pluralizers ||= {}
48-
end
77+
def pluralizers
78+
@pluralizers ||= {}
79+
end
4980

50-
def pluralizer(locale)
51-
pluralizers[locale] ||= I18n.t(:'i18n.plural.rule', :locale => locale, :resolve => false)
52-
end
81+
def pluralizer(locale)
82+
pluralizers[locale] ||= I18n.t(:'i18n.plural.rule', :locale => locale, :resolve => false)
83+
end
84+
85+
private
86+
87+
# Normalizes categories of 0.0 and 1.0
88+
# and returns the symbolic version
89+
def symbolic_count(count)
90+
count = 0 if count == 0
91+
count = 1 if count == 1
92+
count.to_s.to_sym
93+
end
5394
end
5495
end
5596
end

test/backend/pluralization_test.rb

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,23 @@ class Backend < I18n::Backend::Simple
99
def setup
1010
super
1111
I18n.backend = Backend.new
12-
@rule = lambda { |n| n == 1 ? :one : n == 0 || (2..10).include?(n % 100) ? :few : (11..19).include?(n % 100) ? :many : :other }
12+
@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 }
1313
store_translations(:xx, :i18n => { :plural => { :rule => @rule } })
14-
@entry = { :zero => 'zero', :one => 'one', :few => 'few', :many => 'many', :other => 'other' }
14+
@entry = { :"0" => 'none', :"1" => 'single', :one => 'one', :few => 'few', :many => 'many', :other => 'other' }
15+
@entry_with_zero = @entry.merge( { :zero => 'zero' } )
1516
end
1617

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

21-
test "pluralization picks :one for 1" do
22+
test "pluralization picks the explicit 1 rule for count == 1, the explicit rule takes priority over the matching :one rule" do
23+
assert_equal 'single', I18n.t(:count => 1, :default => @entry, :locale => :xx)
24+
assert_equal 'single', I18n.t(:count => 1.0, :default => @entry, :locale => :xx)
25+
end
26+
27+
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
28+
@entry.delete(:"1")
2229
assert_equal 'one', I18n.t(:count => 1, :default => @entry, :locale => :xx)
2330
end
2431

@@ -31,14 +38,25 @@ def setup
3138
end
3239

3340
test "pluralization picks zero for 0 if the key is contained in the data" do
34-
assert_equal 'zero', I18n.t(:count => 0, :default => @entry, :locale => :xx)
41+
assert_equal 'zero', I18n.t(:count => 0, :default => @entry_with_zero, :locale => :xx)
42+
end
43+
44+
test "pluralization picks explicit 0 rule for count == 0, since the explicit rule takes priority over the matching :few rule" do
45+
assert_equal 'none', I18n.t(:count => 0, :default => @entry, :locale => :xx)
46+
assert_equal 'none', I18n.t(:count => 0.0, :default => @entry, :locale => :xx)
47+
assert_equal 'none', I18n.t(:count => -0, :default => @entry, :locale => :xx)
3548
end
3649

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

55+
test "pluralization does Lateral Inheritance to :other to cover missing data" do
56+
@entry.delete(:many)
57+
assert_equal 'other', I18n.t(:count => 11, :default => @entry, :locale => :xx)
58+
end
59+
4260
test "pluralization picks one for 1 if the entry has attributes hash on unknown locale" do
4361
@entry[:attributes] = { :field => 'field', :second => 'second' }
4462
assert_equal 'one', I18n.t(:count => 1, :default => @entry, :locale => :pirate)

0 commit comments

Comments
 (0)