Skip to content

Commit

Permalink
Add RSpec/FactoryBot/AssociationStyle cop
Browse files Browse the repository at this point in the history
  • Loading branch information
r7kamura committed Nov 19, 2022
1 parent 852052b commit 957caf9
Show file tree
Hide file tree
Showing 7 changed files with 652 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
- Add new `RSpec/FactoryBot/FactoryNameStyle` cop. ([@ydah])
- Fix wrong autocorrection in `n_times` style on `RSpec/FactoryBot/CreateList`. ([@r7kamura])
- Fix a false positive for `RSpec/FactoryBot/ConsistentParenthesesStyle` when using `generate` with multiple arguments. ([@ydah])
- Add `RSpec/FactoryBot/AssociationStyle` cop. ([@r7kamura])

## 2.15.0 (2022-11-03)

Expand Down
16 changes: 16 additions & 0 deletions config/default.yml
Expand Up @@ -911,6 +911,22 @@ RSpec/FactoryBot:
Include: *1
Language: *2

RSpec/FactoryBot/AssociationStyle:
Description: Use consistent style to define associations.
Enabled: pending
Safe: false
Include:
- spec/factories.rb
- spec/factories/**/*.rb
- features/support/factories/**/*.rb
VersionAdded: "<<next>>"
EnforcedStyle: implicit
SupportedStyles:
- explicit
- implicit
NonImplicitAssociationMethodNames: ~
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/FactoryBot/AssociationStyle

RSpec/FactoryBot/AttributeDefinedStatically:
Description: Always declare attribute values as blocks.
Enabled: true
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/cops.adoc
Expand Up @@ -104,6 +104,7 @@

=== Department xref:cops_rspec_factorybot.adoc[RSpec/FactoryBot]

* xref:cops_rspec_factorybot.adoc#rspecfactorybot/associationstyle[RSpec/FactoryBot/AssociationStyle]
* xref:cops_rspec_factorybot.adoc#rspecfactorybot/attributedefinedstatically[RSpec/FactoryBot/AttributeDefinedStatically]
* xref:cops_rspec_factorybot.adoc#rspecfactorybot/consistentparenthesesstyle[RSpec/FactoryBot/ConsistentParenthesesStyle]
* xref:cops_rspec_factorybot.adoc#rspecfactorybot/createlist[RSpec/FactoryBot/CreateList]
Expand Down
69 changes: 69 additions & 0 deletions docs/modules/ROOT/pages/cops_rspec_factorybot.adoc
@@ -1,5 +1,74 @@
= RSpec/FactoryBot

== RSpec/FactoryBot/AssociationStyle

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

| Pending
| No
| Yes (Unsafe)
| <<next>>
| -
|===

Use consistent style to define associations.

=== Safety

This cop may cause false-positives in `EnforcedStyle: explicit`
case. It recognizes any method call that has no arguments as an
implicit association but it might be a user-defined trait call.

=== Examples

==== EnforcedStyle: implicit (default)

[source,ruby]
----
# bad
association :user
# good
user
----

==== EnforcedStyle: explicit

[source,ruby]
----
# bad
user
# good
association :user
# good (defined in NonImplicitAssociationMethodNames)
skip_create
----

=== Configurable attributes

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

| Include
| `spec/factories.rb`, `+spec/factories/**/*.rb+`, `+features/support/factories/**/*.rb+`
| Array

| EnforcedStyle
| `implicit`
| `explicit`, `implicit`

| NonImplicitAssociationMethodNames
| `<none>`
|
|===

=== References

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

== RSpec/FactoryBot/AttributeDefinedStatically

|===
Expand Down
254 changes: 254 additions & 0 deletions lib/rubocop/cop/rspec/factory_bot/association_style.rb
@@ -0,0 +1,254 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
module FactoryBot
# Use consistent style to define associations.
#
# @safety
# This cop may cause false-positives in `EnforcedStyle: explicit`
# case. It recognizes any method call that has no arguments as an
# implicit association but it might be a user-defined trait call.
#
# @example EnforcedStyle: implicit (default)
# # bad
# association :user
#
# # good
# user
#
# @example EnforcedStyle: explicit
# # bad
# user
#
# # good
# association :user
#
# # good (defined in NonImplicitAssociationMethodNames)
# skip_create
class AssociationStyle < Base # rubocop:disable Metrics/ClassLength
extend AutoCorrector

include ConfigurableEnforcedStyle

DEFAULT_NON_IMPLICIT_ASSOCIATION_METHOD_NAMES = %w[
association
sequence
skip_create
traits_for_enum
].freeze

RESTRICT_ON_SEND = %i[factory trait].freeze

# @param node [RuboCop::AST::SendNode]
# @return [void]
def on_send(node)
bad_associations_in(node).each do |association|
add_offense(
association,
message: "Use #{style} style to define associations."
) do |corrector|
autocorrect(corrector, association)
end
end
end

private

# @!method explicit_association?(node)
# @param node [RuboCop::AST::SendNode]
# @return [Boolean]
def_node_matcher :explicit_association?, <<~PATTERN
(send nil? :association sym ...)
PATTERN

