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
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,5 @@ Performance/ZipWithoutBlock: {Enabled: true}

RSpec/IncludeExamples: {Enabled: true}
RSpec/LeakyLocalVariable: {Enabled: true}
RSpec/HaveAttributes: {Enabled: true}
RSpec/Output: {Enabled: true}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Master (Unreleased)

- Add support for `itblock` nodes. ([@Darhazer])
- Add new cop `RSpec/HaveAttributes`. ([@Darhazer])
- `RSpec/ScatteredLet` now preserves the order of `let`s during auto-correction. ([@Darhazer])
- Fix a false negative for `RSpec/EmptyLineAfterFinalLet` inside `shared_examples` / `include_examples` / `it_behaves_like` blocks. ([@Darhazer])
- Add autocorrect support for `RSpec/SubjectDeclaration`. ([@eugeneius])
Expand Down
6 changes: 6 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ RSpec/Focus:
VersionChanged: '2.31'
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/Focus

RSpec/HaveAttributes:
Description: Checks for expectations on the same object that can be combined.
Enabled: pending
VersionAdded: "<<next>>"
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/HaveAttributes

RSpec/HookArgument:
Description: Checks the arguments passed to `before`, `around`, and `after`.
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 @@ -44,6 +44,7 @@
* xref:cops_rspec.adoc#rspecexpectinlet[RSpec/ExpectInLet]
* xref:cops_rspec.adoc#rspecexpectoutput[RSpec/ExpectOutput]
* xref:cops_rspec.adoc#rspecfocus[RSpec/Focus]
* xref:cops_rspec.adoc#rspechaveattributes[RSpec/HaveAttributes]
* xref:cops_rspec.adoc#rspechookargument[RSpec/HookArgument]
* xref:cops_rspec.adoc#rspechooksbeforeexamples[RSpec/HooksBeforeExamples]
* xref:cops_rspec.adoc#rspecidenticalequalityassertion[RSpec/IdenticalEqualityAssertion]
Expand Down
38 changes: 38 additions & 0 deletions docs/modules/ROOT/pages/cops_rspec.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2395,6 +2395,44 @@ focus 'test' do; end

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

