Skip to content

Commit 9db9f6d

Browse files
committed
Add new RSpec/HaveAttributes cop
1 parent fd1bef6 commit 9db9f6d

File tree

8 files changed

+479
-0
lines changed

8 files changed

+479
-0
lines changed

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,5 @@ Performance/ZipWithoutBlock: {Enabled: true}
294294

295295
RSpec/IncludeExamples: {Enabled: true}
296296
RSpec/LeakyLocalVariable: {Enabled: true}
297+
RSpec/HaveAttributes: {Enabled: true}
297298
RSpec/Output: {Enabled: true}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Master (Unreleased)
44

5+
- Add new cop `RSpec/HaveAttributes`. ([@Darhazer])
56
- `RSpec/ScatteredLet` now preserves the order of `let`s during auto-correction. ([@Darhazer])
67
- Fix a false negative for `RSpec/EmptyLineAfterFinalLet` inside `shared_examples` / `include_examples` / `it_behaves_like` blocks. ([@Darhazer])
78

config/default.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,12 @@ RSpec/Focus:
482482
VersionChanged: '2.31'
483483
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Focus
484484

485+
RSpec/HaveAttributes:
486+
Description: Checks for expectations on the same object that can be combined.
487+
Enabled: pending
488+
VersionAdded: "<<next>>"
489+
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/HaveAttributes
490+
485491
RSpec/HookArgument:
486492
Description: Checks the arguments passed to `before`, `around`, and `after`.
487493
Enabled: true

docs/modules/ROOT/pages/cops.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
* xref:cops_rspec.adoc#rspecexpectinlet[RSpec/ExpectInLet]
4545
* xref:cops_rspec.adoc#rspecexpectoutput[RSpec/ExpectOutput]
4646
* xref:cops_rspec.adoc#rspecfocus[RSpec/Focus]
47+
* xref:cops_rspec.adoc#rspechaveattributes[RSpec/HaveAttributes]
4748
* xref:cops_rspec.adoc#rspechookargument[RSpec/HookArgument]
4849
* xref:cops_rspec.adoc#rspechooksbeforeexamples[RSpec/HooksBeforeExamples]
4950
* xref:cops_rspec.adoc#rspecidenticalequalityassertion[RSpec/IdenticalEqualityAssertion]

