rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/style/string_concatenation.rb

Summary

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

module RuboCop
  module Cop
    module Style
      # Checks for places where string concatenation
      # can be replaced with string interpolation.
      #
      # The cop can autocorrect simple cases but will skip autocorrecting
      # more complex cases where the resulting code would be harder to read.
      # In those cases, it might be useful to extract statements to local
      # variables or methods which you can then interpolate in a string.
      #
      # NOTE: When concatenation between two strings is broken over multiple
      # lines, this cop does not register an offense; instead,
      # `Style/LineEndConcatenation` will pick up the offense if enabled.
      #
      # Two modes are supported:
      # 1. `aggressive` style checks and corrects all occurrences of `+` where
      # either the left or right side of `+` is a string literal.
      # 2. `conservative` style on the other hand, checks and corrects only if
      # left side (receiver of `+` method call) is a string literal.
      # This is useful when the receiver is some expression that returns string like `Pathname`
      # instead of a string literal.
      #
      # @safety
      #   This cop is unsafe in `aggressive` mode, as it cannot be guaranteed that
      #   the receiver is actually a string, which can result in a false positive.
      #
      # @example Mode: aggressive (default)
      #   # bad
      #   email_with_name = user.name + ' <' + user.email + '>'
      #   Pathname.new('/') + 'test'
      #
      #   # good
      #   email_with_name = "#{user.name} <#{user.email}>"
      #   email_with_name = format('%s <%s>', user.name, user.email)
      #   "#{Pathname.new('/')}test"
      #
      #   # accepted, line-end concatenation
      #   name = 'First' +
      #     'Last'
      #
      # @example Mode: conservative
      #   # bad
      #   'Hello' + user.name
      #
      #   # good
      #   "Hello #{user.name}"
      #   user.name + '!!'
      #   Pathname.new('/') + 'test'
      #
      class StringConcatenation < Base
        include Util
        include RangeHelp
        extend AutoCorrector

        MSG = 'Prefer string interpolation to string concatenation.'
        RESTRICT_ON_SEND = %i[+].freeze

        # @!method string_concatenation?(node)
        def_node_matcher :string_concatenation?, <<~PATTERN
          {
            (send str_type? :+ _)
            (send _ :+ str_type?)
          }
        PATTERN

        def on_new_investigation
          @corrected_nodes = nil
        end

        def on_send(node)
          return unless string_concatenation?(node)
          return if line_end_concatenation?(node)

          topmost_plus_node = find_topmost_plus_node(node)
          parts = collect_parts(topmost_plus_node)
          return if mode == :conservative && !parts.first.str_type?

          register_offense(topmost_plus_node, parts)
        end

        private

        def register_offense(topmost_plus_node, parts)
          add_offense(topmost_plus_node) do |corrector|
            correctable_parts = parts.none? { |part| uncorrectable?(part) }
            if correctable_parts && !corrected_ancestor?(topmost_plus_node)
              corrector.replace(topmost_plus_node, replacement(parts))

              @corrected_nodes ||= Set.new.compare_by_identity
              @corrected_nodes.add(topmost_plus_node)
            end
          end
        end

        def line_end_concatenation?(node)
          # If the concatenation happens at the end of the line,
          # and both the receiver and argument are strings, allow
          # `Style/LineEndConcatenation` to handle it instead.
          node.receiver.str_type? &&
            node.first_argument.str_type? &&
            node.multiline? &&
            node.source =~ /\+\s*\n/
        end

        def find_topmost_plus_node(node)
          current = node
          while (parent = current.parent) && plus_node?(parent)
            current = parent
          end
          current
        end

        def collect_parts(node, parts = [])
          return unless node

          if plus_node?(node)
            collect_parts(node.receiver, parts)
            collect_parts(node.first_argument, parts)
          else
            parts << node
          end
        end

        def plus_node?(node)
          node.send_type? && node.method?(:+)
        end

        def uncorrectable?(part)
          part.multiline? || heredoc?(part) || part.each_descendant(:block).any?
        end

        def heredoc?(node)
          return false unless node.str_type? || node.dstr_type?

          node.heredoc?
        end

        def corrected_ancestor?(node)
          node.each_ancestor(:send).any? { |ancestor| @corrected_nodes&.include?(ancestor) }
        end

        def replacement(parts)
          interpolated_parts =
            parts.map do |part|
              case part.type
              when :str
                value = part.value
                single_quoted?(part) ? value.gsub(/(\\|")/, '\\\\\&') : value.inspect[1..-2]
              when :dstr
                contents_range(part).source
              else
                "\#{#{part.source}}"
              end
            end

          "\"#{handle_quotes(interpolated_parts).join}\""
        end

        def handle_quotes(parts)
          parts.map do |part|
            part == '"' ? '\"' : part
          end
        end

        def single_quoted?(str_node)
          str_node.source.start_with?("'")
        end

        def mode
          cop_config['Mode'].to_sym
        end
      end
    end
  end
end