lib/rubocop/cop/lint/safe_navigation_consistency.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module Lint
      # Check to make sure that if safe navigation is used in an `&&` or `||` condition,
      # consistent and appropriate safe navigation, without excess or deficiency,
      # is used for all method calls on the same object.
      #
      # @example
      #   # bad
      #   foo&.bar && foo&.baz
      #
      #   # good
      #   foo&.bar && foo.baz
      #
      #   # bad
      #   foo.bar && foo&.baz
      #
      #   # good
      #   foo.bar && foo.baz
      #
      #   # bad
      #   foo&.bar || foo.baz
      #
      #   # good
      #   foo&.bar || foo&.baz
      #
      #   # bad
      #   foo.bar || foo&.baz
      #
      #   # good
      #   foo.bar || foo.baz
      #
      #   # bad
      #   foo&.bar && (foobar.baz || foo&.baz)
      #
      #   # good
      #   foo&.bar && (foobar.baz || foo.baz)
      #
      class SafeNavigationConsistency < Base
        include NilMethods
        extend AutoCorrector

        USE_DOT_MSG = 'Use `.` instead of unnecessary `&.`.'
        USE_SAFE_NAVIGATION_MSG = 'Use `&.` for consistency with safe navigation.'

        def on_and(node)
          all_operands = collect_operands(node, [])
          operand_groups = all_operands.group_by { |operand| receiver_name_as_key(operand, +'') }

          operand_groups.each_value do |grouped_operands|
            next unless (dot_op, begin_of_rest_operands = find_consistent_parts(grouped_operands))

            rest_operands = grouped_operands[begin_of_rest_operands..]
            rest_operands.each do |operand|
              next if already_appropriate_call?(operand, dot_op)

              register_offense(operand, dot_op)
            end
          end
        end
        alias on_or on_and

        private

        def collect_operands(node, operand_nodes)
          operand_nodes(node.lhs, operand_nodes)
          operand_nodes(node.rhs, operand_nodes)

          operand_nodes
        end

        def receiver_name_as_key(method, fully_receivers)
          if method.parent.call_type?
            receiver(method.parent, fully_receivers)
          else
            fully_receivers << method.receiver&.source.to_s
          end
        end

        # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
        def find_consistent_parts(grouped_operands)
          csend_in_and, csend_in_or, send_in_and, send_in_or = most_left_indices(grouped_operands)

          return if csend_in_and && csend_in_or && csend_in_and < csend_in_or

          if csend_in_and
            ['.', (send_in_and ? [send_in_and, csend_in_and].min : csend_in_and) + 1]
          elsif send_in_or && csend_in_or
            send_in_or < csend_in_or ? ['.', send_in_or + 1] : ['&.', csend_in_or + 1]
          elsif send_in_and && csend_in_or && send_in_and < csend_in_or
            ['.', csend_in_or]
          end
        end
        # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

        def already_appropriate_call?(operand, dot_op)
          return true if operand.safe_navigation? && dot_op == '&.'

          (operand.dot? || operand.operator_method?) && dot_op == '.'
        end

        def register_offense(operand, dot_operator)
          offense_range = operand.operator_method? ? operand : operand.loc.dot
          message = dot_operator == '.' ? USE_DOT_MSG : USE_SAFE_NAVIGATION_MSG

          add_offense(offense_range, message: message) do |corrector|
            next if operand.operator_method?

            corrector.replace(operand.loc.dot, dot_operator)
          end
        end

        def operand_nodes(operand, operand_nodes)
          if operand.operator_keyword?
            collect_operands(operand, operand_nodes)
          elsif operand.call_type?
            operand_nodes << operand
          end
        end

        # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
        def most_left_indices(grouped_operands)
          indices = { csend_in_and: nil, csend_in_or: nil, send_in_and: nil, send_in_or: nil }

          grouped_operands.each_with_index do |operand, index|
            indices[:csend_in_and] ||= index if operand_in_and?(operand) && operand.csend_type?
            indices[:csend_in_or] ||= index if operand_in_or?(operand) && operand.csend_type?
            indices[:send_in_and] ||= index if operand_in_and?(operand) && !nilable?(operand)
            indices[:send_in_or] ||= index if operand_in_or?(operand) && !nilable?(operand)
          end

          indices.values
        end
        # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

        def operand_in_and?(node)
          return true if node.parent.and_type?

          parent = node.parent.parent while node.parent.begin_type?

          parent&.and_type?
        end

        def operand_in_or?(node)
          return true if node.parent.or_type?

          parent = node.parent.parent while node.parent.begin_type?

          parent&.or_type?
        end

        def nilable?(node)
          node.csend_type? || nil_methods.include?(node.method_name)
        end
      end
    end
  end
end