diff --git a/changelog/new_add_new_dot_separated_keys_cop.md b/changelog/new_add_new_dot_separated_keys_cop.md new file mode 100644 index 0000000000..6a91b47d7f --- /dev/null +++ b/changelog/new_add_new_dot_separated_keys_cop.md @@ -0,0 +1 @@ +* [#325](https://github.com/rubocop/rubocop-rails/pull/325): Add new `Rails/DotSeparatedKeys` cop. ([@fatkodima][]) diff --git a/config/default.yml b/config/default.yml index af63332d2f..c951800d7f 100644 --- a/config/default.yml +++ b/config/default.yml @@ -274,6 +274,12 @@ Rails/DeprecatedActiveModelErrorsMethods: VersionAdded: '2.14' VersionChanged: '<>' +Rails/DotSeparatedKeys: + Description: 'Enforces the use of dot-separated keys instead of `:scope` options in `I18n` translation methods.' + StyleGuide: 'https://rails.rubystyle.guide/#dot-separated-keys' + Enabled: pending + VersionAdded: '<>' + Rails/DuplicateAssociation: Description: "Don't repeat associations in a model." Enabled: pending diff --git a/lib/rubocop/cop/rails/dot_separated_keys.rb b/lib/rubocop/cop/rails/dot_separated_keys.rb new file mode 100644 index 0000000000..e2b8b795e7 --- /dev/null +++ b/lib/rubocop/cop/rails/dot_separated_keys.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module Rails + # Enforces the use of dot-separated locale keys instead of specifying the `:scope` option + # with an array or a single symbol in `I18n` translation methods. + # Dot-separated notation is easier to read and trace the hierarchy. + # + # @example + # # bad + # I18n.t :record_invalid, scope: [:activerecord, :errors, :messages] + # I18n.t :title, scope: :invitation + # + # # good + # I18n.t 'activerecord.errors.messages.record_invalid' + # I18n.t :record_invalid, scope: 'activerecord.errors.messages' + # + class DotSeparatedKeys < Base + include RangeHelp + extend AutoCorrector + + MSG = 'Use the dot-separated keys instead of specifying the `:scope` option.' + TRANSLATE_METHODS = %i[translate t].freeze + + def_node_matcher :translate_with_scope?, <<~PATTERN + (send {nil? (const nil? :I18n)} {:translate :t} ${sym_type? str_type?} + (hash <$(pair (sym :scope) ${array_type? sym_type?}) ...>) + ) + PATTERN + + def on_send(node) + return unless TRANSLATE_METHODS.include?(node.method_name) + + translate_with_scope?(node) do |key_node, scope_node| + return unless should_convert_scope?(scope_node) + + add_offense(scope_node) do |corrector| + # Eat the comma on the left. + range = range_with_surrounding_space(range: scope_node.source_range, side: :left) + range = range_with_surrounding_comma(range, :left) + corrector.remove(range) + + corrector.replace(key_node, new_key(key_node, scope_node)) + end + end + end + + private + + def should_convert_scope?(scope_node) + scopes(scope_node).all?(&:basic_literal?) + end + + def new_key(key_node, scope_node) + "'#{scopes(scope_node).map(&:value).join('.')}.#{key_node.value}'" + end + + def scopes(scope_node) + value = scope_node.value + + if value.array_type? + value.values + else + [value] + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rails_cops.rb b/lib/rubocop/cop/rails_cops.rb index 2b6fdc9c1c..8bc2be7455 100644 --- a/lib/rubocop/cop/rails_cops.rb +++ b/lib/rubocop/cop/rails_cops.rb @@ -34,6 +34,7 @@ require_relative 'rails/delegate' require_relative 'rails/delegate_allow_blank' require_relative 'rails/deprecated_active_model_errors_methods' +require_relative 'rails/dot_separated_keys' require_relative 'rails/duplicate_association' require_relative 'rails/duplicate_scope' require_relative 'rails/duration_arithmetic' diff --git a/spec/rubocop/cop/rails/dot_separated_keys_spec.rb b/spec/rubocop/cop/rails/dot_separated_keys_spec.rb new file mode 100644 index 0000000000..dd838631d5 --- /dev/null +++ b/spec/rubocop/cop/rails/dot_separated_keys_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::Rails::DotSeparatedKeys, :config do + it 'registers an offense and corrects when translating keys with convertible scopes' do + expect_offense(<<~RUBY) + I18n.t :key, scope: [:one, :two] + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + I18n.translate :key, scope: [:one, :two] + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + t :key, scope: [:one, :two] + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + translate :key, scope: [:one, :two] + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + t :key, scope: [:one, :two], default: 'Not here' + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + I18n.t :key, scope: ['one', :two] + ^^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + I18n.t 'key', scope: [:one, :two] + ^^^^^^^^^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + I18n.t :key, scope: :one + ^^^^^^^^^^^ Use the dot-separated keys instead of specifying the `:scope` option. + RUBY + + expect_correction(<<~RUBY) + I18n.t 'one.two.key' + I18n.translate 'one.two.key' + t 'one.two.key' + translate 'one.two.key' + t 'one.two.key', default: 'Not here' + I18n.t 'one.two.key' + I18n.t 'one.two.key' + I18n.t 'one.key' + RUBY + end + + it 'does not register an offense when key is an array' do + expect_no_offenses(<<~RUBY) + t [:key1, :key2], scope: :one + RUBY + end + + it 'does not register an offense when key is not a basic literal' do + expect_no_offenses(<<~RUBY) + t key1, scope: :one + RUBY + end + + it 'does not register an offense when `scope` is an array containing non literals' do + expect_no_offenses(<<~RUBY) + t :key, scope: [:one, two] + RUBY + end + + it 'does not register an offense when `scope` is a string' do + expect_no_offenses(<<~RUBY) + t :key, scope: 'one' + RUBY + end + + it 'does not register an offense when `scope` is not a literal' do + expect_no_offenses(<<~RUBY) + t :key, scope: something + RUBY + end + + it 'does not register an offense when there is no `scope`' do + expect_no_offenses(<<~RUBY) + t :key + RUBY + end +end