Skip to content

Commit

Permalink
Add new Rails/HashLiteralKeysConversion cop
Browse files Browse the repository at this point in the history
  • Loading branch information
fatkodima committed Aug 18, 2024
1 parent 7616bde commit 661d89b
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog/new_hash_literal_keys_conversion_cop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#1332](https://github.com/rubocop/rubocop-rails/issues/1332): Add new `Rails/HashLiteralKeysConversion` cop. ([@fatkodima][])
5 changes: 5 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,11 @@ Rails/HasManyOrHasOneDependent:
Include:
- app/models/**/*.rb

Rails/HashLiteralKeysConversion:
Description: 'Convert hash literal keys manually instead of using keys conversion methods.'
Enabled: pending
VersionAdded: '<<next>>'

Rails/HelperInstanceVariable:
Description: 'Do not use instance variables in helpers.'
Enabled: true
Expand Down
117 changes: 117 additions & 0 deletions lib/rubocop/cop/rails/hash_literal_keys_conversion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Rails
# Detects when keys conversion methods are called on literal hashes, where it is redundant
# or keys can be manually converted to the required type.
#
# @example
# # bad
# { a: 1, b: 2 }.symbolize_keys
#
# # bad
# { a: 1, b: 2 }.stringify_keys
#
# # good
# { 'a' => 1, 'b' => 2 }
#
# # good
# { a: 1, var => 3 }.symbolize_keys
#
class HashLiteralKeysConversion < Base
extend AutoCorrector

REDUNDANT_CONVERSION_MSG = 'Redundant hash keys conversion, all the keys have the required type.'
MSG = 'Convert hash keys explicitly to the required type.'

CONVERSION_METHODS = {
symbolize_keys: :sym,
symbolize_keys!: :sym,
stringify_keys: :str,
stringify_keys!: :str,
deep_symbolize_keys: :sym,
deep_symbolize_keys!: :sym,
deep_stringify_keys: :str,
deep_stringify_keys!: :str
}.freeze

RESTRICT_ON_SEND = CONVERSION_METHODS.keys

def on_send(node)
return unless (receiver = node.receiver)&.hash_type?
return unless convertible_hash?(receiver)

type = CONVERSION_METHODS[node.method_name]
deep = node.method_name.start_with?('deep_')

check(node, receiver, type: type, deep: deep)
end

# rubocop:disable Metrics/AbcSize
def check(node, hash_node, type: :sym, deep: false)
pair_nodes = pair_nodes(hash_node, deep: deep)

type_pairs, other_pairs = pair_nodes.partition { |pair_node| pair_node.key.type == type }

if type_pairs == pair_nodes
add_offense(node.loc.selector, message: REDUNDANT_CONVERSION_MSG) do |corrector|
corrector.remove(node.loc.dot)
corrector.remove(node.loc.selector)
end
else
add_offense(node.loc.selector) do |corrector|
corrector.remove(node.loc.dot)
corrector.remove(node.loc.selector)
autocorrect_hash_keys(other_pairs, type, corrector)
end
end
end
# rubocop:enable Metrics/AbcSize

private

def convertible_hash?(node)
node.pairs.each do |pair|
key, value = *pair
return false if pair.value_omission?
return false unless key.str_type? || key.sym_type?
return false if key.value.match?(/\W/)
return convertible_hash?(value) if value.hash_type?
end

true
end

def pair_nodes(hash_node, deep: false)
if deep
pair_nodes = []
do_pair_nodes(hash_node, pair_nodes)
pair_nodes
else
hash_node.pairs
end
end

def do_pair_nodes(hash_node, pair_nodes)
hash_node.pairs.each do |pair_node|
pair_nodes << pair_node
do_pair_nodes(pair_node.value, pair_nodes) if pair_node.value.hash_type?
end
end

def autocorrect_hash_keys(pair_nodes, type, corrector)
pair_nodes.each do |pair_node|
if type == :sym
corrector.replace(pair_node.key, ":#{pair_node.key.value}")
else
corrector.replace(pair_node.key, "'#{pair_node.key.source}'")
end

corrector.replace(pair_node.loc.operator, '=>')
end
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rails_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
require_relative 'rails/freeze_time'
require_relative 'rails/has_and_belongs_to_many'
require_relative 'rails/has_many_or_has_one_dependent'
require_relative 'rails/hash_literal_keys_conversion'
require_relative 'rails/helper_instance_variable'
require_relative 'rails/http_positional_arguments'
require_relative 'rails/http_status'
Expand Down
231 changes: 231 additions & 0 deletions spec/rubocop/cop/rails/hash_literal_keys_conversion_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Rails::HashLiteralKeysConversion, :config do
it 'registers an offense and corrects when using `symbolize_keys` with only symbol keys' do
expect_offense(<<~RUBY)
{ a: 1, b: 2 }.symbolize_keys
^^^^^^^^^^^^^^ Redundant hash keys conversion, all the keys have the required type.
RUBY

expect_correction(<<~RUBY)
{ a: 1, b: 2 }
RUBY
end

it 'registers an offense and corrects when using `symbolize_keys` with only symbol and string keys' do
expect_offense(<<~RUBY)
{ a: 1, 'b' => 2 }.symbolize_keys
^^^^^^^^^^^^^^ Convert hash keys explicitly to the required type.
RUBY

