`underscore`メソッドで重複する頭字語の順序依存性を解消
RailsのActiveSupport::Inflector#underscoreメソッドで、重複する頭字語(acronym)を定義した際の挙動が不安定になる問題が修正されました。
背景
ActiveSupport::Inflectorでは、inflect.acronymを使ってAPIやURLなどの頭字語を登録できます。しかし、「USD」と「USDC」のように一方が他方の部分文字列になっているケースでは、登録順序によってunderscoreの結果が変わる問題がありました。
これは内部で生成される正規表現が交替演算子(|)を使用しており、左から順に評価されるためです。短い頭字語が先に登録されていると、長い頭字語が正しくマッチすべき場合でも短い方が優先されてしまいます。
技術的な変更
変更前:
def define_acronym_regex_patterns
@acronym_regex = @acronyms.empty? ? /(?=a)b/ : /#{@acronyms.values.join("|")}/
@acronyms_camelize_regex = /^(?:#{@acronym_regex}(?=\b|[A-Z_])|\w)/
@acronyms_underscore_regex = /(?:(?<=([A-Za-z\d]))|\b)(#{@acronym_regex})(?=\b|[^a-z])/
end
変更後:
def define_acronym_regex_patterns
sorted_acronyms = @acronyms.empty? ? [] : @acronyms.values.sort_by { |a| -a.length }
@acronym_regex = sorted_acronyms.empty? ? /(?=a)b/ : /#{sorted_acronyms.join("|")}/
@acronyms_camelize_regex = /^(?:#{@acronym_regex}(?=\b|[A-Z_])|\w)/
@acronyms_underscore_regex = /(?:(?<=([A-Za-z\d]))|\b)(#{@acronym_regex})(?=\b|[^a-z])/
end
頭字語を長さの降順(sort_by { |a| -a.length })でソートしてから正規表現を構築するようになりました。これにより、常に長い頭字語が優先的にマッチするようになります。
動作例:
ActiveSupport::Inflector.inflections do |inflect|
inflect.acronym "USD"
inflect.acronym "USDC"
end
"USD".underscore # => "usd"
"USDC".underscore # => "usdc"
登録順序に関わらず、「USDC」は「usdc」に、「USD」は「usd」に正しく変換されます。
設計判断
この修正は正規表現の評価順序を制御することで問題を解決しています。代替案として頭字語の境界判定ロジックを複雑化する方法も考えられますが、ソートによる解決は実装がシンプルで、パフォーマンスへの影響も最小限です。
また、sort_byの結果をキャッシュせず、define_acronym_regex_patternsが呼ばれるたびにソートを実行する設計になっています。これは頭字語の登録が初期化時にのみ行われる想定のため、実行時のオーバーヘッドよりもコードの明瞭性を優先した判断と考えられます。