# frozen_string_literal: true module RuboCop module Cop module Style # This cop 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 unless parts[0..-2].any? { |receiver_node| offensive_for_mode?(receiver_node) } 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 offensive_for_mode?(receiver_node) mode = cop_config['Mode'].to_sym mode == :aggressive || (mode == :conservative && receiver_node.str_type?) 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? || (part.str_type? && part.heredoc?) || part.each_descendant(:block).any? 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 end end end end