lib/rubocop/cop/internal_affairs/node_pattern_groups.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module InternalAffairs
      # Use node groups (`any_block`, `argument`, `boolean`, `call`, `numeric`, `range`)
      # in node patterns instead of a union (`{ ... }`) of the member types of the group.
      #
      # @example
      #   # bad
      #   def_node_matcher :my_matcher, <<~PATTERN
      #     {send csend}
      #   PATTERN
      #
      #   # good
      #   def_node_matcher :my_matcher, <<~PATTERN
      #     call
      #   PATTERN
      #
      class NodePatternGroups < Base
        require_relative 'node_pattern_groups/ast_processor'
        require_relative 'node_pattern_groups/ast_walker'

        include RangeHelp
        extend AutoCorrector

        MSG = 'Replace `%<names>s` in node pattern union with `%<replacement>s`.'
        RESTRICT_ON_SEND = %i[def_node_matcher def_node_search].freeze
        NODE_GROUPS = {
          any_block: %i[block numblock itblock],
          any_def: %i[def defs],
          argument: %i[arg optarg restarg kwarg kwoptarg kwrestarg blockarg forward_arg shadowarg],
          boolean: %i[true false],
          call: %i[send csend],
          numeric: %i[int float rational complex],
          range: %i[irange erange]
        }.freeze

        def on_new_investigation
          @walker = ASTWalker.new
        end

        # When a Node Pattern matcher is defined, investigate the pattern string to search
        # for node types that can be replaced with a node group (ie. `{send csend}` can be
        # replaced with `call`).
        #
        # In order to deal with node patterns in an efficient and non-brittle way, we will
        # parse the Node Pattern string given to this `send` node using
        # `RuboCop::AST::NodePattern::Parser::WithMeta`. `WithMeta` is important! We need
        # location information so that we can calculate the exact locations within the
        # pattern to report and correct.
        #
        # The resulting AST is processed by `NodePatternGroups::ASTProccessor` which rewrites
        # the AST slightly to handle node sequences (ie. `(send _ :foo ...)`). See the
        # documentation of that class for more details.
        #
        # Then the processed AST is walked, and metadata is collected for node types that
        # can be replaced with a node group.
        #
        # Finally, the metadata is used to register offenses and make corrections, using
        # the location data captured earlier. The ranges captured while parsing the Node
        # Pattern are offset using the string argument to this `send` node to ensure
        # that offenses are registered at the correct location.
        #
        def on_send(node)
          pattern_node = node.arguments[1]
          return unless acceptable_heredoc?(pattern_node) || pattern_node.str_type?

          process_pattern(pattern_node)
          return if node_groups.nil?

          apply_range_offsets(pattern_node)

          node_groups.each_with_index do |group, index|
            register_offense(group, index)
          end
        end

        def after_send(_)
          @walker.reset!
        end

        private

        def node_groups
          @walker.node_groups
        end

        # rubocop:disable InternalAffairs/RedundantSourceRange -- `node` here is a NodePatternNode
        def register_offense(group, index)
          replacement = replacement(group)
          message = format(
            MSG,
            names: group.node_types.map { |node| node.source_range.source }.join('`, `'),
            replacement: replacement
          )

          add_offense(group.offense_range, message: message) do |corrector|
            # Only correct one group at a time to avoid clobbering.
            # Other offenses will be corrected in the subsequent iterations of the
            # correction loop.
            next if index.positive?

            if group.other_elements?
              replace_types_with_node_group(corrector, group, replacement)
            else
              replace_union(corrector, group, replacement)
            end
          end
        end

        def replacement(group)
          if group.sequence?
            # If the original nodes were in a sequence (ie. wrapped in parentheses),
            # use it to generate the resulting NodePattern syntax.
            first_node_type = group.node_types.first
            template = first_node_type.source_range.source
            template.sub(first_node_type.child.to_s, group.name.to_s)
          else
            group.name
          end
        end
        # rubocop:enable InternalAffairs/RedundantSourceRange

        # When there are other elements in the union, remove the node types that can be replaced.
        def replace_types_with_node_group(corrector, group, replacement)
          ranges = group.ranges.map.with_index do |range, index|
            # Collect whitespace and pipes preceding each element
            range_for_full_union_element(range, index, group.pipe)
          end

          ranges.each { |range| corrector.remove(range) }

          corrector.insert_before(ranges.first, replacement)
        end

        # If the union contains pipes, remove the pipe character as well.
        # Unfortunately we don't get the location of the pipe in `loc` object, so we have
        # to find it.
        def range_for_full_union_element(range, index, pipe)
          if index.positive?
            range = if pipe
                      range_with_preceding_pipe(range)
                    else
                      range_with_surrounding_space(range: range, side: :left, newlines: true)
                    end
          end

          range
        end

        # Collect a preceding pipe and any whitespace left of the pipe
        def range_with_preceding_pipe(range)
          pos = range.begin_pos - 1

          while pos
            unless processed_source.buffer.source[pos].match?(/[\s|]/)
              return range.with(begin_pos: pos + 1)
            end

            pos -= 1
          end

          range
        end

        # When there are no other elements, the entire union can be replaced
        def replace_union(corrector, group, replacement)
          corrector.replace(group.ranges.first, replacement)
        end

        # rubocop:disable Metrics/AbcSize
        # Calculate the ranges for each node within the pattern string that will
        # be replaced or removed. Takes the offset of the string node into account.
        def apply_range_offsets(pattern_node)
          range, offset = range_with_offset(pattern_node)

          node_groups.each do |node_group|
            node_group.ranges ||= []
            node_group.offense_range = pattern_range(range, node_group.union, offset)

            if node_group.other_elements?
              node_group.node_types.each do |node_type|
                node_group.ranges << pattern_range(range, node_type, offset)
              end
            else
              node_group.ranges << node_group.offense_range
            end
          end
        end
        # rubocop:enable Metrics/AbcSize

        def pattern_range(range, node, offset)
          begin_pos = node.source_range.begin_pos
          end_pos = node.source_range.end_pos
          size = end_pos - begin_pos

          range.adjust(begin_pos: begin_pos + offset).resize(size)
        end

        def range_with_offset(pattern_node)
          if pattern_node.heredoc?
            [pattern_node.loc.heredoc_body, 0]
          else
            [pattern_node.source_range, pattern_node.loc.begin.size]
          end
        end

        # A heredoc can be a `dstr` without interpolation, but if there is interpolation
        # there'll be a `begin` node, in which case, we cannot evaluate the pattern.
        def acceptable_heredoc?(node)
          node.type?(:str, :dstr) && node.heredoc? && node.each_child_node(:begin).none?
        end

        def process_pattern(pattern_node)
          parser = RuboCop::AST::NodePattern::Parser::WithMeta.new
          ast = parser.parse(pattern_value(pattern_node))
          ast = ASTProcessor.new.process(ast)
          @walker.walk(ast)
        rescue RuboCop::AST::NodePattern::Invalid
          # if the pattern is invalid, no offenses will be registered
        end

        def pattern_value(pattern_node)
          pattern_node.heredoc? ? pattern_node.loc.heredoc_body.source : pattern_node.value
        end
      end
    end
  end
end