lib/rubocop/cop/layout/class_structure.rb



# 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)
      # - Initializer
      # - Instance methods
      # - Protected methods
      # - Private methods
      #
      # You can configure the following order:
      #
      # ```yaml
      #  Layout/ClassStructure:
      #    Categories:
      #      module_inclusion:
      #        - include
      #        - prepend
      #        - extend
      #    ExpectedOrder:
      #        - module_inclusion
      #        - constants
      #        - public_class_methods
      #        - initializer
      #        - public_methods
      #        - protected_methods
      #        - private_methods
      #
      # ```
      # 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
      #   # Expect extend be before constant
      #   class Person < ApplicationRecord
      #     has_many :orders
      #     ANSWER = 42
      #
      #     extend SomeModule
      #     include AnotherModule
      #   end
      #
      #   # good
      #   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/rubocop-hq/ruby-style-guide#consistent-classes
      class ClassStructure < Cop
        HUMANIZED_NODE_TYPE = {
          casgn: :constants,
          defs: :class_methods,
          def: :public_methods
        }.freeze

        VISIBILITY_SCOPES = %i[private protected public].freeze
        MSG = '`%<category>s` is supposed to appear before ' \
              '`%<previous>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: category,
                                    previous: 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 :initializer if method_name == :initialize

            return "#{node_visibility(node)}_methods"
          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.first_line - 1
          first_comment = nil

          processed_source.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 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