Skip to content

Commit

Permalink
Merge pull request #634 from movermeyer/movermeyer/explicit_0_1_and_l…
Browse files Browse the repository at this point in the history
…ateral_inheritance

Get closer to full CLDR pluralization support
  • Loading branch information
radar authored Feb 3, 2023
2 parents 75fc49b + c78ca61 commit d94228c
Show file tree
Hide file tree
Showing 2 changed files with 82 additions and 23 deletions.
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

0 comments on commit d94228c

Please sign in to comment.