Skip to content

Commit 680ff12

Browse files
committed
Add new RSpec/DiscardedMatcher cop
1 parent 33f7949 commit 680ff12

File tree

8 files changed

+503
-0
lines changed

8 files changed

+503
-0
lines changed

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ RSpec/DescribeClass:
111111
Exclude:
112112
- spec/project/**/*.rb
113113

114+
RSpec/DiscardedMatcher: {Enabled: true}
115+
114116
RSpec/ExampleLength:
115117
CountAsOne:
116118
- heredoc

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Fix a false positive for `RSpec/ScatteredSetup` when the hook is defined inside a class method. ([@d4rky-pl])
1414
- Fix a false positive for `RSpec/DescribedClass` inside dynamically evaluated blocks (`class_eval`, `module_eval`, `instance_eval`, `class_exec`, `module_exec`, `instance_exec`). ([@sucicfilip])
1515
- Add new cop `RSpec/Output`. ([@kevinrobell-st])
16+
- Add new cop `RSpec/DiscardedMatcher` to detect matchers in void context (e.g. missing `.and` between compound matchers). ([@ydakuka])
1617

1718
## 3.8.0 (2025-11-12)
1819

@@ -1109,6 +1110,7 @@ Compatibility release so users can upgrade RuboCop to 0.51.0. No new features.
11091110
[@yasu551]: https://github.com/yasu551
11101111
[@ybiquitous]: https://github.com/ybiquitous
11111112
[@ydah]: https://github.com/ydah
1113+
[@ydakuka]: https://github.com/ydakuka
11121114
[@yevhene]: https://github.com/yevhene
11131115
[@ypresto]: https://github.com/ypresto
11141116
[@yujideveloper]: https://github.com/yujideveloper

config/default.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ RSpec/Dialect:
311311
VersionAdded: '1.33'
312312
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Dialect
313313

314+
RSpec/DiscardedMatcher:
315+
Description: Checks for matchers that are used in void context.
316+
Enabled: pending
317+
VersionAdded: "<<next>>"
318+
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DiscardedMatcher
319+
314320
RSpec/DuplicatedMetadata:
315321
Description: Avoid duplicated metadata.
316322
Enabled: true

docs/modules/ROOT/pages/cops.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* xref:cops_rspec.adoc#rspecdescribesymbol[RSpec/DescribeSymbol]
2323
* xref:cops_rspec.adoc#rspecdescribedclass[RSpec/DescribedClass]
2424
* xref:cops_rspec.adoc#rspecdescribedclassmodulewrapping[RSpec/DescribedClassModuleWrapping]
25+
* xref:cops_rspec.adoc#rspecdiscardedmatcher[RSpec/DiscardedMatcher]
2526
* xref:cops_rspec.adoc#rspecdialect[RSpec/Dialect]
2627
* xref:cops_rspec.adoc#rspecduplicatedmetadata[RSpec/DuplicatedMetadata]
2728
* xref:cops_rspec.adoc#rspecemptyexamplegroup[RSpec/EmptyExampleGroup]

docs/modules/ROOT/pages/cops_rspec.adoc

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,55 @@ end
11291129
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DescribedClassModuleWrapping
11301130
* https://github.com/rubocop/rubocop-rspec/issues/735
11311131

