Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve cops by using TopLevelGroup #977

Merged
merged 7 commits into from
Aug 4, 2020
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Relax `RSpec/VariableDefinition` cop so interpolated and multiline strings are accepted even when configured to enforce the `symbol` style. ([@bquorning][])
* Fix `RSpec/EmptyExampleGroup` to flag example groups with examples in invalid scopes. ([@mlarraz][])
* Fix `RSpec/EmptyExampleGroup` to ignore examples groups with examples defined inside iterators. ([@pirj][])
* Improve `RSpec/NestedGroups`, `RSpec/FilePath`, `RSpec/DescribeMethod`, `RSpec/MultipleDescribes`, `RSpec/DescribeClass`'s top-level example group detection. ([@pirj][])

## 1.42.0 (2020-07-09)

Expand Down
4 changes: 2 additions & 2 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ RSpec/ContextWording:
StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/ContextWording

RSpec/DescribeClass:
Description: Check that the first argument to the top level describe is a constant.
Description: Check that the first argument to the top-level describe is a constant.
Enabled: true
VersionAdded: '1.0'
StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/DescribeClass
Expand Down Expand Up @@ -381,7 +381,7 @@ RSpec/MissingExampleGroupArgument:
StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/MissingExampleGroupArgument

RSpec/MultipleDescribes:
Description: Checks for multiple top level describes.
Description: Checks for multiple top-level example groups.
Enabled: true
VersionAdded: '1.0'
StyleGuide: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/MultipleDescribes
Expand Down
49 changes: 20 additions & 29 deletions lib/rubocop/cop/rspec/describe_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RuboCop
module Cop
module RSpec
# Check that the first argument to the top level describe is a constant.
# Check that the first argument to the top-level describe is a constant.
#
# @example
# # bad
Expand All @@ -22,49 +22,40 @@ module RSpec
# describe "A feature example", type: :feature do
# end
class DescribeClass < Base
include RuboCop::RSpec::TopLevelDescribe
include RuboCop::RSpec::TopLevelGroup

MSG = 'The first argument to describe should be '\
'the class or module being tested.'