[#rspechaveattributes]
== RSpec/HaveAttributes

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

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

Checks for expectations on the same object that can be combined.

[#examples-rspechaveattributes]
=== Examples

[source,ruby]
----
# bad
expect(obj.foo).to eq(bar)
expect(obj.fu).to eq(bax)
expect(obj.name).to eq(baz)

# good
expect(obj).to have_attributes(
foo: bar,
fu: bax,
name: baz
)
----

[#references-rspechaveattributes]
=== References

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

[#rspechookargument]
== RSpec/HookArgument

Expand Down
228 changes: 228 additions & 0 deletions lib/rubocop/cop/rspec/have_attributes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
# Checks for expectations on the same object that can be combined.
#
# @example
# # bad
# expect(obj.foo).to eq(bar)
# expect(obj.fu).to eq(bax)
# expect(obj.name).to eq(baz)
#
# # good
# expect(obj).to have_attributes(
# foo: bar,
# fu: bax,
# name: baz
# )
#
class HaveAttributes < Base
extend AutoCorrector
include RangeHelp

MSG = 'Combine multiple expectations on the same object ' \
'using `have_attributes`.'

# Mapping of RSpec matchers to their have_attributes equivalents
# nil means use the value directly (for eq)
MATCHER_MAPPING = {
eq: nil,
be_an_instance_of: :an_instance_of,
be_within: :a_value_within,
contain_exactly: :a_collection_containing_exactly,
end_with: :a_string_ending_with,
start_with: :a_string_starting_with
}.freeze

# @!method expect_method_matcher?(node)
def_node_matcher :expect_method_matcher?, <<~PATTERN
(send
(send nil? :expect
(send $_ $_)
)
:to
(send nil? $_ $_)
)
PATTERN

# @!method expectation_statement?(node)
def_node_matcher :expectation_statement?, <<~PATTERN
(send (send nil? :expect ...) {:to :not_to :to_not} ...)
PATTERN

def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler, InternalAffairs/ItblockHandler
return unless example?(node)

body = node.body
return unless body

statements = body.begin_type? ? body.children : [body]
find_consecutive_groups(statements).each do |group|
flag_group(group)
end
end

private

def find_consecutive_groups(statements)
groups = []
current_groups = {}

statements.each do |statement|
exp = extract_expectation(statement)
if exp
obj_key = exp[:object].source
(current_groups[obj_key] ||= []) << exp
elsif expectation_statement?(statement)
# An expect call we can't combine (unsupported matcher,
# not_to, etc.) — doesn't break consecutive chains
else
# Non-expectation statement breaks all consecutive chains
flush_groups(current_groups, groups)
current_groups = {}
end
end

flush_groups(current_groups, groups)
groups
end

def extract_expectation(statement)
expect_method_matcher?(statement) do |obj, method, matcher, value|
next if obj.nil? || !MATCHER_MAPPING.key?(matcher)

return {
node: statement,
object: obj,
method: method,
matcher: matcher,
value: value
}
end
end

def flush_groups(current_groups, groups)
current_groups.each_value do |group|
groups << group if group.size >= 2
end
end

def flag_group(group)
# Sort by line number to maintain order
sorted_group = group.sort_by { |exp| exp[:node].loc.line }

# Flag all nodes in the group, but only correct once
sorted_group.each_with_index do |exp, index|
add_offense(exp[:node]) do |corrector|
# Only correct on the first offense to avoid multiple corrections
if index.zero?
AttributesCorrector.new(sorted_group).call(corrector)
end
end
end
end

# :nodoc:
class AttributesCorrector
include RangeHelp

def initialize(group)
# Sort nodes by position
@sorted_nodes = group.sort_by do |exp|
exp[:node].source_range.begin_pos
end
end

def call(corrector)
first_node = sorted_nodes.first[:node]

# Replace the first node with the combined expectation
replacement = build_replacement
corrector.replace(first_node, replacement)

# Remove the remaining nodes individually
sorted_nodes[1..].each do |exp|
node_range = range_by_whole_lines(
exp[:node].source_range,
include_final_newline: true,
buffer: exp[:node].source_range.source_buffer
)
corrector.remove(node_range)
end
end

private

attr_reader :sorted_nodes

def build_attributes
method_groups = {}
sorted_nodes.each do |exp|
(method_groups[exp[:method]] ||= []) << exp
end

method_groups.map do |method_name, exps|
value_str = if exps.size == 1
exp = exps.first
transform_value(exp[:matcher], exp[:value])
else
combine_matchers(exps)
end
"#{method_name}: #{value_str}"
end.join(",\n ")
end

def combine_matchers(exps)
parts = exps.map do |exp|
transform_value_for_and(exp[:matcher], exp[:value])
end
parts[1..].inject(parts[0]) { |acc, part| "#{acc}.and(#{part})" }
end

def transform_value_for_and(matcher, value)
have_attributes_matcher = HaveAttributes::MATCHER_MAPPING[matcher]
if have_attributes_matcher.nil?
"eq(#{wrap_keyword_arguments(value)})"
else
"#{have_attributes_matcher}(#{value.source})"
end
end

def transform_value(matcher, value)
have_attributes_matcher = HaveAttributes::MATCHER_MAPPING[matcher]

if have_attributes_matcher.nil?
# For eq, use value directly
# If value is keyword arguments (hash without braces), wrap in {}
wrap_keyword_arguments(value)
else
# For other matchers, wrap value in the have_attributes matcher
"#{have_attributes_matcher}(#{value.source})"
end
end

def wrap_keyword_arguments(value)
source = value.source
if value.hash_type? && !source.strip.start_with?('{')
"{ #{source} }"
else
source
end
end

def build_replacement
obj = sorted_nodes.first[:object]
attributes = build_attributes
<<~RUBY.chomp
expect(#{obj.source}).to have_attributes(
#{attributes}
)
RUBY
end
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rspec_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
require_relative 'rspec/expect_in_let'
require_relative 'rspec/expect_output'
require_relative 'rspec/focus'
require_relative 'rspec/have_attributes'
require_relative 'rspec/hook_argument'
require_relative 'rspec/hooks_before_examples'
require_relative 'rspec/identical_equality_assertion'
Expand Down
Loading