sds/haml-lint

View on GitHub
lib/haml_lint/ruby_extraction/chunk_extractor.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

# rubocop:disable Metrics
module HamlLint::RubyExtraction
  # Extracts "chunks" of the haml file into instances of subclasses of HamlLint::RubyExtraction::BaseChunk.
  #
  # This is the first step of generating Ruby code from a HAML file to then be processed by RuboCop.
  # See HamlLint::RubyExtraction::BaseChunk for more details.
  class ChunkExtractor
    include HamlLint::HamlVisitor

    attr_reader :script_output_prefix

    HAML_PARSER_INSTANCE = if Haml::VERSION >= '5.0.0'
                             ::Haml::Parser.new({})
                           else
                             ::Haml::Parser.new('', {})
                           end

    # HAML strips newlines when handling multi-line statements (using pipes or trailing comma)
    # We don't. So the regex must be fixed to correctly detect the start of the string.
    BLOCK_KEYWORD_REGEX = Regexp.new(Haml::Parser::BLOCK_KEYWORD_REGEX.source.sub('^', '\A'))

    def initialize(document, script_output_prefix:)
      @document = document
      @script_output_prefix = script_output_prefix
    end

    def extract
      raise 'Already extracted' if @ruby_chunks

      prepare_extract

      visit(@document.tree)
      @ruby_chunks
    end

    # Useful for tests
    def prepare_extract
      @ruby_chunks = []
      @original_haml_lines = @document.source_lines
    end

    def visit_root(_node)
      yield # Collect lines of code from children
    end

    # Visiting lines like `  Some raw text to output`
    def visit_plain(node)
      indent = @original_haml_lines[node.line - 1].index(/\S/)
      @ruby_chunks << PlaceholderMarkerChunk.new(node, 'plain', indent: indent)
    end

    # Visiting lines like `  -# Some commenting!`
    def visit_haml_comment(node)
      # We want to preserve leading whitespace if it exists, but add a leading
      # whitespace if it doesn't exist so that RuboCop's LeadingCommentSpace
      # doesn't complain
      line_index = node.line - 1
      lines = @original_haml_lines[line_index..(line_index + node.text.count("\n"))].dup
      indent = lines.first.index(/\S/)
      # Remove only the -, the # will align with regular code
      #  -# comment
      #  - foo()
      # becomes
      #  # comment
      #  foo()
      lines[0] = lines[0].sub('-', '')

      # Adding a space before the comment if its missing
      # We can't fix those, so make sure not to generate warnings for them.
      lines[0] = lines[0].sub(/\A(\s*)#(\S)/, '\\1# \\2')

      HamlLint::Utils.map_after_first!(lines) do |line|
        # Since the indent/spaces of the extra line comments isn't exactly in the haml,
        # it's not RuboCop's job to fix indentation, so just make a reasonable indentation
        # to avoid offenses.
        ' ' * indent + line.sub(/^\s*/, '# ').rstrip
      end

      # Using Placeholder instead of script because we can't revert back to the
      # exact original comment since multiple syntax lead to the exact same comment.
      @ruby_chunks << HamlCommentChunk.new(node, lines, end_marker_indent: indent)
    end

    # Visiting comments which are output to HTML. Lines looking like
    #   `  / This will be in the HTML source!`
    def visit_comment(node)
      line = @original_haml_lines[node.line - 1]
      indent = line.index(/\S/)

      @ruby_chunks << PlaceholderMarkerChunk.new(node, 'comment', indent: indent)

      # Comment can have subnodes, such as plain. This happens for conditional comments, such as:
      # %head
      #   /[if mso]
      #     %div
      if node.children
        # We don't want to use a block because assignments in a block are local to that block,
        # so the semantics of the extracted ruby would be different from the one generated by
        # Haml. Those differences can make some cops, such as UselessAssignment, have false
        # positives
        begin_chunk = AdHocChunk.new(node, [' ' * indent + 'begin'])
        @ruby_chunks << begin_chunk
        indent += 2

        yield

        indent -= 2

        if @ruby_chunks.last.equal?(begin_chunk)
          # So there is nothing nesting, remove the wrapping "begin"
          @ruby_chunks.pop
        else
          @ruby_chunks << AdHocChunk.new(node,
                                         [' ' * indent + 'ensure', ' ' * indent + '  HL.noop', ' ' * indent + 'end'],
                                         haml_line_index: @ruby_chunks.last.haml_end_line_index)
        end
      end
    end

    # Visit a script which outputs. Lines looking like `  = foo`
    def visit_script(node, &block)
      raw_first_line = @original_haml_lines[node.line - 1]

      # ==, !, !==, &, &== means interpolation (was needed before HAML 2.2... it's still supported)
      # =, !=, &= mean actual ruby code is coming
      # Anything else is interpolation
      # The regex lists the case for Ruby Code. The 3 cases and making sure they are not followed by another = sign

      match = raw_first_line.match(/\A\s*(=|!=|&=)(?!=)/)
      unless match
        # The line doesn't start with a - or a =, this is actually a "plain"
        # that contains interpolation.
        indent = raw_first_line.index(/\S/)
        @ruby_chunks << PlaceholderMarkerChunk.new(node, 'interpolation', indent: indent)
        lines = extract_piped_plain_multilines(node.line - 1)
        add_interpolation_chunks(node, lines.join("\n"), node.line - 1, indent: indent)
        return
      end

      script_prefix = match[1]
      _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
      # We want the actual indentation and prefix for the first line
      first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
      process_multiline!(first_line)

      lines[0] = lines[0].sub(/(#{script_prefix}[ \t]?)/, '')
      line_indentation = Regexp.last_match(1).size

      raw_code = lines.join("\n")

      if lines[0][/\S/] == '#'
        # a "=" script that only contains a comment... No need for the "HL.out = " prefix,
        # just treat it as comment which will turn into a "-" comment
      else
        lines[0] = HamlLint::Utils.insert_after_indentation(lines[0], script_output_prefix)
      end

      indent_delta = script_output_prefix.size - line_indentation
      HamlLint::Utils.map_after_first!(lines) do |line|
        HamlLint::Utils.indent(line, indent_delta)
      end

      prev_chunk = @ruby_chunks.last
      if prev_chunk.is_a?(ScriptChunk) &&
          prev_chunk.node.type == :script &&
          prev_chunk.node == node.parent
        # When an outputting script is nested under another outputting script,
        # we want to block them from being merged together by rubocop, because
        # this doesn't make sense in HAML.
        # Example:
        #   = if this_is_short
        #     = this_is_short_too
        # Could become (after RuboCop):
        #   HL.out = (HL.out = this_is_short_too if this_is_short)
        # Or in (broken) HAML style:
        #   = this_is_short_too = if this_is_short
        # By forcing this to start a chunk, there will be extra placeholders which
        # blocks rubocop from merging the lines.
        must_start_chunk = true
      elsif script_prefix != '='
        # In the few cases where &= and != are used to start the script,
        # We need to remember and put it back in the final HAML. Fusing scripts together
        # would make that basically impossible. Instead, a script has a "first_output_prefix"
        # field for this specific case
        must_start_chunk = true
      end

      finish_visit_any_script(node, lines, raw_code: raw_code, must_start_chunk: must_start_chunk,
                              first_output_prefix: script_prefix, &block)
    end

    # Visit a script which doesn't output. Lines looking like `  - foo`
    def visit_silent_script(node, &block)
      _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
      # We want the actual indentation and prefix for the first line
      first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
      process_multiline!(first_line)

      lines[0] = lines[0].sub(/(-[ \t]?)/, '')
      nb_to_deindent = Regexp.last_match(1).size

      HamlLint::Utils.map_after_first!(lines) do |line|
        line.sub(/^ {1,#{nb_to_deindent}}/, '')
      end

      finish_visit_any_script(node, lines, &block)
    end

    # Code common to both silent and outputting scripts
    #
    # raw_code is the code before we do transformations, such as adding the `HL.out = `
    def finish_visit_any_script(node, lines, raw_code: nil, must_start_chunk: false, first_output_prefix: '=')
      raw_code ||= lines.join("\n")
      start_nesting = self.class.start_nesting_after?(raw_code)

      lines = add_following_empty_lines(node, lines)

      my_indent = lines.first.index(/\S/)
      indent_after = indent_after_line_index(node.line - 1 + lines.size - 1) || 0
      indent_after = [my_indent, indent_after].max

      @ruby_chunks << ScriptChunk.new(node, lines,
                                      end_marker_indent: indent_after,
                                      must_start_chunk: must_start_chunk,
                                      previous_chunk: @ruby_chunks.last,
                                      first_output_haml_prefix: first_output_prefix)

      yield

      if start_nesting
        if node.children.empty?
          raise "Line #{node.line} should be followed by indentation. This might actually" \
                " work in Haml, but it's almost a bug that it does. haml-lint cannot process."
        end

        last_child = node.children.last
        if last_child.is_a?(HamlLint::Tree::SilentScriptNode) && last_child.keyword == 'end'
          # This is allowed in Haml 5, gotta handle it!
          # No need for the implicit end chunk since there is an explicit one
        else
          @ruby_chunks << ImplicitEndChunk.new(node, [' ' * my_indent + 'end'],
                                               haml_line_index: @ruby_chunks.last.haml_end_line_index,
                                               end_marker_indent: my_indent)
        end
      end
    end

    # Visiting a tag. Lines looking like `  %div`
    def visit_tag(node)
      indent = @original_haml_lines[node.line - 1].index(/\S/)

      @ruby_chunks << PlaceholderMarkerChunk.new(node, 'tag', indent: indent)

      current_line_index = visit_tag_attributes(node, indent: indent)
      visit_tag_script(node, line_index: current_line_index, indent: indent)

      # We don't want to use a block because assignments in a block are local to that block,
      # so the semantics of the extracted ruby would be different from the one generated by
      # Haml. Those differences can make some cops, such as UselessAssignment, have false
      # positives
      code = 'begin'
      begin_chunk = AdHocChunk.new(node, [' ' * indent + code])
      @ruby_chunks << begin_chunk

      indent += 2

      yield

      indent -= 2

      if @ruby_chunks.last.equal?(begin_chunk)
        # So there is nothing going "in" the tag, remove the wrapping "begin" and replace the PlaceholderMarkerChunk
        # by one less indented
        @ruby_chunks.pop
      else
        @ruby_chunks << AdHocChunk.new(node,
                                       [' ' * indent + 'ensure', ' ' * indent + '  HL.noop', ' ' * indent + 'end'],
                                       haml_line_index: @ruby_chunks.last.haml_end_line_index)
      end
    end

    # (Called manually form visit_tag)
    # Visiting the attributes of a tag. Lots of different examples below in the code.
    # A common syntax is: `%div{style: 'yes_please'}`
    #
    # Returns the new line_index we reached, useful to handle the script that follows
    def visit_tag_attributes(node, indent:)
      final_line_index = node.line - 1
      additional_attributes = node.dynamic_attributes_sources

      attributes_code = additional_attributes.first
      if !attributes_code && node.hash_attributes? && node.dynamic_attributes_sources.empty?
        # No idea why .foo{:bar => 123} doesn't get here, but .foo{:bar => '123'} does...
        # The code we get for the latter is {:bar => '123'}.
        # We normalize it by removing the { } so that it matches wha we normally get
        attributes_code = node.dynamic_attributes_source[:hash][1...-1]
      end

      if attributes_code&.start_with?('{')
        # Looks like the .foo(bar = 123) case. Ignoring.
        attributes_code = nil
      end

      return final_line_index unless attributes_code
      # Attributes have different ways to be given to us:
      #   .foo{bar: 123} => "bar: 123"
      #   .foo{:bar => 123} => ":bar => 123"
      #   .foo{:bar => '123'} => "{:bar => '123'}" # No idea why this is different
      #   .foo(bar = 123) => '{"bar" => 123,}'
      #   .foo{html_attrs('fr-fr')} => html_attrs('fr-fr')
      #
      # The (bar = 123) case is extra painful to autocorrect (so is ignored up there).
      # #raw_ruby_from_haml  will "detect" this case by not finding the code.
      #
      # We wrap the result in a method to have a valid syntax for all 3 ways
      # without having to differentiate them.
      first_line_offset, raw_attributes_lines = extract_raw_tag_attributes_ruby_lines(attributes_code,
                                                                                      node.line - 1)
      return final_line_index unless raw_attributes_lines

      final_line_index += raw_attributes_lines.size - 1

      # Since .foo{bar: 123} => "bar: 123" needs wrapping (Or it would be a syntax error) and
      # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr') doesn't care about being
      # wrapped, we always wrap to place them to a similar offset to how they are in the haml.
      wrap_by = first_line_offset - indent
      if wrap_by < 2
        # Need 2 minimum, for "W(". If we have less, we must indent everything for the difference
        extra_indent = 2 - wrap_by
        HamlLint::Utils.map_after_first!(raw_attributes_lines) do |line|
          HamlLint::Utils.indent(line, extra_indent)
        end
        wrap_by = 2
      end
      raw_attributes_lines = wrap_lines(raw_attributes_lines, wrap_by)
      raw_attributes_lines[0] = ' ' * indent + raw_attributes_lines[0]

      @ruby_chunks << TagAttributesChunk.new(node, raw_attributes_lines,
                                             end_marker_indent: indent,
                                             indent_to_remove: extra_indent)

      final_line_index
    end

    # Visiting the script besides tag. The part to the right of the equal sign of
    # lines looking like `  %div= foo(bar)`
    def visit_tag_script(node, line_index:, indent:)
      return if node.script.nil? || node.script.empty?
      # We ignore scripts which are just a comment
      return if node.script[/\S/] == '#'

      first_line_offset, script_lines = extract_raw_ruby_lines(node.script, line_index)

      if script_lines.nil?
        # This is a string with interpolation after a tag
        # ex: %tag hello #{world}
        # Sadly, the text with interpolation is escaped from the original, but this code
        # needs the original.

        interpolation_original = @document.unescape_interpolation_to_original_cache[node.script]
        line_start_index = @original_haml_lines[node.line - 1].rindex(interpolation_original)
        if line_start_index.nil?
          raw_lines = extract_piped_plain_multilines(node.line - 1)
          equivalent_haml_code = "#{raw_lines.first} #{raw_lines[1..].map(&:lstrip).join(' ')}"
          line_start_index = equivalent_haml_code.rindex(interpolation_original)

          interpolation_original = raw_lines.join("\n")
        end
        add_interpolation_chunks(node, interpolation_original, node.line - 1,
                                 line_start_index: line_start_index, indent: indent)
      else
        script_lines[0] = "#{' ' * indent}#{script_output_prefix}#{script_lines[0]}"
        indent_delta = script_output_prefix.size - first_line_offset + indent
        HamlLint::Utils.map_after_first!(script_lines) do |line|
          HamlLint::Utils.indent(line, indent_delta)
        end

        @ruby_chunks << TagScriptChunk.new(node, script_lines,
                                           haml_line_index: line_index,
                                           end_marker_indent: indent)
      end
    end

    # Visiting a HAML filter. Lines looking like `  :javascript` and the following lines
    # that are nested.
    def visit_filter(node)
      # For unknown reasons, haml doesn't escape interpolations in filters.
      # So we can rely on \n to split / get the number of lines.
      filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
      if node.filter_type == 'ruby'
        # The indentation in node.text is normalized, so that at least one line
        # is indented by 0.
        lines = node.text.split("\n")
        lines.map! do |line|
          if !/\S/.match?(line)
            # whitespace or empty
            ''
          else
            ' ' * filter_name_indent + line
          end
        end

        @ruby_chunks << RubyFilterChunk.new(node, lines,
                                            haml_line_index: node.line, # it's one the next line, no need for -1
                                            start_marker_indent: filter_name_indent,
                                            end_marker_indent: filter_name_indent)
      elsif node.text.include?('#')
        name_indentation = ' ' * @original_haml_lines[node.line - 1].index(/\S/)
        # TODO: HAML_LINT_FILTER could be in the string and mess things up
        lines = ["#{name_indentation}#{script_output_prefix}<<~HAML_LINT_FILTER"]
        lines.concat @original_haml_lines[node.line..(node.line + node.text.count("\n") - 1)]
        lines << "#{name_indentation}HAML_LINT_FILTER"
        @ruby_chunks << NonRubyFilterChunk.new(node, lines,
                                               end_marker_indent: filter_name_indent)
      # Those could be interpolation. We treat them as a here-doc, which is nice since we can
      # keep the indentation as-is.
      else
        @ruby_chunks << PlaceholderMarkerChunk.new(node, 'filter', indent: filter_name_indent,
                                                   nb_lines: 1 + node.text.count("\n"))
      end
    end

    # Adds chunks for the interpolation within the given code
    def add_interpolation_chunks(node, code, haml_line_index, indent:, line_start_index: 0)
      HamlLint::Utils.handle_interpolation_with_indexes(code) do |scanner, line_index, line_char_index|
        escapes = scanner[2].size
        next if escapes.odd?
        char = scanner[3] # '{', '@' or '$'
        if Gem::Version.new(Haml::VERSION) >= Gem::Version.new('5') && (char != '{')
          # Before Haml 5, scanner didn't have a scanner[3], it only handled `#{}`
          next
        end

        line_start_char_index = line_char_index
        line_start_char_index += line_start_index if line_index == 0
        code_start_char_index = scanner.charpos

        # This moves the scanner
        Haml::Util.balance(scanner, '{', '}', 1)

        # Need to manually get the code now that we have positions so that all whitespace is present,
        # because Haml::Util.balance does a strip...
        interpolated_code = code[code_start_char_index...scanner.charpos - 1]

        if interpolated_code.include?("\n")
          # We can't correct multiline interpolation.
          # Finding meaningful code to generate and then transfer back is pretty complex

          # Since we can't fix it, strip around the code to reduce RuboCop lints that we won't be able to fix.
          interpolated_code = interpolated_code.strip
          interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"

          placeholder_code = interpolated_code.gsub(/\s*\n\s*/, ' ').rstrip
          unless parse_ruby(placeholder_code)
            placeholder_code = interpolated_code.gsub(/\s*\n\s*/, '; ').rstrip
          end
          @ruby_chunks << AdHocChunk.new(node, [placeholder_code],
                                         haml_line_index: haml_line_index + line_index)
        else
          interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"
          @ruby_chunks << InterpolationChunk.new(node, [interpolated_code],
                                                 haml_line_index: haml_line_index + line_index,
                                                 start_char_index: line_start_char_index,
                                                 end_marker_indent: indent)
        end
      end
    end

    def process_multiline!(line)
      if HAML_PARSER_INSTANCE.send(:is_multiline?, line)
        line.chop!.rstrip!
        true
      else
        false
      end
    end

    def process_plain_multiline!(line)
      if line&.end_with?(' |')
        line[-2..] = ''
        true
      else
        false
      end
    end

    # Returns the raw lines from the haml for the given index.
    # Multiple lines are returned when a line ends with a comma as that is the only
    # time HAMLs allows Ruby lines to be split.

    # Haml's line-splitting rules (allowed after comma in scripts and attributes) are handled
    # at the parser level, so Haml doesn't provide the code as it is actually formatted in the Haml
    # file. #raw_ruby_from_haml extracts the ruby code as it is exactly in the Haml file.
    # The first and last lines may not be the complete lines from the Haml, only the Ruby parts
    # and the indentation between the first and last list.

    # HAML transforms the ruby code in many ways as it parses a document. Often removing lines and/or
    # indentation. This is quite annoying for us since we want the exact layout of the code to analyze it.
    #
    # This function receives the code as haml provides it and the line where it starts. It returns
    # the actual code as it is in the haml file, keeping breaks and indentation for the following lines.
    # In addition, the start position of the code in the first line.
    #
    # The rules for handling multiline code in HAML are as follow:
    # * if the line being processed ends with a space and a pipe, then append to the line (without
    #   newlines) every following lines that also end with a space and a pipe. This means the last line of
    #   the "block" also needs a pipe at the end.
    # * after processing the pipes, when dealing with ruby code (and not in tag attributes' hash), if the line
    #   (which maybe span across multiple lines) ends with a comma, add the next line to the current piece of code.
    #
    # @return [first_line_offset, ruby_lines]
    def extract_raw_ruby_lines(haml_processed_ruby_code, first_line_index)
      haml_processed_ruby_code = haml_processed_ruby_code.strip
      first_line = @original_haml_lines[first_line_index]

      char_index = first_line.index(haml_processed_ruby_code)

      if char_index
        return [char_index, [haml_processed_ruby_code]]
      end

      cur_line_index = first_line_index
      cur_line = first_line.rstrip
      lines = []

      # The pipes must also be on the last line of the multi-line section
      while cur_line && process_multiline!(cur_line)
        lines << cur_line
        cur_line_index += 1
        cur_line = @original_haml_lines[cur_line_index].rstrip
      end

      if lines.empty?
        lines << cur_line
      else
        # The pipes must also be on the last line of the multi-line section. So cur_line is not the next line.
        # We want to go back to check for commas
        cur_line_index -= 1
        cur_line = lines.last
      end

      while HAML_PARSER_INSTANCE.send(:is_ruby_multiline?, cur_line)
        cur_line_index += 1
        cur_line = @original_haml_lines[cur_line_index].rstrip
        lines << cur_line
      end

      joined_lines = lines.join("\n")

      if haml_processed_ruby_code.include?("\n")
        haml_processed_ruby_code = haml_processed_ruby_code.tr("\n", ' ')
      end

      haml_processed_ruby_code.split(/[, ]/)

      regexp = HamlLint::Utils.regexp_for_parts(haml_processed_ruby_code.split(/,\s*|\s+/), '(?:,\\s*|\\s+)')

      match = joined_lines.match(regexp)
      # This can happen when pipes are used as marker for multiline parts, and when tag attributes change lines
      # without ending by a comma. This is quite a can of worm and is probably not too frequent, so for now,
      # these cases are not supported.
      return if match.nil?

      raw_ruby = match[0]
      ruby_lines = raw_ruby.split("\n")
      first_line_offset = match.begin(0)

      [first_line_offset, ruby_lines]
    end

    def extract_piped_plain_multilines(first_line_index)
      lines = []

      cur_line = @original_haml_lines[first_line_index].rstrip
      cur_line_index = first_line_index

      # The pipes must also be on the last line of the multi-line section
      while cur_line && process_plain_multiline!(cur_line)
        lines << cur_line
        cur_line_index += 1
        cur_line = @original_haml_lines[cur_line_index].rstrip
      end

      if lines.empty?
        lines << cur_line
      end
      lines
    end

    # Tag attributes actually handle multiline differently than scripts.
    # The basic system basically keeps considering more lines until it meets the closing braces, but still
    # processes pipes too (same as extract_raw_ruby_lines).
    def extract_raw_tag_attributes_ruby_lines(haml_processed_ruby_code, first_line_index)
      haml_processed_ruby_code = haml_processed_ruby_code.strip
      first_line = @original_haml_lines[first_line_index]

      char_index = first_line.index(haml_processed_ruby_code)

      if char_index
        return [char_index, [haml_processed_ruby_code]]
      end

      # The +1 is for the closing brace, which we need
      min_non_white_chars_to_add = haml_processed_ruby_code.scan(/\S/).size + 1

      regexp = HamlLint::Utils.regexp_for_parts(
        haml_processed_ruby_code.split(/\s+/), '\\s+', prefix: '\s*', suffix: '\s*(?=[)}])'
      )

      joined_lines = first_line.rstrip
      process_multiline!(joined_lines)

      cur_line_index = first_line_index + 1
      while @original_haml_lines[cur_line_index] && min_non_white_chars_to_add > 0
        new_line = @original_haml_lines[cur_line_index].rstrip
        process_multiline!(new_line)

        min_non_white_chars_to_add -= new_line.scan(/\S/).size
        joined_lines << "\n"
        joined_lines << new_line
        cur_line_index += 1
      end

      match = joined_lines.match(regexp)

      return if match.nil?

      first_line_offset = match.begin(0)
      raw_ruby = match[0]
      ruby_lines = raw_ruby.split("\n", -1)

      [first_line_offset, ruby_lines]
    end

    def wrap_lines(lines, wrap_depth)
      lines = lines.dup
      wrapping_prefix = 'W' * (wrap_depth - 1) + '('
      lines[0] = wrapping_prefix + lines[0]
      lines[-1] = lines[-1] + ')'
      lines
    end

    # Adds empty lines that follow the lines (Used for scripts), so that
    # RuboCop can receive them too. Some cops are sensitive to empty lines.
    def add_following_empty_lines(node, lines)
      first_line_index = node.line - 1 + lines.size
      extra_lines = []

      extra_lines << '' while HamlLint::Utils.is_blank_line?(@original_haml_lines[first_line_index + extra_lines.size])

      if @original_haml_lines[first_line_index + extra_lines.size].nil?
        # Since we reached the end of the document without finding content,
        # then we don't add those lines.
        return lines
      end

      lines + extra_lines
    end

    def parse_ruby(source)
      @ruby_parser ||= HamlLint::RubyParser.new
      @ruby_parser.parse(source)
    end

    def indent_after_line_index(line_index)
      (line_index + 1..@original_haml_lines.size - 1).each do |i|
        indent = @original_haml_lines[i].index(/\S/)
        return indent if indent
      end
      nil
    end

    def self.start_nesting_after?(code)
      anonymous_block?(code) || start_block_keyword?(code)
    end

    def self.anonymous_block?(code)
      # Don't start with a comment and end with a `do`
      # Definitely not perfect for the comment handling, but otherwise a more advanced parsing system is needed.
      # Move the comment to its own line if it's annoying.
      code !~ /\A\s*#/ &&
        code =~ /\bdo\s*(\|[^|]*\|\s*)?(#.*)?\z/
    end

    START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze
    def self.start_block_keyword?(code)
      START_BLOCK_KEYWORDS.include?(block_keyword(code))
    end

    LOOP_KEYWORDS = %w[for until while].freeze
    def self.block_keyword(code)
      # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't
      if (keyword = code[/\A\s*([^\s]+)\s+/, 1]) && LOOP_KEYWORDS.include?(keyword)
        return keyword
      end

      return unless keyword = code.scan(BLOCK_KEYWORD_REGEX)[0]
      keyword[0] || keyword[1]
    end
  end
end

# rubocop:enable Metrics