Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ RSpec/DescribeClass:
Exclude:
- spec/project/**/*.rb

RSpec/DiscardedMatcher: {Enabled: true}

RSpec/ExampleLength:
CountAsOne:
- heredoc
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Fix a false positive for `RSpec/ScatteredSetup` when the hook is defined inside a class method. ([@d4rky-pl])
- Fix a false positive for `RSpec/DescribedClass` inside dynamically evaluated blocks (`class_eval`, `module_eval`, `instance_eval`, `class_exec`, `module_exec`, `instance_exec`). ([@sucicfilip])
- Add new cop `RSpec/Output`. ([@kevinrobell-st])
- Add new cop `RSpec/DiscardedMatcher` to detect matchers in void context (e.g. missing `.and` between compound matchers). ([@ydakuka])

## 3.8.0 (2025-11-12)

Expand Down Expand Up @@ -1109,6 +1110,7 @@ Compatibility release so users can upgrade RuboCop to 0.51.0. No new features.
[@yasu551]: https://github.com/yasu551
[@ybiquitous]: https://github.com/ybiquitous
[@ydah]: https://github.com/ydah
[@ydakuka]: https://github.com/ydakuka
[@yevhene]: https://github.com/yevhene
[@ypresto]: https://github.com/ypresto
[@yujideveloper]: https://github.com/yujideveloper
Expand Down
7 changes: 7 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,13 @@ RSpec/Dialect:
VersionAdded: '1.33'
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Dialect

RSpec/DiscardedMatcher:
Description: Checks for matchers that are used in void context.
Enabled: pending
VersionAdded: "<<next>>"
CustomMatcherMethods: []
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DiscardedMatcher

RSpec/DuplicatedMetadata:
Description: Avoid duplicated metadata.
Enabled: true
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/cops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* xref:cops_rspec.adoc#rspecdescribedclass[RSpec/DescribedClass]
* xref:cops_rspec.adoc#rspecdescribedclassmodulewrapping[RSpec/DescribedClassModuleWrapping]
* xref:cops_rspec.adoc#rspecdialect[RSpec/Dialect]
* xref:cops_rspec.adoc#rspecdiscardedmatcher[RSpec/DiscardedMatcher]
* xref:cops_rspec.adoc#rspecduplicatedmetadata[RSpec/DuplicatedMetadata]
* xref:cops_rspec.adoc#rspecemptyexamplegroup[RSpec/EmptyExampleGroup]
* xref:cops_rspec.adoc#rspecemptyhook[RSpec/EmptyHook]
Expand Down
63 changes: 63 additions & 0 deletions docs/modules/ROOT/pages/cops_rspec.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,69 @@ end

* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Dialect