# @!method implicit_association?(node)
# @param node [RuboCop::AST::SendNode]
# @return [Boolean]
def_node_matcher :implicit_association?, <<~PATTERN
(send nil? !#non_implicit_association_method_name? ...)
PATTERN

# @!method factory_option_matcher(node)
# @param node [RuboCop::AST::SendNode]
# @return [Array<Symbol>, Symbol, nil]
def_node_matcher :factory_option_matcher, <<~PATTERN
(send
nil?
:association
...
(hash
<
(pair
(sym :factory)
{
(sym $_) |
(array (sym $_)*)
}
)
...
>
)
)
PATTERN

# @!method trait_arguments_matcher(node)
# @param node [RuboCop::AST::SendNode]
# @return [Array<Symbol>, nil]
def_node_matcher :trait_arguments_matcher, <<~PATTERN
(send nil? :association _ (sym $_)* ...)
PATTERN

# @!method trait_option_matcher(node)
# @param node [RuboCop::AST::SendNode]
# @return [Array<Symbol>, nil]
def_node_matcher :trait_option_matcher, <<~PATTERN
(send
nil?
:association
...
(hash
<
(pair
(sym :traits)
(array (sym $_)*)
)
...
>
)
)
PATTERN

# @param corrector [RuboCop::Cop::Corrector]
# @param node [RuboCop::AST::SendNode]
def autocorrect(corrector, node)
case style
when :explicit
autocorrect_to_explicit_style(corrector, node)
when :implicit
autocorrect_to_implicit_style(corrector, node)
end
end

# @param corrector [RuboCop::Cop::Corrector]
# @param node [RuboCop::AST::SendNode]
# @return [void]
def autocorrect_to_explicit_style(corrector, node)
arguments = [
":#{node.method_name}",
*node.arguments.map(&:source)
]
corrector.replace(node, "association #{arguments.join(', ')}")
end

# @param corrector [RuboCop::Cop::Corrector]
# @param node [RuboCop::AST::SendNode]
# @return [void]
def autocorrect_to_implicit_style(corrector, node)
source = node.first_argument.value.to_s
options = options_for_autocorrect_to_implicit_style(node)
unless options.empty?
rest = options.map { |option| option.join(': ') }.join(', ')
source += " #{rest}"
end
corrector.replace(node, source)
end

# @param node [RuboCop::AST::SendNode]
# @return [Boolean]
def autocorrectable_to_implicit_style?(node)
node.arguments.one?
end

# @param node [RuboCop::AST::SendNode]
# @return [Boolean]
def bad?(node)
case style
when :explicit
implicit_association?(node)
when :implicit
explicit_association?(node)
end
end

# @param node [RuboCop::AST::SendNode]
# @return [Array<RuboCop::AST::SendNode>]
def bad_associations_in(node)
children_of_factory_block(node).select do |child|
bad?(child)
end
end

# @param node [RuboCop::AST::SendNode]
# @return [Array<RuboCop::AST::Node>]
def children_of_factory_block(node)
block = node.parent
return [] unless block
return [] unless block.block_type?
return [] unless block.body

if block.body.begin_type?
block.body.children
else
[block.body]
end
end

# @param node [RuboCop::AST::SendNode]
# @return [Array<Symbol>]
def factory_names_from_explicit(node)
trait_names = trait_names_from_explicit(node)
factory_names = Array(factory_option_matcher(node))
result = factory_names + trait_names
if factory_names.empty? && !trait_names.empty?
result.prepend(node.first_argument.value)
end
result
end

# @param method_name [Symbol]
# @return [Boolean]
def non_implicit_association_method_name?(method_name)
non_implicit_association_method_names.include?(method_name.to_s)
end

# @return [Array<String>]
def non_implicit_association_method_names
DEFAULT_NON_IMPLICIT_ASSOCIATION_METHOD_NAMES +
(cop_config['NonImplicitAssociationMethodNames'] || [])
end

# @param node [RuboCop::AST::SendNode]
# @return [Hash{Symbol => String}]
def options_from_explicit(node)
return {} unless node.last_argument.hash_type?

node.last_argument.pairs.inject({}) do |options, pair|
options.merge(pair.key.value => pair.value.source)
end
end

# @param node [RuboCop::AST::SendNode]
# @return [Hash{Symbol => String}]
def options_for_autocorrect_to_implicit_style(node)
options = options_from_explicit(node)
options.delete(:traits)
factory_names = factory_names_from_explicit(node)
unless factory_names.empty?
options[:factory] = "%i[#{factory_names.join(' ')}]"
end
options
end

# @param node [RuboCop::AST::SendNode]
# @return [Array<Symbol>]
def trait_names_from_explicit(node)
(trait_arguments_matcher(node) || []) +
(trait_option_matcher(node) || [])
end
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rspec_cops.rb
Expand Up @@ -8,6 +8,7 @@
require_relative 'rspec/capybara/specific_matcher'
require_relative 'rspec/capybara/visibility_matcher'

require_relative 'rspec/factory_bot/association_style'
require_relative 'rspec/factory_bot/attribute_defined_statically'
require_relative 'rspec/factory_bot/consistent_parentheses_style'
require_relative 'rspec/factory_bot/create_list'
Expand Down

0 comments on commit 957caf9

Please sign in to comment.