expect_correction(<<~RUBY)
{ a: 1, :b => 2 }
RUBY
end

it 'does not register an offense when using `symbolize_keys` with integer keys' do
expect_no_offenses(<<~RUBY)
{ a: 1, 2 => 3 }.symbolize_keys
RUBY
end

it 'does not register an offense when using `symbolize_keys` with non hash literal receiver' do
expect_no_offenses(<<~RUBY)
options.symbolize_keys
RUBY
end

it 'registers an offense and corrects when using `stringify_keys` with only string keys' do
expect_offense(<<~RUBY)
{ 'a' => 1, 'b' => 2 }.stringify_keys
^^^^^^^^^^^^^^ Redundant hash keys conversion, all the keys have the required type.
RUBY

expect_correction(<<~RUBY)
{ 'a' => 1, 'b' => 2 }
RUBY
end

it 'registers an offense and corrects when using `stringify_keys` with only symbol and string keys' do
expect_offense(<<~RUBY)
{ a: 1, 'b' => 2 }.stringify_keys
^^^^^^^^^^^^^^ Convert hash keys explicitly to the required type.
RUBY

expect_correction(<<~RUBY)
{ 'a'=> 1, 'b' => 2 }
RUBY
end

it 'does not register an offense when using `stringify_keys` with integer keys' do
expect_no_offenses(<<~RUBY)
{ 'a' => 1, 2 => 3 }.stringify_keys
RUBY
end

it 'does not register an offense when using `stringify_keys` with non hash literal receiver' do
expect_no_offenses(<<~RUBY)
options.stringify_keys
RUBY
end

it 'registers an offense and corrects when using `deep_symbolize_keys` with symbol keys' do
expect_offense(<<~RUBY)
{
a: 1,
b: {
c: 1
}
}.deep_symbolize_keys
^^^^^^^^^^^^^^^^^^^ Redundant hash keys conversion, all the keys have the required type.
RUBY

expect_correction(<<~RUBY)
{
a: 1,
b: {
c: 1
}
}
RUBY
end

it 'registers an offense and corrects when using `deep_symbolize_keys` with symbol and string keys' do
expect_offense(<<~RUBY)
{
'a' => 1,
b: {
c: 1
}
}.deep_symbolize_keys
^^^^^^^^^^^^^^^^^^^ Convert hash keys explicitly to the required type.
RUBY

expect_correction(<<~RUBY)
{
:a => 1,
b: {
c: 1
}
}
RUBY
end

it 'registers an offense and corrects when using `deep_symbolize_keys` with flat and only symbol and string keys' do
expect_offense(<<~RUBY)
{
'a' => 1,
b: 2
}.deep_symbolize_keys
^^^^^^^^^^^^^^^^^^^ Convert hash keys explicitly to the required type.
RUBY

expect_correction(<<~RUBY)
{
:a => 1,
b: 2
}
RUBY
end

it 'does not register an offense when using `deep_symbolize_keys` with integer keys' do
expect_no_offenses(<<~RUBY)
{
'a' => 1,
b: {
2 => 3
}
}.deep_symbolize_keys
RUBY
end

it 'does not register an offense when using `deep_symbolize_keys` with non hash literal receiver' do
expect_no_offenses(<<~RUBY)
options.deep_symbolize_keys
RUBY
end

it 'registers an offense and corrects when using `deep_stringify_keys` with only string keys' do
expect_offense(<<~RUBY)
{
'a' => 1,
'b' => {
'c' => 1
}
}.deep_stringify_keys
^^^^^^^^^^^^^^^^^^^ Redundant hash keys conversion, all the keys have the required type.
RUBY

expect_correction(<<~RUBY)
{
'a' => 1,
'b' => {
'c' => 1
}
}
RUBY
end

it 'registers an offense and corrects when using `deep_stringify_keys` with only symbol and string keys' do
expect_offense(<<~RUBY)
{
'a' => 1,
b: {
c: 1
}
}.deep_stringify_keys
^^^^^^^^^^^^^^^^^^^ Convert hash keys explicitly to the required type.
RUBY

expect_correction(<<~RUBY)
{
'a' => 1,
'b'=> {
'c'=> 1
}
}
RUBY
end

it 'does not register an offense when using `deep_stringify_keys` with integer keys' do
expect_no_offenses(<<~RUBY)
{
'a' => 1,
b: {
2 => 3
}
}.deep_stringify_keys
RUBY
end

it 'does not register an offense when using `deep_stringify_keys` with non hash literal receiver' do
expect_no_offenses(<<~RUBY)
options.deep_stringify_keys
RUBY
end

it 'registers an offense and autocorrects when using `symbolize_keys` with empty hash literal' do
expect_offense(<<~RUBY)
{}.symbolize_keys
^^^^^^^^^^^^^^ Redundant hash keys conversion, all the keys have the required type.
RUBY

expect_correction(<<~RUBY)
{}
RUBY
end

it 'does not register an offense when using `symbolize_keys` with non alphanumeric keys' do
expect_no_offenses(<<~RUBY)
{ 'hello world' => 1 }.symbolize_keys
RUBY
end

context 'Ruby >= 3.1', :ruby31 do
it 'does not register an offense when using hash value omission' do
expect_no_offenses(<<~RUBY)
{ a:, b: 2 }.stringify_keys
RUBY
end
end
end

0 comments on commit 661d89b

Please sign in to comment.