Skip to content

Commit bee3bb5

Browse files
koicbbatsov
authored andcommitted
Add new Style/HashExcept cop
This PR adds new `Style/HashExcept` cop. This cop checks for usages of `Hash#reject`, `Hash#select`, and `Hash#filter` methods that can be replaced with `Hash#except` method. This cop should only be enabled on Ruby version 3.0 or higher. (`Hash#except` was added in Ruby 3.0.) ```ruby # bad {foo: 1, bar: 2, baz: 3}.reject {|k, v| k == :bar } {foo: 1, bar: 2, baz: 3}.select {|k, v| k != :bar } # good {foo: 1, bar: 2, baz: 3}.except(:bar) ``` cf. https://bugs.ruby-lang.org/issues/15822
1 parent d160595 commit bee3bb5

File tree

5 files changed

+272
-0
lines changed

5 files changed

+272
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#9283](https://github.com/rubocop-hq/rubocop/pull/9283): Add new `Style/HashExcept` cop. ([@koic][])

config/default.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3355,6 +3355,13 @@ Style/HashEachMethods:
33553355
VersionAdded: '0.80'
33563356
Safe: false
33573357

3358+
Style/HashExcept:
3359+
Description: >-
3360+
Checks for usages of `Hash#reject`, `Hash#select`, and `Hash#filter` methods
3361+
that can be replaced with `Hash#except` method.
3362+
Enabled: pending
3363+
VersionAdded: '<<next>>'
3364+
33583365
Style/HashLikeCase:
33593366
Description: >-
33603367
Checks for places where `case-when` represents a simple 1:1

