lib/rubocop/cop/performance/map_compact.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module Performance
      # In Ruby 2.7, `Enumerable#filter_map` has been added.
      #
      # This cop identifies places where `map { ... }.compact` can be replaced by `filter_map`.
      #
      # @safety
      #   This cop's autocorrection is unsafe because `map { ... }.compact` might yield
      #   different results than `filter_map`. As illustrated in the example, `filter_map`
      #   also filters out falsy values, while `compact` only gets rid of `nil`.
      #
      # [source,ruby]
      # ----
      # [true, false, nil].compact              #=> [true, false]
      # [true, false, nil].filter_map(&:itself) #=> [true]
      # ----
      #
      # @example
      #   # bad
      #   ary.map(&:foo).compact
      #   ary.collect(&:foo).compact
      #
      #   # good
      #   ary.filter_map(&:foo)
      #   ary.map(&:foo).compact!
      #   ary.compact.map(&:foo)
      #
      class MapCompact < Base
        include RangeHelp
        extend AutoCorrector
        extend TargetRubyVersion

        MSG = 'Use `filter_map` instead.'
        RESTRICT_ON_SEND = %i[compact].freeze

        minimum_target_ruby_version 2.7

        def_node_matcher :map_compact, <<~PATTERN
          {
            (call
              $(call _ {:map :collect}
                (block_pass
                  (sym _))) _)
            (call
              (block
                $(call _ {:map :collect})
                  (args ...) _) _)
          }
        PATTERN

        def on_send(node)
          return unless (map_node = map_compact(node))

          compact_loc = node.loc
          range = range_between(map_node.loc.selector.begin_pos, compact_loc.selector.end_pos)

          add_offense(range) do |corrector|
            corrector.replace(map_node.loc.selector, 'filter_map')
            remove_compact_method(corrector, map_node, node, node.parent)
          end
        end
        alias on_csend on_send

        private

        def remove_compact_method(corrector, map_node, compact_node, chained_method)
          compact_method_range = compact_node.loc.selector

          if compact_node.multiline? && chained_method&.loc.respond_to?(:selector) && use_dot?(chained_method) &&
             !map_method_and_compact_method_on_same_line?(map_node, compact_node) &&
             !invoke_method_after_map_compact_on_same_line?(compact_node, chained_method)
            compact_method_range = compact_method_with_final_newline_range(compact_method_range)
          else
            corrector.remove(compact_node.loc.dot)
          end

          corrector.remove(compact_method_range)
        end

        def use_dot?(node)
          node.respond_to?(:dot?) && node.dot?
        end

        def map_method_and_compact_method_on_same_line?(map_node, compact_node)
          compact_node.loc.selector.line == map_node.loc.selector.line
        end

        def invoke_method_after_map_compact_on_same_line?(compact_node, chained_method)
          compact_node.loc.selector.line == chained_method.loc.last_line
        end

        def compact_method_with_final_newline_range(compact_method_range)
          range_by_whole_lines(compact_method_range, include_final_newline: true)
        end
      end
    end
  end
end