rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/layout/closing_parenthesis_indentation.rb

Summary

Maintainability
A
25 mins
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    module Layout
      # Checks the indentation of hanging closing parentheses in
      # method calls, method definitions, and grouped expressions. A hanging
      # closing parenthesis means `)` preceded by a line break.
      #
      # @example
      #
      #   # bad
      #   some_method(
      #     a,
      #     b
      #     )
      #
      #   some_method(
      #     a, b
      #     )
      #
      #   some_method(a, b, c
      #     )
      #
      #   some_method(a,
      #               b,
      #               c
      #     )
      #
      #   some_method(a,
      #     x: 1,
      #     y: 2
      #     )
      #
      #   # Scenario 1: When First Parameter Is On Its Own Line
      #
      #   # good: when first param is on a new line, right paren is *always*
      #   #       outdented by IndentationWidth
      #   some_method(
      #     a,
      #     b
      #   )
      #
      #   # good
      #   some_method(
      #     a, b
      #   )
      #
      #   # Scenario 2: When First Parameter Is On The Same Line
      #
      #   # good: when all other params are also on the same line, outdent
      #   #       right paren by IndentationWidth
      #   some_method(a, b, c
      #              )
      #
      #   # good: when all other params are on multiple lines, but are lined
      #   #       up, align right paren with left paren
      #   some_method(a,
      #               b,
      #               c
      #              )
      #
      #   # good: when other params are not lined up on multiple lines, outdent
      #   #       right paren by IndentationWidth
      #   some_method(a,
      #     x: 1,
      #     y: 2
      #   )
      #
      #
      class ClosingParenthesisIndentation < Base
        include Alignment
        extend AutoCorrector

        MSG_INDENT = 'Indent `)` to column %<expected>d (not %<actual>d)'

        MSG_ALIGN = 'Align `)` with `(`.'

        def on_send(node)
          check(node, node.arguments)
        end
        alias on_csend on_send

        def on_begin(node)
          check(node, node.children)
        end

        def on_def(node)
          check(node.arguments, node.arguments)
        end
        alias on_defs on_def

        private

        def autocorrect(corrector, node)
          AlignmentCorrector.correct(corrector, processed_source, node, @column_delta)
        end

        def check(node, elements)
          if elements.empty?
            check_for_no_elements(node)
          else
            check_for_elements(node, elements)
          end
        end

        def check_for_elements(node, elements)
          left_paren  = node.loc.begin
          right_paren = node.loc.end

          return unless right_paren && begins_its_line?(right_paren)

          correct_column = expected_column(left_paren, elements)

          @column_delta = correct_column - right_paren.column

          return if @column_delta.zero?

          message = message(correct_column, left_paren, right_paren)
          add_offense(right_paren, message: message) do |corrector|
            autocorrect(corrector, right_paren)
          end
        end

        def check_for_no_elements(node)
          left_paren = node.loc.begin
          right_paren = node.loc.end
          return unless right_paren && begins_its_line?(right_paren)

          candidates = correct_column_candidates(node, left_paren)

          return if candidates.include?(right_paren.column)

          # Although there are multiple choices for a correct column,
          # select the first one of candidates to determine a specification.
          correct_column = candidates.first
          @column_delta = correct_column - right_paren.column
          message = message(correct_column, left_paren, right_paren)
          add_offense(right_paren, message: message) do |corrector|
            autocorrect(corrector, right_paren)
          end
        end

        def expected_column(left_paren, elements)
          if line_break_after_left_paren?(left_paren, elements)
            source_indent = processed_source.line_indentation(first_argument_line(elements))
            new_indent    = source_indent - configured_indentation_width

            new_indent.negative? ? 0 : new_indent
          elsif all_elements_aligned?(elements)
            left_paren.column
          else
            processed_source.line_indentation(first_argument_line(elements))
          end
        end

        def all_elements_aligned?(elements)
          elements.flat_map do |e|
            if e.hash_type?
              e.each_child_node.map { |child| child.loc.column }
            else
              e.loc.column
            end
          end.uniq.count == 1
        end

        def first_argument_line(elements)
          elements.first.loc.first_line
        end

        def correct_column_candidates(node, left_paren)
          [
            processed_source.line_indentation(left_paren.line),
            left_paren.column,
            node.loc.column
          ]
        end

        def message(correct_column, left_paren, right_paren)
          if correct_column == left_paren.column
            MSG_ALIGN
          else
            format(MSG_INDENT, expected: correct_column, actual: right_paren.column)
          end
        end

        def line_break_after_left_paren?(left_paren, elements)
          elements.first && elements.first.loc.line > left_paren.line
        end
      end
    end
  end
end