docs/modules/ROOT/pages/cops_rspec.adoc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2395,6 +2395,44 @@ focus 'test' do; end
23952395
23962396
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Focus
23972397
2398+
[#rspechaveattributes]
2399+
== RSpec/HaveAttributes
2400+
2401+
|===
2402+
| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed
2403+
2404+
| Pending
2405+
| Yes
2406+
| Always
2407+
| <<next>>
2408+
| -
2409+
|===
2410+
2411+
Checks for expectations on the same object that can be combined.
2412+
2413+
[#examples-rspechaveattributes]
2414+
=== Examples
2415+
2416+
[source,ruby]
2417+
----
2418+
# bad
2419+
expect(obj.foo).to eq(bar)
2420+
expect(obj.fu).to eq(bax)
2421+
expect(obj.name).to eq(baz)
2422+
2423+
# good
2424+
expect(obj).to have_attributes(
2425+
foo: bar,
2426+
fu: bax,
2427+
name: baz
2428+
)
2429+
----
2430+
2431+
[#references-rspechaveattributes]
2432+
=== References
2433+
2434+
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/HaveAttributes
2435+
23982436
[#rspechookargument]
23992437
== RSpec/HookArgument
24002438
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module RSpec
6+
# Checks for expectations on the same object that can be combined.
7+
#
8+
# @example
9+
# # bad
10+
# expect(obj.foo).to eq(bar)
11+
# expect(obj.fu).to eq(bax)
12+
# expect(obj.name).to eq(baz)
13+
#
14+
# # good
15+
# expect(obj).to have_attributes(
16+
# foo: bar,
17+
# fu: bax,
18+
# name: baz
19+
# )
20+
#
21+
class HaveAttributes < Base
22+
extend AutoCorrector
23+
include RangeHelp
24+
25+
MSG = 'Combine multiple expectations on the same object ' \
26+
'using `have_attributes`.'
27+
28+
# Mapping of RSpec matchers to their have_attributes equivalents
29+
# nil means use the value directly (for eq)
30+
MATCHER_MAPPING = {
31+
eq: nil,
32+
be_an_instance_of: :an_instance_of,
33+
be_within: :a_value_within,
34+
contain_exactly: :a_collection_containing_exactly,
35+
end_with: :a_string_ending_with,
36+
start_with: :a_string_starting_with
37+
}.freeze
38+
39+
# @!method expect_method_matcher?(node)
40+
def_node_matcher :expect_method_matcher?, <<~PATTERN
41+
(send
42+
(send nil? :expect
43+
(send $_ $_)
44+
)
45+
:to
46+
(send nil? $_ $_)
47+
)
48+
PATTERN
49+
50+
def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
51+
return unless example?(node)
52+
53+
expectations = find_expectations(node)
54+
grouped = group_by_object(expectations)
55+
grouped.each_value do |group|
56+
next if group.size < 2
57+
58+
flag_group(group)
59+
end
60+
end
61+
62+
private
63+
64+
def find_expectations(node)
65+
node.each_descendant(:send).filter_map do |send_node|
66+
expect_method_matcher?(send_node) do |obj, method, matcher, value|
67+
next if obj.nil? || !MATCHER_MAPPING.key?(matcher)
68+
69+
{
70+
node: send_node,
71+
object: obj,
72+
method: method,
73+
matcher: matcher,
74+
value: value
75+
}
76+
end
77+
end
78+
end
79+
80+
def group_by_object(expectations)
81+
expectations.each_with_object({}) do |exp, grouped|
82+
obj_key = exp[:object].source
83+
grouped[obj_key] ||= []
84+
grouped[obj_key] << exp
85+
end
86+
end
87+
88+
def flag_group(group)
89+
# Sort by line number to maintain order
90+
sorted_group = group.sort_by { |exp| exp[:node].loc.line }
91+
92+
# Flag all nodes in the group, but only correct once
93+
sorted_group.each_with_index do |exp, index|
94+
add_offense(exp[:node]) do |corrector|
95+
# Only correct on the first offense to avoid multiple corrections
96+
if index.zero?
97+
AttributesCorrector.new(sorted_group).call(corrector)
98+
end
99+
end
100+
end
101+
end
102+
103+
# :nodoc:
104+
class AttributesCorrector
105+
include RangeHelp
106+
107+
def initialize(group)
108+
# Sort nodes by position
109+
@sorted_nodes = group.sort_by do |exp|
110+
exp[:node].source_range.begin_pos
111+
end
112+
end
113+
114+
def call(corrector)
115+
first_node = sorted_nodes.first[:node]
116+
117+
# Replace the first node with the combined expectation
118+
replacement = build_replacement
119+
corrector.replace(first_node, replacement)
120+
121+
# Remove the remaining nodes individually
122+
sorted_nodes[1..].each do |exp|
123+
node_range = range_by_whole_lines(
124+
exp[:node].source_range,
125+
include_final_newline: true,
126+
buffer: exp[:node].source_range.source_buffer
127+
)
128+
corrector.remove(node_range)
129+
end
130+
end
131+
132+
private
133+
134+
attr_reader :sorted_nodes
135+
136+
def build_attributes
137+
sorted_nodes.map do |exp|
138+
method_name = exp[:method]
139+
matcher = exp[:matcher]
140+
value = exp[:value]
141+
142+
transformed_value = transform_value(matcher, value)
143+
"#{method_name}: #{transformed_value}"
144+
end.join(",\n ")
145+
end
146+
147+
def transform_value(matcher, value)
148+
have_attributes_matcher = HaveAttributes::MATCHER_MAPPING[matcher]
149+
150+
if have_attributes_matcher.nil?
151+
# For eq, use value directly
152+
# If value is keyword arguments (hash without braces), wrap in {}
153+
wrap_keyword_arguments(value)
154+
else
155+
# For other matchers, wrap value in the have_attributes matcher
156+
"#{have_attributes_matcher}(#{value.source})"
157+
end
158+
end
159+
160+
def wrap_keyword_arguments(value)
161+
source = value.source
162+
if value.hash_type? && !source.strip.start_with?('{')
163+
"{ #{source} }"
164+
else
165+
source
166+
end
167+
end
168+
169+
def build_replacement
170+
obj = sorted_nodes.first[:object]
171+
attributes = build_attributes
172+
<<~RUBY.chomp
173+
expect(#{obj.source}).to have_attributes(
174+
#{attributes}
175+
)
176+
RUBY
177+
end
178+
end
179+
end
180+
end
181+
end
182+
end

lib/rubocop/cop/rspec_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
require_relative 'rspec/expect_in_let'
4343
require_relative 'rspec/expect_output'
4444
require_relative 'rspec/focus'
45+
require_relative 'rspec/have_attributes'
4546
require_relative 'rspec/hook_argument'
4647
require_relative 'rspec/hooks_before_examples'
4748
require_relative 'rspec/identical_equality_assertion'

0 commit comments

Comments
 (0)