1132+
[#rspecdiscardedmatcher]
1133+
== RSpec/DiscardedMatcher
1134+
1135+
|===
1136+
| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed
1137+
1138+
| Enabled
1139+
| Yes
1140+
| No
1141+
| <<next_version>>
1142+
| -
1143+
|===
1144+
1145+
Checks for matchers that are used in void context.
1146+
1147+
Matcher calls like `change`, `receive`, etc. that appear as standalone
1148+
expressions have their result silently discarded. This usually means a
1149+
missing `.and` to chain compound matchers.
1150+
1151+
[#examples-rspecdiscardedmatcher]
1152+
=== Examples
1153+
1154+
[source,ruby]
1155+
----
1156+
# bad
1157+
specify do
1158+
expect { result }.to \
1159+
change { obj.foo }.from(1).to(2)
1160+
change { obj.bar }.from(3).to(4)
1161+
end
1162+
1163+
# good
1164+
specify do
1165+
expect { result }.to \
1166+
change { obj.foo }.from(1).to(2)
1167+
.and change { obj.bar }.from(3).to(4)
1168+
end
1169+
1170+
# good
1171+
specify do
1172+
expect { result }.to change { obj.foo }.from(1).to(2)
1173+
end
1174+
----
1175+
1176+
[#references-rspecdiscardedmatcher]
1177+
=== References
1178+
1179+
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DiscardedMatcher
1180+
11321181
[#rspecdialect]
11331182
== RSpec/Dialect
11341183

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module RSpec
6+
# Checks for matchers that are used in void context.
7+
#
8+
# Matcher calls like `change`, `receive`, etc. that appear as
9+
# standalone expressions have their result silently discarded.
10+
# This usually means a missing `.and` to chain compound matchers.
11+
#
12+
# @example
13+
# # bad
14+
# specify do
15+
# expect { result }.to \
16+
# change { obj.foo }.from(1).to(2)
17+
# change { obj.bar }.from(3).to(4)
18+
# end
19+
#
20+
# # good
21+
# specify do
22+
# expect { result }.to \
23+
# change { obj.foo }.from(1).to(2)
24+
# .and change { obj.bar }.from(3).to(4)
25+
# end
26+
#
27+
# # good
28+
# specify do
29+
# expect { result }.to change { obj.foo }.from(1).to(2)
30+
# end
31+
#
32+
class DiscardedMatcher < Base
33+
MSG = 'The result of `%<method>s` is not used. ' \
34+
'Did you mean to chain it with `.and`?'
35+
36+
MATCHER_METHODS = %i[
37+
change
38+
receive
39+
receive_messages
40+
receive_message_chain
41+
].to_set.freeze
42+
43+
RESTRICT_ON_SEND = MATCHER_METHODS.freeze
44+
45+
# @!method matcher_call?(node)
46+
def_node_matcher :matcher_call?, <<~PATTERN
47+
(send nil? MATCHER_METHODS ...)
48+
PATTERN
49+
50+
def on_send(node)
51+
return unless matcher_call?(node)
52+
return unless inside_example?(node)
53+
54+
target = find_outermost_chain(node)
55+
return unless void_value?(target)
56+
57+
add_offense(target, message: format(MSG, method: node.method_name))
58+
end
59+
60+
def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
61+
return unless matcher_call?(node.send_node)
62+
return unless inside_example?(node)
63+
64+
target = find_outermost_chain(node)
65+
return unless void_value?(target)
66+
67+
add_offense(target, message: format(MSG, method: node.method_name))
68+
end
69+
70+
private
71+
72+
def void_value?(node)
73+
case node.parent.type
74+
when :begin
75+
true
76+
when :block
77+
example?(node.parent)
78+
when :if
79+
void_value_in_branch?(node, node.parent)
80+
when :and, :or
81+
void_in_logical_operator?(node, node.parent)
82+
when :when, :case
83+
void_in_case_branch?(node, node.parent)
84+
end
85+
end
86+
87+
def void_value_in_branch?(node, parent)
88+
(parent.if_branch == node || parent.else_branch == node) &&
89+
void_value?(parent)
90+
end
91+
92+
def void_in_logical_operator?(node, parent)
93+
parent.rhs == node && void_value?(parent)
94+
end
95+
96+
def void_in_case_branch?(node, parent)
97+
if parent.when_type?
98+
parent.body == node && void_value?(parent.parent)
99+
else
100+
parent.else_branch == node && void_value?(parent)
101+
end
102+
end
103+
104+
def find_outermost_chain(node)
105+
current = node
106+
current = current.parent while current.parent.receiver == current
107+
current
108+
end
109+
110+
def inside_example?(node)
111+
node.each_ancestor(:block).any? { |ancestor| example?(ancestor) }
112+
end
113+
end
114+
end
115+
end
116+
end

lib/rubocop/cop/rspec_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_relative 'rspec/described_class'
2222
require_relative 'rspec/described_class_module_wrapping'
2323
require_relative 'rspec/dialect'
24+
require_relative 'rspec/discarded_matcher'
2425
require_relative 'rspec/duplicated_metadata'
2526
require_relative 'rspec/empty_example_group'
2627
require_relative 'rspec/empty_hook'

0 commit comments

Comments
 (0)