[#rspecdiscardedmatcher]
== RSpec/DiscardedMatcher

|===
| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed

| Pending
| Yes
| No
| <<next>>
| -
|===

Checks for matchers that are used in void context.

Matcher calls like `change`, `receive`, etc. that appear as
standalone expressions have their result silently discarded.
This usually means a missing `.and` to chain compound matchers.

The list of matcher methods can be configured
with `CustomMatcherMethods`.

[#examples-rspecdiscardedmatcher]
=== Examples

[source,ruby]
----
# bad
specify do
expect { result }
.to change { obj.foo }.from(1).to(2)
change { obj.bar }.from(3).to(4)
end

# good
specify do
expect { result }
.to change { obj.foo }.from(1).to(2)
.and change { obj.bar }.from(3).to(4)
end

# good
specify do
expect { result }.to change { obj.foo }.from(1).to(2)
end
----

[#configurable-attributes-rspecdiscardedmatcher]
=== Configurable attributes

|===
| Name | Default value | Configurable values

| CustomMatcherMethods
| `[]`
| Array
|===

[#references-rspecdiscardedmatcher]
=== References

* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DiscardedMatcher

[#rspecduplicatedmetadata]
== RSpec/DuplicatedMetadata

Expand Down
1 change: 1 addition & 0 deletions lib/rubocop-rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

require_relative 'rubocop/cop/rspec/mixin/file_help'
require_relative 'rubocop/cop/rspec/mixin/final_end_location'
require_relative 'rubocop/cop/rspec/mixin/inside_example'
require_relative 'rubocop/cop/rspec/mixin/inside_example_group'
require_relative 'rubocop/cop/rspec/mixin/location_help'
require_relative 'rubocop/cop/rspec/mixin/metadata'
Expand Down
113 changes: 113 additions & 0 deletions lib/rubocop/cop/rspec/discarded_matcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
# Checks for matchers that are used in void context.
#
# Matcher calls like `change`, `receive`, etc. that appear as
# standalone expressions have their result silently discarded.
# This usually means a missing `.and` to chain compound matchers.
#
# The list of matcher methods can be configured
# with `CustomMatcherMethods`.
#
# @example
# # bad
# specify do
# expect { result }
# .to change { obj.foo }.from(1).to(2)
# change { obj.bar }.from(3).to(4)
# end
#
# # good
# specify do
# expect { result }
# .to change { obj.foo }.from(1).to(2)
# .and change { obj.bar }.from(3).to(4)
# end
#
# # good
# specify do
# expect { result }.to change { obj.foo }.from(1).to(2)
# end
#
class DiscardedMatcher < Base
include InsideExample

MSG = 'The result of `%<method>s` is not used. ' \
'Did you mean to chain it with `.and`?'

MATCHER_METHODS = %i[
change have_received output
receive receive_messages receive_message_chain
].to_set.freeze

def on_send(node)
check_discarded_matcher(node, node)
end

def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler, InternalAffairs/ItblockHandler
check_discarded_matcher(node.send_node, node)
end

private

def check_discarded_matcher(send_node, node)
return unless matcher_call?(send_node)
return unless inside_example?(node)
return unless example_with_matcher_expectation?(node)

target = find_outermost_chain(node)
return unless void_value?(target)

add_offense(target, message: format(MSG, method: node.method_name))
end

def example_with_matcher_expectation?(node)
example_node =
node.each_ancestor(:block).find { |ancestor| example?(ancestor) }

example_node.each_descendant(:send).any? do |send_node|
expectation_with_matcher?(send_node)
end
end

def expectation_with_matcher?(node)
%i[to to_not not_to].include?(node.method_name) &&
node.arguments.any? do |arg|
arg.each_node(:send).any? { |s| matcher_call?(s) }
end
end

def void_value?(node)
case node.parent.type
when :block
example?(node.parent)
when :begin, :case, :when
void_value?(node.parent)
end
end

def matcher_call?(node)
node.receiver.nil? && all_matcher_methods.include?(node.method_name)
end

def all_matcher_methods
@all_matcher_methods ||=
(MATCHER_METHODS + custom_matcher_methods).freeze
end

def custom_matcher_methods
cop_config.fetch('CustomMatcherMethods', []).map(&:to_sym)
end

def find_outermost_chain(node)
current = node
current = current.parent while current.parent.receiver == current
current
end
end
end
end
end
3 changes: 2 additions & 1 deletion lib/rubocop/cop/rspec/empty_example_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ module RSpec
class EmptyExampleGroup < Base
extend AutoCorrector

include InsideExample
include RangeHelp

MSG = 'Empty example group detected.'
Expand Down Expand Up @@ -138,7 +139,7 @@ class EmptyExampleGroup < Base

def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler, InternalAffairs/ItblockHandler
return if node.each_ancestor(:any_def).any?
return if node.each_ancestor(:block).any? { |block| example?(block) }
return if inside_example?(node)

example_group_body(node) do |body|
next unless offensive?(body)
Expand Down
16 changes: 16 additions & 0 deletions lib/rubocop/cop/rspec/mixin/inside_example.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
# Helps check if a given node is within an example block.
module InsideExample
private

def inside_example?(node)
node.each_ancestor(:block).any? { |ancestor| example?(ancestor) }
end
end
end
end
end
8 changes: 2 additions & 6 deletions lib/rubocop/cop/rspec/skip_block_inside_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ module RSpec
# end
#
class SkipBlockInsideExample < Base
include InsideExample

MSG = "Don't pass a block to `skip` inside examples."

def on_block(node)
Expand All @@ -35,12 +37,6 @@ def on_block(node)

alias on_numblock on_block
alias on_itblock on_block

private

def inside_example?(node)
node.each_ancestor(:block).any? { |ancestor| example?(ancestor) }
end
end
end
end
Expand Down
6 changes: 2 additions & 4 deletions lib/rubocop/cop/rspec/void_expect.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ module RSpec
# expect(something).to be(1)
#
class VoidExpect < Base
include InsideExample

MSG = 'Do not use `expect()` without `.to` or `.not_to`. ' \
'Chain the methods or remove it.'
RESTRICT_ON_SEND = %i[expect].freeze
Expand Down Expand Up @@ -55,10 +57,6 @@ def void?(expect)

parent.block_type? && parent.body == expect
end

def inside_example?(node)
node.each_ancestor(:block).any? { |ancestor| example?(ancestor) }
end
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/rubocop/cop/rspec_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require_relative 'rspec/described_class'
require_relative 'rspec/described_class_module_wrapping'
require_relative 'rspec/dialect'
require_relative 'rspec/discarded_matcher'
require_relative 'rspec/duplicated_metadata'
require_relative 'rspec/empty_example_group'
require_relative 'rspec/empty_hook'
Expand Down
Loading