diff --git a/lib/i18n/backend/pluralization.rb b/lib/i18n/backend/pluralization.rb index b6026571..1d3277b8 100644 --- a/lib/i18n/backend/pluralization.rb +++ b/lib/i18n/backend/pluralization.rb @@ -16,26 +16,57 @@ 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 @@ -43,13 +74,23 @@ def pluralize(locale, entry, count) 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 diff --git a/test/backend/pluralization_test.rb b/test/backend/pluralization_test.rb index d955818e..dee4ac70 100644 --- a/test/backend/pluralization_test.rb +++ b/test/backend/pluralization_test.rb @@ -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 @@ -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)