lib/rubocop/cop/mixin/check_line_breakable.rb
# 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