lib/rubocop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@
463463
require_relative 'rubocop/cop/style/guard_clause'
464464
require_relative 'rubocop/cop/style/hash_as_last_array_item'
465465
require_relative 'rubocop/cop/style/hash_each_methods'
466+
require_relative 'rubocop/cop/style/hash_except'
466467
require_relative 'rubocop/cop/style/hash_like_case'
467468
require_relative 'rubocop/cop/style/hash_syntax'
468469
require_relative 'rubocop/cop/style/hash_transform_keys'
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Style
6+
# This cop checks for usages of `Hash#reject`, `Hash#select`, and `Hash#filter` methods
7+
# that can be replaced with `Hash#except` method.
8+
#
9+
# This cop should only be enabled on Ruby version 3.0 or higher.
10+
# (`Hash#except` was added in Ruby 3.0.)
11+
#
12+
# For safe detection, it is limited to commonly used string and symbol comparisons
13+
# when used `==`.
14+
# And do not check `Hash#delete_if` and `Hash#keep_if` to change receiver object.
15+
#
16+
# @example
17+
#
18+
# # bad
19+
# {foo: 1, bar: 2, baz: 3}.reject {|k, v| k == :bar }
20+
# {foo: 1, bar: 2, baz: 3}.select {|k, v| k != :bar }
21+
# {foo: 1, bar: 2, baz: 3}.filter {|k, v| k != :bar }
22+
#
23+
# # good
24+
# {foo: 1, bar: 2, baz: 3}.except(:bar)
25+
#
26+
class HashExcept < Base
27+
include RangeHelp
28+
extend TargetRubyVersion
29+
extend AutoCorrector
30+
31+
minimum_target_ruby_version 3.0
32+
33+
MSG = 'Use `%<prefer>s` instead.'
34+
RESTRICT_ON_SEND = %i[reject select filter].freeze
35+
36+
def_node_matcher :bad_method?, <<~PATTERN
37+
(block
38+
(send _ _)
39+
(args
40+
(arg _)
41+
(arg _))
42+
(send
43+
_ {:== :!= :eql?} _))
44+
PATTERN
45+
46+
def on_send(node)
47+
block = node.parent
48+
return unless bad_method?(block) && semantically_except_method?(node, block)
49+
50+
except_key = except_key(block)
51+
return unless safe_to_register_offense?(block, except_key)
52+
53+
range = offense_range(node)
54+
preferred_method = "except(#{except_key.source})"
55+
56+
add_offense(range, message: format(MSG, prefer: preferred_method)) do |corrector|
57+
corrector.replace(range, preferred_method)
58+
end
59+
end
60+
61+
private
62+
63+
def semantically_except_method?(send, block)
64+
body = block.body
65+
66+
case send.method_name
67+
when :reject
68+
body.method?('==') || body.method?('eql?')
69+
when :select, :filter
70+
body.method?('!=')
71+
else
72+
false
73+
end
74+
end
75+
76+
def safe_to_register_offense?(block, except_key)
77+
return true if block.body.method?('eql?')
78+
79+
except_key.sym_type? || except_key.str_type?
80+
end
81+
82+
def except_key(node)
83+
key_argument = node.argument_list.first
84+
lhs, _method_name, rhs = *node.body
85+
86+
[lhs, rhs].find { |operand| operand.source != key_argument.source }
87+
end
88+
89+
def offense_range(node)
90+
range_between(node.loc.selector.begin_pos, node.parent.loc.end.end_pos)
91+
end
92+
end
93+
end
94+
end
95+
end
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe RuboCop::Cop::Style::HashExcept, :config do
4+
context 'Ruby 3.0 or higher', :ruby30 do
5+
it 'registers and corrects an offense when using `reject` and comparing with `lvar == :sym`' do
6+
expect_offense(<<~RUBY)
7+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| k == :bar }
8+
^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(:bar)` instead.
9+
RUBY
10+
11+
expect_correction(<<~RUBY)
12+
{foo: 1, bar: 2, baz: 3}.except(:bar)
13+
RUBY
14+
end
15+
16+
it 'registers and corrects an offense when using `reject` and comparing with `:sym == lvar`' do
17+
expect_offense(<<~RUBY)
18+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| :bar == k }
19+
^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(:bar)` instead.
20+
RUBY
21+
22+
expect_correction(<<~RUBY)
23+
{foo: 1, bar: 2, baz: 3}.except(:bar)
24+
RUBY
25+
end
26+
27+
it 'registers and corrects an offense when using `select` and comparing with `lvar != :sym`' do
28+
expect_offense(<<~RUBY)
29+
{foo: 1, bar: 2, baz: 3}.select { |k, v| k != :bar }
30+
^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(:bar)` instead.
31+
RUBY
32+
33+
expect_correction(<<~RUBY)
34+
{foo: 1, bar: 2, baz: 3}.except(:bar)
35+
RUBY
36+
end
37+
38+
it 'registers and corrects an offense when using `select` and comparing with `:sym != lvar`' do
39+
expect_offense(<<~RUBY)
40+
{foo: 1, bar: 2, baz: 3}.select { |k, v| :bar != k }
41+
^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(:bar)` instead.
42+
RUBY
43+
44+
expect_correction(<<~RUBY)
45+
{foo: 1, bar: 2, baz: 3}.except(:bar)
46+
RUBY
47+
end
48+
49+
it "registers and corrects an offense when using `reject` and comparing with `lvar == 'str'`" do
50+
expect_offense(<<~RUBY)
51+
hash.reject { |k, v| k == 'str' }
52+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except('str')` instead.
53+
RUBY
54+
55+
expect_correction(<<~RUBY)
56+
hash.except('str')
57+
RUBY
58+
end
59+
60+
it 'registers and corrects an offense when using `reject` and other than comparison by string and symbol using `eql?`' do
61+
expect_offense(<<~RUBY)
62+
hash.reject { |k, v| k.eql?(0.0) }
63+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(0.0)` instead.
64+
RUBY
65+
66+
expect_correction(<<~RUBY)
67+
hash.except(0.0)
68+
RUBY
69+
end
70+
71+
it 'registers and corrects an offense when using `filter` and comparing with `lvar != :sym`' do
72+
expect_offense(<<~RUBY)
73+
{foo: 1, bar: 2, baz: 3}.filter { |k, v| k != :bar }
74+
^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `except(:bar)` instead.
75+
RUBY
76+
77+
expect_correction(<<~RUBY)
78+
{foo: 1, bar: 2, baz: 3}.except(:bar)
79+
RUBY
80+
end
81+
82+
it 'does not register an offense when using `reject` and other than comparison by string and symbol using `==`' do
83+
expect_no_offenses(<<~RUBY)
84+
hash.reject { |k, v| k == 0.0 }
85+
RUBY
86+
end
87+
88+
it 'does not register an offense when using `delete_if` and comparing with `lvar == :sym`' do
89+
expect_no_offenses(<<~RUBY)
90+
{foo: 1, bar: 2, baz: 3}.delete_if { |k, v| k == :bar }
91+
RUBY
92+
end
93+
94+
it 'does not register an offense when using `keep_if` and comparing with `lvar != :sym`' do
95+
expect_no_offenses(<<~RUBY)
96+
{foo: 1, bar: 2, baz: 3}.keep_if { |k, v| k != :bar }
97+
RUBY
98+
end
99+
end
100+
101+
context 'Ruby 2.7 or lower', :ruby27 do
102+
it 'does not register an offense when using `reject` and comparing with `lvar == :key`' do
103+
expect_no_offenses(<<~RUBY)
104+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| k == :bar }
105+
RUBY
106+
end
107+
108+
it 'does not register an offense when using `reject` and comparing with `:key == lvar`' do
109+
expect_no_offenses(<<~RUBY)
110+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| :bar == k }
111+
RUBY
112+
end
113+
114+
it 'does not register an offense when using `select` and comparing with `lvar != :key`' do
115+
expect_no_offenses(<<~RUBY)
116+
{foo: 1, bar: 2, baz: 3}.select { |k, v| k != :bar }
117+
RUBY
118+
end
119+
120+
it 'does not register an offense when using `select` and comparing with `:key != lvar`' do
121+
expect_no_offenses(<<~RUBY)
122+
{foo: 1, bar: 2, baz: 3}.select { |k, v| :bar != k }
123+
RUBY
124+
end
125+
end
126+
127+
it 'does not register an offense when using `reject` and comparing with `lvar != :key`' do
128+
expect_no_offenses(<<~RUBY)
129+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| k != :bar }
130+
RUBY
131+
end
132+
133+
it 'does not register an offense when using `reject` and comparing with `:key != lvar`' do
134+
expect_no_offenses(<<~RUBY)
135+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| :bar != key }
136+
RUBY
137+
end
138+
139+
it 'does not register an offense when using `select` and comparing with `lvar == :key`' do
140+
expect_no_offenses(<<~RUBY)
141+
{foo: 1, bar: 2, baz: 3}.select { |k, v| k == :bar }
142+
RUBY
143+
end
144+
145+
it 'does not register an offense when using `select` and comparing with `:key == lvar`' do
146+
expect_no_offenses(<<~RUBY)
147+
{foo: 1, bar: 2, baz: 3}.select { |k, v| :bar == key }
148+
RUBY
149+
end
150+
151+
it 'does not register an offense when not using key block argument`' do
152+
expect_no_offenses(<<~RUBY)
153+
{foo: 1, bar: 2, baz: 3}.reject { |k, v| do_something != :bar }
154+
RUBY
155+
end
156+
157+
it 'does not register an offense when not using block`' do
158+
expect_no_offenses(<<~RUBY)
159+
{foo: 1, bar: 2, baz: 3}.reject
160+
RUBY
161+
end
162+
163+
it 'does not register an offense when using `Hash#except`' do
164+
expect_no_offenses(<<~RUBY)
165+
{foo: 1, bar: 2, baz: 3}.except(:bar)
166+
RUBY
167+
end
168+
end

0 commit comments

Comments
 (0)