rubocop-hq/rubocop

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

Summary

Maintainability
B
4 hrs
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    module Layout
      # Checks that operators have space around them, except for ** which
      # should or shouldn't have surrounding space depending on configuration.
      # It allows vertical alignment consisting of one or more whitespace
      # around operators.
      #
      # This cop has `AllowForAlignment` option. When `true`, allows most
      # uses of extra spacing if the intent is to align with an operator on
      # the previous or next line, not counting empty lines or comment lines.
      #
      # @example
      #   # bad
      #   total = 3*4
      #   "apple"+"juice"
      #   my_number = 38/4
      #
      #   # good
      #   total = 3 * 4
      #   "apple" + "juice"
      #   my_number = 38 / 4
      #
      # @example AllowForAlignment: true (default)
      #   # good
      #   {
      #     1 =>  2,
      #     11 => 3
      #   }
      #
      # @example AllowForAlignment: false
      #   # bad
      #   {
      #     1 =>  2,
      #     11 => 3
      #   }
      #
      # @example EnforcedStyleForExponentOperator: no_space (default)
      #   # bad
      #   a ** b
      #
      #   # good
      #   a**b
      #
      # @example EnforcedStyleForExponentOperator: space
      #   # bad
      #   a**b
      #
      #   # good
      #   a ** b
      #
      # @example EnforcedStyleForRationalLiterals: no_space (default)
      #   # bad
      #   1 / 48r
      #
      #   # good
      #   1/48r
      #
      # @example EnforcedStyleForRationalLiterals: space
      #   # bad
      #   1/48r
      #
      #   # good
      #   1 / 48r
      class SpaceAroundOperators < Base
        include PrecedingFollowingAlignment
        include RangeHelp
        include RationalLiteral
        extend AutoCorrector

        IRREGULAR_METHODS = %i[[] ! []=].freeze
        EXCESSIVE_SPACE = '  '

        def self.autocorrect_incompatible_with
          [Style::SelfAssignment]
        end

        def on_sclass(node)
          check_operator(:sclass, node.loc.operator, node)
        end

        def on_pair(node)
          return unless node.hash_rocket?

          return if hash_table_style? && !node.parent.pairs_on_same_line?

          check_operator(:pair, node.loc.operator, node)
        end

        def on_if(node)
          return unless node.ternary?

          check_operator(:if, node.loc.question, node.if_branch)
          check_operator(:if, node.loc.colon, node.else_branch)
        end

        def on_resbody(node)
          return unless node.loc.assoc

          _, variable, = *node

          check_operator(:resbody, node.loc.assoc, variable)
        end

        def on_send(node)
          return if rational_literal?(node)

          if node.setter_method?
            on_special_asgn(node)
          elsif regular_operator?(node)
            check_operator(:send, node.loc.selector, node.first_argument)
          end
        end

        def on_assignment(node)
          _, rhs, = *node

          return unless rhs

          check_operator(:assignment, node.loc.operator, rhs)
        end

        def on_casgn(node)
          _, _, right, = *node

          return unless right

          check_operator(:assignment, node.loc.operator, right)
        end

        def on_binary(node)
          _, rhs, = *node

          return unless rhs

          check_operator(:binary, node.loc.operator, rhs)
        end

        def on_special_asgn(node)
          _, _, right, = *node

          return unless right

          check_operator(:special_asgn, node.loc.operator, right)
        end

        def on_match_pattern(node)
          return if target_ruby_version < 3.0

          check_operator(:match_pattern, node.loc.operator, node)
        end

        alias on_or       on_binary
        alias on_and      on_binary
        alias on_lvasgn   on_assignment
        alias on_masgn    on_assignment
        alias on_ivasgn   on_assignment
        alias on_cvasgn   on_assignment
        alias on_gvasgn   on_assignment
        alias on_class    on_binary
        alias on_or_asgn  on_assignment
        alias on_and_asgn on_assignment
        alias on_op_asgn  on_special_asgn

        private

        def regular_operator?(send_node)
          return false if send_node.unary_operation? || send_node.dot? || send_node.double_colon?

          operator_with_regular_syntax?(send_node)
        end

        def operator_with_regular_syntax?(send_node)
          send_node.operator_method? && !IRREGULAR_METHODS.include?(send_node.method_name)
        end

        def check_operator(type, operator, right_operand)
          with_space = range_with_surrounding_space(operator)
          return if with_space.source.start_with?("\n")

          offense(type, operator, with_space, right_operand) do |msg|
            add_offense(operator, message: msg) do |corrector|
              autocorrect(corrector, with_space, right_operand)
            end
          end
        end

        def offense(type, operator, with_space, right_operand)
          msg = offense_message(type, operator, with_space, right_operand)
          yield msg if msg
        end

        def autocorrect(corrector, range, right_operand)
          range_source = range.source

          if range_source.include?('**') && !space_around_exponent_operator?
            corrector.replace(range, '**')
          elsif range_source.include?('/') && !space_around_slash_operator?(right_operand)
            corrector.replace(range, '/')
          elsif range_source.end_with?("\n")
            corrector.replace(range, " #{range_source.strip}\n")
          else
            enclose_operator_with_space(corrector, range)
          end
        end

        def enclose_operator_with_space(corrector, range)
          operator = range.source

          # If `ForceEqualSignAlignment` is true, `Layout/ExtraSpacing` cop
          # inserts spaces before operator. If `Layout/SpaceAroundOperators` cop
          # inserts a space, it collides and raises the infinite loop error.
          if force_equal_sign_alignment? && !operator.end_with?(' ')
            corrector.insert_after(range, ' ')
          else
            corrector.replace(range, " #{operator.strip} ")
          end
        end

        def offense_message(type, operator, with_space, right_operand)
          if should_not_have_surrounding_space?(operator, right_operand)
            return if with_space.is?(operator.source)

            "Space around operator `#{operator.source}` detected."
          elsif !/^\s.*\s$/.match?(with_space.source)
            "Surrounding space missing for operator `#{operator.source}`."
          elsif excess_leading_space?(type, operator, with_space) ||
                excess_trailing_space?(right_operand.source_range, with_space)
            "Operator `#{operator.source}` should be surrounded " \
              'by a single space.'
          end
        end

        def excess_leading_space?(type, operator, with_space)
          return false unless allow_for_alignment?
          return false unless with_space.source.start_with?(EXCESSIVE_SPACE)

          return !aligned_with_operator?(operator) unless type == :assignment

          token            = Token.new(operator, nil, operator.source)
          align_preceding  = aligned_with_preceding_assignment(token)

          return false if align_preceding == :yes ||
                          aligned_with_subsequent_assignment(token) == :none

          aligned_with_subsequent_assignment(token) != :yes
        end

        def excess_trailing_space?(right_operand, with_space)
          with_space.source.end_with?(EXCESSIVE_SPACE) &&
            (!allow_for_alignment? || !aligned_with_something?(right_operand))
        end

        def align_hash_cop_config
          config.for_cop('Layout/HashAlignment')
        end

        def hash_table_style?
          align_hash_cop_config && align_hash_cop_config['EnforcedHashRocketStyle'] == 'table'
        end

        def space_around_exponent_operator?
          cop_config['EnforcedStyleForExponentOperator'] == 'space'
        end

        def space_around_slash_operator?(right_operand)
          return true unless right_operand.rational_type?

          cop_config['EnforcedStyleForRationalLiterals'] == 'space'
        end

        def force_equal_sign_alignment?
          config.for_cop('Layout/ExtraSpacing')['ForceEqualSignAlignment']
        end

        def should_not_have_surrounding_space?(operator, right_operand)
          if operator.is?('**')
            !space_around_exponent_operator?
          elsif operator.is?('/')
            !space_around_slash_operator?(right_operand)
          else
            false
          end
        end
      end
    end
  end
end