こんにちは、hachi8833です。2016年度最後の記事はActive Support探訪シリーズです。
今回はString#pluralizeにお邪魔します。
先日Railsが5.0.1になりましたので今後5.0.1を使うことにしました。
今回のメソッド
- メソッド: 
String#pluralize - ディレクトリ配置: https://github.com/rails/rails/blob/5-0-stable/activesupport/lib/active_support/core_ext/string/inflections.rb
 
条件
- Railsバージョン: 5-0-stable(執筆時点では5.0.1)
 - Rubyバージョン: 2.4.0
 
String#pluralizeについて
ご存じのとおり、RailsではMVCのクラス名やファイル名などで英語の単数形<=>複数形などの変換が多用されています。scaffold`コマンドを使ってモデル/ビュー/コントローラを生成すると、たとえば次のようになります。
| 要素 | 単複 | クラス名の例(キャメルケース) | ファイル名の例(スネークケース) | 
|---|---|---|---|
| コントローラ | 複数形 | AdminUsers | admin_users.rb | 
| モデル | 単数形 | AdminUser | admin_user.rb | 
| ビュー | 複数形 | AdminUsers | admin_users.erb | 
こうした変換に使われているのが、String#pluralizeなどの各種活用形メソッドです。
詳しくはRailsガイド: 活用形をご覧ください。
今回は、String#pluralizeで不規則な活用(octopus->octopiなど)が行われる部分に絞って追ってみたいと思います。きっとどこかに不規則活用表に相当するデータを持っているはずなので、それを見つけるところまでやってみます。
string/inflections.rb
例によってコメント行は省略しています。
require 'active_support/inflector/methods'
require 'active_support/inflector/transliterate'
class String
  def pluralize(count = nil, locale = :en)
    locale = count if count.is_a?(Symbol)
    if count == 1
      self.dup
    else
      ActiveSupport::Inflector.pluralize(self, locale)
    end
  end
  def singularize(locale = :en)
    ActiveSupport::Inflector.singularize(self, locale)
  end
  def constantize
    ActiveSupport::Inflector.constantize(self)
  end
  def safe_constantize
    ActiveSupport::Inflector.safe_constantize(self)
  end
  def camelize(first_letter = :upper)
    case first_letter
    when :upper
      ActiveSupport::Inflector.camelize(self, true)
    when :lower
      ActiveSupport::Inflector.camelize(self, false)
    end
  end
  alias_method :camelcase, :camelize
  def titleize
    ActiveSupport::Inflector.titleize(self)
  end
  alias_method :titlecase, :titleize
  def underscore
    ActiveSupport::Inflector.underscore(self)
  end
  def dasherize
    ActiveSupport::Inflector.dasherize(self)
  end
  def demodulize
    ActiveSupport::Inflector.demodulize(self)
  end
  def deconstantize
    ActiveSupport::Inflector.deconstantize(self)
  end
  def parameterize(sep = :unused, separator: '-', preserve_case: false)
    unless sep == :unused
      ActiveSupport::Deprecation.warn("Passing the separator argument as a positional parameter is deprecated and will soon be removed. Use `separator: '#{sep}'` instead.")
      separator = sep
    end
    ActiveSupport::Inflector.parameterize(self, separator: separator, preserve_case: preserve_case)
  end
  def tableize
    ActiveSupport::Inflector.tableize(self)
  end
  def classify
    ActiveSupport::Inflector.classify(self)
  end
  def humanize(options = {})
    ActiveSupport::Inflector.humanize(self, options)
  end
  def upcase_first
    ActiveSupport::Inflector.upcase_first(self)
  end
  def foreign_key(separate_class_name_and_id_with_underscore = true)
    ActiveSupport::Inflector.foreign_key(self, separate_class_name_and_id_with_underscore)
  end
end
一読してわかるのは、String#pluralizeを含むほとんどのメソッドがActiveSupport::Inflectorに丸投げされていることです。いわゆる委譲(delegate)ですね。
pluralizeとsingularizeはロケールによって変える必要があるので、ロケールを引数として渡せるようになっています。デフォルトのロケールはenです。
履歴をざっと見てみると、もともとはcore_ext/string/inflections.rbにあったコードが、7db0b0: Make ActiveSupport::Inflector locale aware and multilingualのマルチリンガル対応などの改修によってinflector/inflections.rbに順次移動したようです。
ActiveSupport::Inflector
というわけで、本体であるActiveSupport::Inflectorを見てみます。#pluralizeはどこでしょうか。
require 'concurrent/map'
require 'active_support/core_ext/array/prepend_and_append'
require 'active_support/i18n'
module ActiveSupport
  module Inflector
    extend self
    class Inflections
      @__instance__ = Concurrent::Map.new
      class Uncountables < Array
        def initialize
          @regex_array = []
          super
        end
        def delete(entry)
          super entry
          @regex_array.delete(to_regex(entry))
        end
        def <<(*word)
          add(word)
        end
        def add(words)
          self.concat(words.flatten.map(&:downcase))
          @regex_array += self.map {|word|  to_regex(word) }
          self
        end
        def uncountable?(str)
          @regex_array.any? { |regex| regex === str }
        end
        private
          def to_regex(string)
            /\b#{::Regexp.escape(string)}\Z/i
          end
      end
      def self.instance(locale = :en)
        @__instance__[locale] ||= new
      end
      attr_reader :plurals, :singulars, :uncountables, :humans, :acronyms, :acronym_regex
      def initialize
        @plurals, @singulars, @uncountables, @humans, @acronyms, @acronym_regex = [], [], Uncountables.new, [], {}, /(?=a)b/
      end
      # Private, for the test suite.
      def initialize_dup(orig) # :nodoc:
        %w(plurals singulars uncountables humans acronyms acronym_regex).each do |scope|
          instance_variable_set("@#{scope}", orig.send(scope).dup)
        end
      end
      def acronym(word)
        @acronyms[word.downcase] = word
        @acronym_regex = /#{@acronyms.values.join("|")}/
      end
      def plural(rule, replacement)
        @uncountables.delete(rule) if rule.is_a?(String)
        @uncountables.delete(replacement)
        @plurals.prepend([rule, replacement])
      end
      def singular(rule, replacement)
        @uncountables.delete(rule) if rule.is_a?(String)
        @uncountables.delete(replacement)
        @singulars.prepend([rule, replacement])
      end
      def irregular(singular, plural)
        @uncountables.delete(singular)
        @uncountables.delete(plural)
        s0 = singular[0]
        srest = singular[1..-1]
        p0 = plural[0]
        prest = plural[1..-1]
        if s0.upcase == p0.upcase
          plural(/(#{s0})#{srest}$/i, '\1' + prest)
          plural(/(#{p0})#{prest}$/i, '\1' + prest)
          singular(/(#{s0})#{srest}$/i, '\1' + srest)
          singular(/(#{p0})#{prest}$/i, '\1' + srest)
        else
          plural(/#{s0.upcase}(?i)#{srest}$/,   p0.upcase   + prest)
          plural(/#{s0.downcase}(?i)#{srest}$/, p0.downcase + prest)
          plural(/#{p0.upcase}(?i)#{prest}$/,   p0.upcase   + prest)
          plural(/#{p0.downcase}(?i)#{prest}$/, p0.downcase + prest)
          singular(/#{s0.upcase}(?i)#{srest}$/,   s0.upcase   + srest)
          singular(/#{s0.downcase}(?i)#{srest}$/, s0.downcase + srest)
          singular(/#{p0.upcase}(?i)#{prest}$/,   s0.upcase   + srest)
          singular(/#{p0.downcase}(?i)#{prest}$/, s0.downcase + srest)
        end
      end
      def uncountable(*words)
        @uncountables.add(words)
      end
      def human(rule, replacement)
        @humans.prepend([rule, replacement])
      end
      def clear(scope = :all)
        case scope
          when :all
            @plurals, @singulars, @uncountables, @humans = [], [], Uncountables.new, []
          else
            instance_variable_set "@#{scope}", []
        end
      end
    end
    def inflections(locale = :en)
      if block_given?
        yield Inflections.instance(locale)
      else
        Inflections.instance(locale)
      end
    end
  end
end
# Specifies a new pluralization rule and its replacement. # Specifies a new singularization rule and its replacement.
といったコメントが目につきます。
しかしinflector/inflections.rbにはInflectorモジュールはあるものの、意外にも#pluralizeメソッドが見当たりません。
inflector/methods.rbのInflectorモジュール
探してみたところ、#pluralizeはinflector/methods.rbのInflectorモジュールにありました。長くなるので今度はメソッド定義だけ取り出してみました。
def pluralize(word, locale = :en)
  apply_inflections(word, inflections(locale).plurals)
end
def singularize(word, locale = :en)
  apply_inflections(word, inflections(locale).singulars)
end
今度は#apply_inflectionsです。これは同じinflector/methods.rbにありました。#pluralizeも#apply_inflectionsもInflectorモジュールのメソッドです。
def apply_inflections(word, rules)
  result = word.to_s.dup
  if word.empty? || inflections.uncountables.uncountable?(result)
    result
  else
    rules.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
    result
  end
end
inflections(locale).pluralsやinflections(locale).singularsを引数として渡すことで動作を変更するようになっています。
では引数のinflections(locale)はどこにあるのでしょうか。探してみるとinflector/inflections.rbのInflectorモジュールにありました。
def inflections(locale = :en)
  if block_given?
    yield Inflections.instance(locale)
  else
    Inflections.instance(locale)
  end
end
ブロックを渡すかどうかで若干動作が異なりますが、今度はInflections.instanceを呼び出しています。だんだん不安になってきました。Inflections.instanceはどこにあるのでしょうか。
inflections.rb
活用表に相当するデータは、結局active_support/inflections.rbのActiveSupportモジュールで見つかりました。
module ActiveSupport
  Inflector.inflections(:en) do |inflect|
    inflect.plural(/$/, "s")
    inflect.plural(/s$/i, "s")
    inflect.plural(/^(ax|test)is$/i, '\1es')
    inflect.plural(/(octop|vir)us$/i, '\1i')
    inflect.plural(/(octop|vir)i$/i, '\1i'
...
    inflect.singular(/s$/i, "")
    inflect.singular(/(ss)$/i, '\1')
    inflect.singular(/(n)ews$/i, '\1ews')
    inflect.singular(/([ti])a$/i, '\1um')
    inflect.singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis')
...
    inflect.irregular("person", "people")
    inflect.irregular("man", "men")
    inflect.irregular("child", "children")
...
    inflect.uncountable(%w(equipment information rice money species series fish sheep jeans police))
  end
end
英語の宿命である不規則活用は機械的な活用形を拒むので、こうした対応表がどこかで必要になります。
senpaiのような新語や外来語は、不規則活用表になければ当然規則活用が適用されることになります。
#pluralizeや#singularizeはRailsで必要な活用変化をカバーするのが目的と考えるのが自然なので、person<->peopleなど、Rails開発で使われる可能性のある語彙に絞っていると理解しました。英語のあらゆる不規則活用表をカバーするのは現実的ではなさそうですし、何より不規則活用表を進んでメンテする人がいなさそうです。
#pluralizeがこんなにたらい回しにされているあたりにRailsの歴史を感じてしまいました。
活用形のカスタマイズ方法
活用形周りをカスタマイズする方法については、以下の外部記事をご覧ください。
記事中にもあるとおり、Railsのconfig/initializers/inflections.rbに追記すればよいです。以下にRails 5のinflections.rbをざっくり訳して引用します。
# このファイルを変更後サーバーを必ず再起動すること
# 以下のフォーマットで新しい活用形ルールを追加できる
# 活用形はロケールに依存するので、好きなだけロケールごとの活用ルールを設定できる
# ちなみに以下の例はすべてデフォルトで有効になっている(のでここで足さなくてよい)
# ActiveSupport::Inflector.inflections(:en) do |inflect|
#   inflect.plural /^(ox)$/i, '\1en'
#   inflect.singular /^(ox)en/i, '\1'
#   inflect.irregular 'person', 'people'
#   inflect.uncountable %w( fish sheep )
# end
# 以下の活用ルールはサポートされているがデフォルトでは有効になっていない
# ActiveSupport::Inflector.inflections(:en) do |inflect|
#   inflect.acronym 'RESTful'
# end
サンプル部分をコードハイライトしたかったので取り出してみました。
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.plural /^(ox)$/i, '\1en'
  inflect.singular /^(ox)en/i, '\1'
  inflect.irregular 'person', 'people'
  inflect.uncountable %w( fish sheep )
end
ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym 'RESTful'
end
最後に
ActiveSupport::Inflectorには便利メソッドが多いので、次回は他のメソッドも概観してみようと思います。