def_node_matcher :valid_describe?, <<-PATTERN
{
(send #rspec? :describe const ...)
(send #rspec? :describe)
}
PATTERN

def_node_matcher :describe_with_rails_metadata?, <<-PATTERN
(send #rspec? :describe !const ...
(hash <#rails_metadata? ...>)
)
PATTERN

def_node_matcher :rails_metadata?, <<-PATTERN
(pair
(sym :type)
(sym {
:channel :controller :helper :job :mailer :model :request
:routing :view :feature :system :mailbox
}
)
(sym { :channel :controller :helper :job :mailer :model :request
:routing :view :feature :system :mailbox })
)
PATTERN

def on_top_level_describe(node, (described_value, _))
return if shared_group?(root_node)
return if valid_describe?(node)
return if describe_with_rails_metadata?(node)
return if string_constant_describe?(described_value)
def_node_matcher :example_group_with_rails_metadata?, <<~PATTERN
(send #rspec? :describe ... (hash <#rails_metadata? ...>))
PATTERN

def_node_matcher :not_a_const_described, <<~PATTERN
(send #rspec? :describe $[!const !#string_constant?] ...)
PATTERN
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collapsed several significantly overlapping node patterns into one.
Moved guard statements into node pattern as well.

There still seems to be some redundancy in this node pattern, similarly to SubjectStub:

          {
            (block (send nil? :subject (sym $_)) args ...)
            (block (send nil? $:subject) args ...)
          }

I'll open a ticket for rubocop-ast.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is [ ...] a new syntax for { ... }? Does it work on the minimum supported rubocop version or should we bump the requirements?

Copy link
Member Author

@pirj pirj Jul 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ ] is logical "and", it's around for a while, just not too frequently used.

I find it too hard to reason about boolean login, this node pattern sounds like:

a block
  with a send of `describe` to `RSpec`/nil?
    where the first argument is
      neither a const not a string resembling a const
      *and* the rest of the arguments don't contain a hash with Rails-like metadata

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused by the (send #rspec? :describe part in the second argument of [ ]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Darhazer Why?

It's basically the same as previously (which I forgot to remove during merge resolution):

        def_node_matcher :describe_with_rails_metadata?, <<-PATTERN
          (send #rspec? :describe !const ...
            (hash <#rails_metadata? ...>)
          )
        PATTERN

!const check is not necessary anymore, it's checked in the first part of [ ].

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I too find this node pattern a bit cryptic. Perhaps a bit more duplication can be justified if it makes this code easier to read?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point guys. Split this node pattern into two parts. Looks more readable now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI the shiny newly released rubocop-ast allows comments in patterns


def on_top_level_group(top_level_node)
return if example_group_with_rails_metadata?(top_level_node.send_node)

add_offense(described_value)
not_a_const_described(top_level_node.send_node) do |described|
add_offense(described)
end
end

private

def string_constant_describe?(described_value)
described_value.str_type? &&
described_value.value.match?(/^(?:(?:::)?[A-Z]\w*)+$/)
def string_constant?(described)
described.str_type? &&
described.value.match?(/^(?:(?:::)?[A-Z]\w*)+$/)
end
end
end
Expand Down
18 changes: 13 additions & 5 deletions lib/rubocop/cop/rspec/describe_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@ module RSpec
# describe MyClass, '.my_class_method' do
# end
class DescribeMethod < Base
include RuboCop::RSpec::TopLevelDescribe
include RuboCop::RSpec::TopLevelGroup

MSG = 'The second argument to describe should be the method '\
"being tested. '#instance' or '.class'."

def on_top_level_describe(_node, (_, second_arg))
return unless second_arg&.str_type?
return if second_arg.str_content.start_with?('#', '.')
def_node_matcher :second_argument, <<~PATTERN
(block
(send #rspec? :describe _first_argument $(str _) ...) ...
pirj marked this conversation as resolved.
Show resolved Hide resolved
)
PATTERN

add_offense(second_arg)
def on_top_level_group(node)
second_argument = second_argument(node)
pirj marked this conversation as resolved.
Show resolved Hide resolved

return unless second_argument
return if second_argument.str_content.start_with?('#', '.')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok to turn this into a method and pushing this into the node pattern, but decided not to fix everything in one go.


add_offense(second_argument)
end
end
end
Expand Down
40 changes: 24 additions & 16 deletions lib/rubocop/cop/rspec/file_path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,34 +57,42 @@ module RSpec
# my_class_spec.rb # describe MyClass, '#method'
#
class FilePath < Base
include RuboCop::RSpec::TopLevelDescribe
include RuboCop::RSpec::TopLevelGroup

MSG = 'Spec path should end with `%<suffix>s`.'

def_node_search :const_described?, '(send _ :describe (const ...) ...)'
def_node_matcher :const_described, <<~PATTERN
(block
$(send #rspec? _example_group $(const ...) $...) ...
pirj marked this conversation as resolved.
Show resolved Hide resolved
)
PATTERN

def_node_search :routing_metadata?, '(pair (sym :type) (sym :routing))'

def on_top_level_describe(node, args)
return unless const_described?(node) && single_top_level_describe?
pirj marked this conversation as resolved.
Show resolved Hide resolved
return if routing_spec?(args)
def on_top_level_group(node)
return unless top_level_groups.one?

glob = glob_for(args)
const_described(node) do |send_node, described_class, arguments|
next if routing_spec?(arguments)
bquorning marked this conversation as resolved.
Show resolved Hide resolved

return if filename_ends_with?(glob)

add_offense(
node,
message: format(MSG, suffix: glob)
)
ensure_correct_file_path(send_node, described_class, arguments)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code climate insisted on this extraction.

end
end

private

def ensure_correct_file_path(send_node, described_class, arguments)
glob = glob_for(described_class, arguments.first)
return if filename_ends_with?(glob)

add_offense(send_node, message: format(MSG, suffix: glob))
end

def routing_spec?(args)
args.any?(&method(:routing_metadata?))
end

def glob_for((described_class, method_name))
def glob_for(described_class, method_name)
return glob_for_spec_suffix_only? if spec_suffix_only?

"#{expected_path(described_class)}#{name_glob(method_name)}*_spec.rb"
Expand All @@ -94,10 +102,10 @@ def glob_for_spec_suffix_only?
'*_spec.rb'
end

def name_glob(name)
return unless name&.str_type?
def name_glob(method_name)
return unless method_name&.str_type?

"*#{name.str_content.gsub(/\W/, '')}" unless ignore_methods?
"*#{method_name.str_content.gsub(/\W/, '')}" unless ignore_methods?
end

def expected_path(constant)
Expand Down
17 changes: 10 additions & 7 deletions lib/rubocop/cop/rspec/multiple_describes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module RuboCop
module Cop
module RSpec
# Checks for multiple top level describes.
# Checks for multiple top-level example groups.
#
# Multiple descriptions for the same class or module should either
# be nested or separated into different test files.
Expand All @@ -23,16 +23,19 @@ module RSpec
# end
# end
class MultipleDescribes < Base
include RuboCop::RSpec::TopLevelDescribe
include RuboCop::RSpec::TopLevelGroup

MSG = 'Do not use multiple top level describes - '\
MSG = 'Do not use multiple top-level example groups - '\
'try to nest them.'

def on_top_level_describe(node, _args)
return if single_top_level_describe?
return unless top_level_nodes.first.equal?(node)
def on_top_level_group(node)
top_level_example_groups =
top_level_groups.select(&method(:example_group?))

add_offense(node)
return if top_level_example_groups.one?
return unless top_level_example_groups.first.equal?(node)

add_offense(node.send_node)
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/rubocop/cop/rspec/nested_groups.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ module RSpec
#
class NestedGroups < Base
include ConfigurableMax
include RuboCop::RSpec::TopLevelDescribe
include RuboCop::RSpec::TopLevelGroup

MSG = 'Maximum example group nesting exceeded [%<total>d/%<max>d].'

Expand All @@ -97,8 +97,8 @@ class NestedGroups < Base
"Configuration key `#{DEPRECATED_MAX_KEY}` for #{cop_name} is " \
'deprecated in favor of `Max`. Please use that instead.'

def on_top_level_describe(node, _args)
find_nested_example_groups(node.parent) do |example_group, nesting|
def on_top_level_group(node)
find_nested_example_groups(node) do |example_group, nesting|
self.max = nesting
add_offense(
example_group.send_node,
Expand Down
37 changes: 24 additions & 13 deletions lib/rubocop/rspec/top_level_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,40 @@ module TopLevelGroup
def_node_matcher :example_or_shared_group?,
(ExampleGroups::ALL + SharedGroups::ALL).block_pattern

def on_block(node)
return unless respond_to?(:on_top_level_group)
return unless top_level_group?(node)
def on_new_investigation
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hair more efficient than on_block.

super

on_top_level_group(node)
return unless root_node

top_level_groups.each do |node|
example_group?(node, &method(:on_top_level_example_group))
on_top_level_group(node)
end
pirj marked this conversation as resolved.
Show resolved Hide resolved
end

def top_level_groups
@top_level_groups ||=
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memoization needs to be reset on each new investigation, right?

In all cases it is recommended to to invalidate your cache data during the on_new_investigation callback

https://github.com/rubocop-hq/rubocop/blob/6b36c4490c2d7c41349e4f71721bf0cf91920578/docs/modules/ROOT/pages/v1_upgrade_notes.adoc#if-your-class-uses-instance-variables

Copy link
Member Author

@pirj pirj Aug 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was following this note:

Right now a new cop instance is created for every file

There might have been some changes after the doc has been introduced.

As a quick experiment, I've set a breakpoint in random cop's initialize and ran rubocop (0.88.0):

From: /Users/pirj/source/rubocop-rspec/lib/rubocop/cop/rspec/described_class.rb:63 RuboCop::Cop::RSpec::DescribedClass#initialize:

    61: def initialize(*args)
    62:   require 'pry'; binding.pry
 => 63:   super
    64: end

[1] pry(#<RuboCop::Cop::RSpec::DescribedClass>)>
.
From: /Users/pirj/source/rubocop-rspec/lib/rubocop/cop/rspec/described_class.rb:63 RuboCop::Cop::RSpec::DescribedClass#initialize:

    61: def initialize(*args)
    62:   require 'pry'; binding.pry
 => 63:   super
    64: end

[1] pry(#<RuboCop::Cop::RSpec::DescribedClass>)>
.
From: /Users/pirj/source/rubocop-rspec/lib/rubocop/cop/rspec/described_class.rb:63 RuboCop::Cop::RSpec::DescribedClass#initialize:

    61: def initialize(*args)
    62:   require 'pry'; binding.pry
 => 63:   super
    64: end

[1] pry(#<RuboCop::Cop::RSpec::DescribedClass>)>
.
From: /Users/pirj/source/rubocop-rspec/lib/rubocop/cop/rspec/described_class.rb:63 RuboCop::Cop::RSpec::DescribedClass#initialize:

    61: def initialize(*args)
    62:   require 'pry'; binding.pry
 => 63:   super
    64: end

[1] pry(#<RuboCop::Cop::RSpec::DescribedClass>)>
.

@marcandre Can you please shed light on this? Isn't this doc misleading?

I used to think it would be quite hard to reuse cop instances since depending on the directory they may receive different config (due to directory-local .rubocop.yml).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cop mentioned in the referenced discussion, SurroundingSpace does reset its state:

      def on_new_investigation
        @token_table = nil

Copy link
Member Author

@pirj pirj Aug 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's even more strange:

        def on_new_investigation
          print "N"
          super
        end

        def initialize(*args)
          print "#"
          super
        end

outputs:

##.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#C#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#N.#.

reduced for better visibility:

#.#.#.#.#.#.#.#.#N.#N.#N.#N.#N.#N.#N.

🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirj Currently, cops being persisted is opt-in. That being said, I'm wondering if a cop that implements on_new_investigation shouldn't be persisted by default.
BTW, different config => new cop, but that's a very rare setup.
It looks like the cop is initialized a bunch of times before doing the actual run, that seems like a bug somewhere, I'll check it out.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cop specs won't be bringing memoization to the light, will they? With all numerous well-unknown RuboCop extensions this can have quite an impact.

Yes, that was my thinking too.

I don't see a reason to disrespect this option.

Absolutely :-)

However, does it make sense to keep this notice for the rest of the cops

You mean the recommendation to clear the data with on_new_investigation? I guess it's not necessary. I should also be clearer reword the "will be called on different if" to "will not be called unless"...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove the whole "If your class uses instance variables" section, as it's more confusing than helpful considering the current "one source" - "one cop instance" behaviour.

Even for those cops that need to be persisted, I can think of global variables class variables to persist the data between the runs.

By the way, there's one more thing that can impact those persisted cops - --parallel. Do we parallelize by cops or by source?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even for those cops that need to be persisted, I can think of class variables to persist the data between the runs.

Not quite... See rubocop/rubocop#7968 if interested

By the way, there's one more thing that can impact those persisted cops - --parallel. Do we parallelize by cops or by source?

I imagine it's by source, but we could segregate those cops that need it; I suspect they simply run them without --parallel though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bquorning As a bottom-line: I wouldn't worry about @top_level_groups ||= used here unless any of the cops would decide to opt-in for reusage and use TopLevelGroup which I highly doubt.

top_level_nodes(root_node).select { |n| example_or_shared_group?(n) }
end

private

# Dummy methods to be overridden in the consumer
def on_top_level_example_group; end

def on_top_level_group; end

def top_level_group?(node)
top_level_groups.include?(node)
end

def top_level_groups
@top_level_groups ||=
top_level_nodes.select { |n| example_or_shared_group?(n) }
end

def top_level_nodes
if root_node.begin_type?
root_node.children
def top_level_nodes(node)
if node.begin_type?
bquorning marked this conversation as resolved.
Show resolved Hide resolved
node.children
elsif node.module_type? || node.class_type?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously,

module A
  class B
    RSpec.describe 'C' do

was missed.

top_level_nodes(node.body)
else
[root_node]
[node]
end
end

Expand Down
4 changes: 2 additions & 2 deletions manual/cops_rspec.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChan
--- | --- | --- | --- | ---
Enabled | Yes | No | 1.0 | -

Check that the first argument to the top level describe is a constant.
Check that the first argument to the top-level describe is a constant.

### Examples

Expand Down Expand Up @@ -2033,7 +2033,7 @@ Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChan
--- | --- | --- | --- | ---
Enabled | Yes | No | 1.0 | -

Checks for multiple top level describes.
Checks for multiple top-level example groups.

Multiple descriptions for the same class or module should either
be nested or separated into different test files.
Expand Down
2 changes: 0 additions & 2 deletions spec/rubocop/cop/rspec/expect_output_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe RuboCop::Cop::RSpec::ExpectOutput do
subject(:cop) { described_class.new }

Expand Down
7 changes: 7 additions & 0 deletions spec/rubocop/cop/rspec/file_path_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
RUBY
end

it 'registers an offense for a bad path for all kinds of example groups' do
expect_offense(<<-RUBY, 'wrong_path_foo_spec.rb')
example_group MyClass, 'foo' do; end
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Spec path should end with `my_class*foo*_spec.rb`.
RUBY
end

it 'registers an offense for a wrong class but a correct method' do
expect_offense(<<-RUBY, 'wrong_class_foo_spec.rb')
describe MyClass, '#foo' do; end
Expand Down
Loading