rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/mixin/check_line_breakable.rb

Summary

Maintainability
A
3 hrs
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    # This mixin detects collections that are safe to "break"
    # by inserting new lines. This is useful for breaking
    # up long lines.
    #
    # Let's look at hashes as an example:
    #
    # We know hash keys are safe to break across lines. We can add
    # linebreaks into hashes on lines longer than the specified maximum.
    # Then in further passes cops can clean up the multi-line hash.
    # For example, say the maximum line length is as indicated below:
    #
    #                                         |
    #                                         v
    # {foo: "0000000000", bar: "0000000000", baz: "0000000000"}
    #
    # In a LineLength autocorrection pass, a line is added before
    # the first key that exceeds the column limit:
    #
    # {foo: "0000000000", bar: "0000000000",
    # baz: "0000000000"}
    #
    # In a MultilineHashKeyLineBreaks pass, lines are inserted
    # before all keys:
    #
    # {foo: "0000000000",
    # bar: "0000000000",
    # baz: "0000000000"}
    #
    # Then in future passes FirstHashElementLineBreak,
    # MultilineHashBraceLayout, and TrailingCommaInHashLiteral will
    # manipulate as well until we get:
    #
    # {
    #   foo: "0000000000",
    #   bar: "0000000000",
    #   baz: "0000000000",
    # }
    #
    # (Note: Passes may not happen exactly in this sequence.)
    module CheckLineBreakable
      def extract_breakable_node(node, max)
        if node.send_type?
          args = process_args(node.arguments)
          return extract_breakable_node_from_elements(node, args, max)
        elsif node.def_type?
          return extract_breakable_node_from_elements(node, node.arguments, max)
        elsif node.array_type? || node.hash_type?
          return extract_breakable_node_from_elements(node, node.children, max)
        end
        nil
      end

      private

      # @api private
      def extract_breakable_node_from_elements(node, elements, max)
        return unless breakable_collection?(node, elements)
        return if safe_to_ignore?(node)

        line = processed_source.lines[node.first_line - 1]
        return if processed_source.line_with_comment?(node.loc.line)
        return if line.length <= max

        extract_first_element_over_column_limit(node, elements, max)
      end

      # @api private
      def extract_first_element_over_column_limit(node, elements, max)
        line = node.first_line

        # If a `send` node is not parenthesized, don't move the first element, because it
        # can result in changed behavior or a syntax error.
        if node.send_type? && !node.parenthesized? && !first_argument_is_heredoc?(node)
          elements = elements.drop(1)
        end

        i = 0
        i += 1 while within_column_limit?(elements[i], max, line)
        i = shift_elements_for_heredoc_arg(node, elements, i)

        return if i.nil?
        return elements.first if i.zero?

        elements[i - 1]
      end

      # @api private
      def first_argument_is_heredoc?(node)
        first_argument = node.first_argument

        first_argument.respond_to?(:heredoc?) && first_argument.heredoc?
      end

      # @api private
      # If a send node contains a heredoc argument, splitting cannot happen
      # after the heredoc or else it will cause a syntax error.
      def shift_elements_for_heredoc_arg(node, elements, index)
        return index unless node.send_type? || node.array_type?

        heredoc_index = elements.index { |arg| arg.respond_to?(:heredoc?) && arg.heredoc? }
        return index unless heredoc_index
        return nil if heredoc_index.zero?

        heredoc_index >= index ? index : heredoc_index + 1
      end

      # @api private
      def within_column_limit?(element, max, line)
        element && element.loc.column <= max && element.loc.line == line
      end

      # @api private
      def safe_to_ignore?(node)
        return true unless max
        return true if already_on_multiple_lines?(node)

        # If there's a containing breakable collection on the same
        # line, we let that one get broken first. In a separate pass,
        # this one might get broken as well, but to avoid conflicting
        # or redundant edits, we only mark one offense at a time.
        return true if contained_by_breakable_collection_on_same_line?(node)

        return true if contained_by_multiline_collection_that_could_be_broken_up?(node)

        false
      end

      # @api private
      def breakable_collection?(node, elements)
        # For simplicity we only want to insert breaks in normal
        # hashes wrapped in a set of curly braces like {foo: 1}.
        # That is, not a kwargs hash. For method calls, this ensures
        # the method call is made with parens.
        starts_with_bracket = !node.hash_type? || node.loc.begin

        # If the call has a second argument, we can insert a line
        # break before the second argument and the rest of the
        # argument will get auto-formatted onto separate lines
        # by other cops.
        has_second_element = elements.length >= 2

        starts_with_bracket && has_second_element
      end

      # @api private
      def contained_by_breakable_collection_on_same_line?(node)
        node.each_ancestor.find do |ancestor|
          # Ignore ancestors on different lines.
          break if ancestor.first_line != node.first_line

          if ancestor.hash_type? || ancestor.array_type?
            elements = ancestor.children
          elsif ancestor.send_type?
            elements = process_args(ancestor.arguments)
          else
            next
          end

          return true if breakable_collection?(ancestor, elements)
        end

        false
      end

      # @api private
      def contained_by_multiline_collection_that_could_be_broken_up?(node)
        node.each_ancestor.find do |ancestor|
          if (ancestor.hash_type? || ancestor.array_type?) &&
             breakable_collection?(ancestor, ancestor.children)
            return children_could_be_broken_up?(ancestor.children)
          end

          next unless ancestor.send_type?

          args = process_args(ancestor.arguments)
          return children_could_be_broken_up?(args) if breakable_collection?(ancestor, args)
        end

        false
      end

      # @api private
      def children_could_be_broken_up?(children)
        return false if all_on_same_line?(children)

        last_seen_line = -1
        children.each do |child|
          return true if last_seen_line >= child.first_line

          last_seen_line = child.last_line
        end
        false
      end

      # @api private
      def all_on_same_line?(nodes)
        return true if nodes.empty?

        nodes.first.first_line == nodes.last.last_line
      end

      # @api private
      def process_args(args)
        # If there is a trailing hash arg without explicit braces, like this:
        #
        #    method(1, 'key1' => value1, 'key2' => value2)
        #
        # ...then each key/value pair is treated as a method 'argument'
        # when determining where line breaks should appear.
        last_arg = args.last
        args = args[0...-1] + last_arg.children if last_arg&.hash_type? && !last_arg&.braces?
        args
      end

      # @api private
      def already_on_multiple_lines?(node)
        return node.first_line != node.last_argument.last_line if node.def_type?

        !node.single_line?
      end
    end
  end
end