Skip to content

Commit

Permalink
[Fix rubocop#1575] Add Layout/ClassStructure cop
Browse files Browse the repository at this point in the history
  • Loading branch information
Jônatas Davi Paganini committed Nov 23, 2017
1 parent b1a74d7 commit 6f4c533
Show file tree
Hide file tree
Showing 8 changed files with 646 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ Style/FormatStringToken:
Layout/EndOfLine:
EnforcedStyle: lf

Layout/ClassStructure:
Enabled: true
Categories:
include:
- prepend
ExpectedOrder:
- extend
- include
- constant
- public_class_method
- initialize
- instance_method
- protected_method
- private_method

Layout/IndentHeredoc:
EnforcedStyle: powerpack

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## master (unreleased)

* [#1575](https://github.com/bbatsov/rubocop/issues/1575): Add new `Layout/ClassStructure` cop that checks whether definitions in a class are in the configured order. This cop is disabled by default. ([@jonatas][])

### New features

* [#5101](https://github.com/bbatsov/rubocop/pull/5101): Allow to specify `TargetRubyVersion` 2.5. ([@walf443][])
Expand Down
26 changes: 26 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,32 @@ Layout/SpaceInsideStringInterpolation:
- space
- no_space

Layout/ClassStructure:
Description: 'Enforces a configured order of definitions within a class body.'
StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-classes'
Enabled: false
# All `Categories` can be used in the ExpectedOrder. The following classifications are also provided:
# - constant
# - class_method
# - instance_method
# - initialize
# - private_method
# - protected_method
ExpectedOrder:
- includes
- constant
- public_class_method
- initialize
- instance_method
- protected_method
- private_method
# Mapping categories allows to group code macros and classifications to reuse in the `ExpectedOrder`.
Categories:
includes:
- include
- prepend
- extend

Layout/Tab:
# By default, the indentation width from Layout/IndentationWidth is used
# But it can be overridden by setting this parameter
Expand Down
1 change: 1 addition & 0 deletions lib/rubocop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
require_relative 'rubocop/cop/layout/align_parameters'
require_relative 'rubocop/cop/layout/block_end_newline'
require_relative 'rubocop/cop/layout/case_indentation'
require_relative 'rubocop/cop/layout/class_structure'
require_relative 'rubocop/cop/layout/closing_parenthesis_indentation'
require_relative 'rubocop/cop/layout/comment_indentation'
require_relative 'rubocop/cop/layout/dot_position'
Expand Down
302 changes: 302 additions & 0 deletions lib/rubocop/cop/layout/class_structure.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Layout
# Checks if the code style follows the ExpectedOrder configuration:
#
# `Categories` allows us to map macro names into a category.
#
# Consider an example of code style that covers the following order:
# - Constants
# - Associations (has_one, has_many)
# - Attributes (attr_accessor, attr_writer, attr_reader)
# - Initialize
# - Instance methods
# - Protected methods
# - Private methods
#
# You can configure the following order:
#
# ```yaml
# Layout/ClassStructure:
# ExpectedOrder:
# - constant
# - association
# - attribute
# - initialize
# - instance_method
# - protected_method
# - private_method
#
# ```
# Instead of putting all literals in the expected order, is also
# possible to group categories of macros.
#
# ```yaml
# Layout/ClassStructure:
# Categories:
# association:
# - has_many
# - has_one
# attribute:
# - attr_accessor
# - attr_reader
# - attr_writer
# ```
#
# @example bad code
#
# # bad: Expect extend be before constant
# class Person < ApplicationRecord
# has_many :
# ANSWER = 42
#
# extend SomeModule
# include AnotherModule
# end
#
# @example of good code
#
# class Person
# # extend and include go first
# extend SomeModule
# include AnotherModule
#
# # inner classes
# CustomError = Class.new(StandardError)
#
# # constants are next
# SOME_CONSTANT = 20
#
# # afterwards we have attribute macros
# attr_reader :name
#
# # followed by other macros (if any)
# validates :name
#
# # public class methods are next in line
# def self.some_method
# end
#
# # initialization goes between class methods and instance methods
# def initialize
# end
#
# # followed by other public instance methods
# def some_method
# end
#
# # protected and private methods are grouped near the end
# protected
#
# def some_protected_method
# end
#
# private
#
# def some_private_method
# end
# end
#
# @see https://github.com/bbatsov/ruby-style-guide#consistent-classes
class ClassStructure < Cop
HUMANIZED_NODE_TYPE = {
casgn: :constant,
defs: :class_method,
def: :instance_method
}.freeze

VISIBILITY_SCOPES = %i[private protected public].freeze
MSG = '`%s` is supposed to appear before `%s`.'.freeze

def_node_matcher :visibility_block?, <<-PATTERN
(send nil? { :private :protected :public })
PATTERN

# Validates code style on class declaration.
# Add offense when find a node out of expected order.
def on_class(class_node)
previous = -1
walk_over_nested_class_definition(class_node) do |node, category|
index = expected_order.index(category)
if index < previous
message = format(MSG, category, expected_order[previous])
add_offense(node, message: message)
end
previous = index
end
end

# Autocorrect by swapping between two nodes autocorrecting them
def autocorrect(node)
node_classification = classify(node)
previous = left_siblings_of(node).find do |sibling|
classification = classify(sibling)
!ignore?(classification) && node_classification != classification
end

current_range = source_range_with_comment(node)
previous_range = source_range_with_comment(previous)

lambda do |corrector|
corrector.insert_before(previous_range, current_range.source)
corrector.remove(current_range)
end
end

private

# Classifies a node to match with something in the {expected_order}
# @param node to be analysed
# @return String when the node type is a `:block` then
# {classify} recursively with the first children
# @return String when the node type is a `:send` then {find_category}
# by method name
# @return String otherwise trying to {humanize_node} of the current node
def classify(node)
return node.to_s unless node.respond_to?(:type)
case node.type
when :block
classify(node.send_node)
when :send
find_category(node.method_name)
else
humanize_node(node)
end.to_s
end

# Categorize a method_name according to the {expected_order}
# @param method_name try to match {categories} values
# @return [String] with the key category or the `method_name` as string
def find_category(method_name)
name = method_name.to_s
category, = categories.find { |_, names| names.include?(name) }
category || name
end

def walk_over_nested_class_definition(class_node)
class_elements(class_node).each do |node|
classification = classify(node)
next if ignore?(classification)
yield node, classification
end
end

def class_elements(class_node)
*, class_def = class_node.children
return [] unless class_def
if class_def.def_type? || class_def.send_type?
[class_def]
else
class_def.children.compact
end
end

def ignore?(classification)
classification.nil? ||
classification.to_s.end_with?('=') ||
expected_order.index(classification).nil?
end

def node_visibility(node)
_, method_name, = *find_visibility_start(node)
method_name || :public
end

def find_visibility_start(node)
left_siblings_of(node)
.reverse
.find(&method(:visibility_block?))
end

# Navigate to find the last protected method
def find_visibility_end(node)
possible_visibilities = VISIBILITY_SCOPES - [node_visibility(node)]
right = right_siblings_of(node)
right.find do |child_node|
possible_visibilities.include?(node_visibility(child_node))
end || right.last
end

def siblings_of(node)
node.parent.children
end

def right_siblings_of(node)
siblings_of(node)[node.sibling_index..-1]
end

def left_siblings_of(node)
siblings_of(node)[0, node.sibling_index]
end

def humanize_node(node)
method_name, = *node
if node.def_type?
return :initialize if method_name == :initialize
return "#{node_visibility(node)}_method"
end
HUMANIZED_NODE_TYPE[node.type] || node.type
end

def source_range_with_comment(node)
begin_pos, end_pos =
if node.def_type?
start_node = find_visibility_start(node) || node
end_node = find_visibility_end(node) || node
[begin_pos_with_comment(start_node),
end_position_for(end_node) + 1]
else
[begin_pos_with_comment(node), end_position_for(node)]
end

Parser::Source::Range.new(buffer, begin_pos, end_pos)
end

def end_position_for(node)
end_line = buffer.line_for_position(node.loc.expression.end_pos)
buffer.line_range(end_line).end_pos
end

def begin_pos_with_comment(node)
annotation_line = node.loc.line - 1
first_comment = nil

comments_before_line(annotation_line).reverse_each do |comment|
if comment.location.line == annotation_line
first_comment = comment
annotation_line -= 1
end
end

start_line_position(first_comment || node)
end

def start_line_position(node)
buffer.line_range(node.loc.line).begin_pos - 1
end

def comments_before_line(line)
processed_source.comments.select { |c| c.location.line <= line }
end

def buffer
processed_source.buffer
end

# Load expected order from `ExpectedOrder` config.
# Define new terms in the expected order by adding new {categories}.
def expected_order
cop_config['ExpectedOrder']
end

# Setting categories hash allow you to group methods in group to match
# in the {expected_order}.
def categories
cop_config['Categories']
end
end
end
end
end
1 change: 1 addition & 0 deletions manual/cops.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ In the following section you find all available cops:
* [Layout/AlignParameters](cops_layout.md#layoutalignparameters)
* [Layout/BlockEndNewline](cops_layout.md#layoutblockendnewline)
* [Layout/CaseIndentation](cops_layout.md#layoutcaseindentation)
* [Layout/ClassStructure](cops_layout.md#layoutclassstructure)
* [Layout/ClosingParenthesisIndentation](cops_layout.md#layoutclosingparenthesisindentation)
* [Layout/CommentIndentation](cops_layout.md#layoutcommentindentation)
* [Layout/DotPosition](cops_layout.md#layoutdotposition)
Expand Down
Loading

0 comments on commit 6f4c533

Please sign in to comment.