lib/scaffolding/block_manipulator.rb



require "scaffolding/file_manipulator"

module Scaffolding
  class BlockManipulator
    class << self
      #
      # Wrap a block of ruby code with another block on the outside.
      #
      # @param [String] `starting` A string to search for at the start of the block. Eg "<%= cable_ready_updates_for context, collection do"
      # @param [Array] `with` An array with two String elements. The text that should wrap the block. Eg ["<%= action_model_select_controller do %>", "<% end %>"]
      #
      def wrap_block(starting:, with:, lines:)
        with[0] += "\n" unless with[0].match?(/\n$/)
        with[1] += "\n" unless with[1].match?(/\n$/)
        starting_line = find_block_start(starting_from: starting, lines: lines)
        end_line = find_block_end(starting_from: starting_line, lines: lines)

        final = []
        block_indent = ""
        spacer = "  "
        lines.each_with_index do |line, index|
          line += "\n" unless line.match?(/\n$/)
          if index < starting_line
            final << line
          elsif index == starting_line
            block_indent = line.match(/^\s*/).to_s
            final << block_indent + with[0]
            final << (line.blank? ? "\n" : "#{spacer}#{line}")
          elsif index > starting_line && index < end_line
            final << (line.blank? ? "\n" : "#{spacer}#{line}")
          elsif index == end_line
            final << (line.blank? ? "\n" : "#{spacer}#{line}")
            final << block_indent + with[1]
          else
            final << line
          end
        end

        lines = final
        unless lines.last.match?(/\n$/)
          lines[-1] += "\n"
        end
        lines
      end

      # This method unwraps the block from the perspective of the child.
      #
      # 2.times do
      #   3.times do
      #     puts "foo"
      #   end
      # end
      #
      # Here we would pass the index of `"3.times do\n"` to
      # `block_start` which would result in removing the outer block.
      def unwrap_block(lines:, block_start:)
        block_start = if block_start.is_a? String
          block_start_line = lines.find { |line| line.match?(block_start) }
          lines.index(block_start_line)
        end

        # Find the proper indices for both child and parent blocks.
        block_parent_start = find_block_parent(block_start, lines)
        block_parent_end = find_block_end(starting_from: block_parent_start, lines: lines)

        new_lines = shift_block(lines: lines, block_start: block_start)
        new_lines.reject.with_index { |lines, idx| idx == block_parent_start || idx == block_parent_end }
      end

      def insert(content, lines:, within: nil, after: nil, before: nil, after_block: nil, append: false)
        content = prepare_content_array(content)

        # We initialize the search with the entire file's lines and look for the block below.
        start_line = 0
        end_line = lines.count - 1

        # Search for before like we do after, we'll just inject before it.
        after ||= before

        # If within is given, find the start and end lines of the block
        if within.present?
          start_line = find_block_start(starting_from: within, lines: lines)
          end_line = find_block_end(starting_from: start_line, lines: lines)
          # start_line += 1 # ensure we actually insert the content _within_ the given block
          # end_line += 1 if end_line == start_line
        end

        if after_block.present?
          block_start = find_block_start(starting_from: after_block, lines: lines)
          block_end = find_block_end(starting_from: block_start, lines: lines)
          start_line = block_end
          end_line = lines.count - 1
        end

        index = start_line
        match = false
        while index < end_line && !match
          line = lines[index]
          if after.nil? || line.match?(after)
            unless append
              match = true
              indent = !(before.present? || after.present? || after_block.present?)

              # We adjust the injection point if we really wanted to insert before.
              lines = insert_lines(content, index - (before ? 1 : 0), lines, indent)
            end
          end
          index += 1
        end

        return lines if match

        # Match should always be false here.
        if append && !match
          lines = insert_lines(content, index - 1, lines)
        end
        lines
      end

      def insert_lines(content, insert_at_index, lines, indent)
        final = []
        lines.each_with_index do |line, index|
          indentation = line.match(/^\s*/).to_s
          indentation += "\s" * 2 if indent

          final << line
          content.each { |new_line| final << indentation + new_line } if index == insert_at_index
        end
        final
      end

      # TODO: We should eventually replace this with `insert_lines``,
      # I just want to make sure everything doesn't break first.
      def insert_line(content, insert_at_index, lines, indent = true)
        insert_lines(prepare_content_array(content), insert_at_index, lines, indent)
      end

      def insert_block(block_content, after_block:, lines:)
        # Since `after_block` must be present for this method to work,
        # the assumption is we never inseart a block inside an empty block, but
        # always after the end of one. For that reason, ident defaults to false.
        indent = false
        block_start = find_block_start(starting_from: after_block, lines: lines)
        block_end = find_block_end(starting_from: block_start, lines: lines)
        lines = insert_line(block_content[0], block_end, lines, indent)
        insert_line(block_content[1], block_end + 1, lines, indent)
      end

      def find_block_parent(starting_line_number, lines)
        return nil unless indentation_of(starting_line_number, lines)
        cursor = starting_line_number
        while cursor >= 0
          unless lines[cursor].match?(/^#{indentation_of(starting_line_number, lines)}/) || !lines[cursor].present?
            return cursor
          end
          cursor -= 1
        end
        nil
      end

      def find_block_start(starting_from:, lines:)
        matcher = Regexp.escape(starting_from)
        starting_line = 0

        lines.each_with_index do |line, index|
          if line.match?(matcher)
            starting_line = index
            break
          end
        end
        starting_line
      end

      def find_block_end(starting_from:, lines:)
        # This loop was previously in the RoutesFileManipulator.
        lines.each_with_index do |line, line_number|
          next unless line_number > starting_from
          if /^#{indentation_of(starting_from, lines)}end\s*/.match?(line)
            return line_number
          end
        end

        depth = 0
        current_line = starting_from
        lines[starting_from..lines.count].each_with_index do |line, index|
          current_line = starting_from + index
          depth += 1 if line.match?(/\s*<%.+ do .*%>/)
          depth += 1 if line.match?(/\s*<% if .*%>/)
          depth += 1 if line.match?(/\s*<% unless .*%>/)
          depth -= 1 if line.match?(/\s*<%.* end .*%>/)
          break current_line if depth == 0
        end
        current_line
      end

      # TODO: We shouldn't need this second argument, but since
      # we have `lines` here and in the RoutesFileManipulator,
      # the lines diverge from one another when we edit them individually.
      def indentation_of(line_number, lines)
        lines[line_number].match(/^( +)/)[1]
      rescue
        nil
      end

      # Shifts the block either to the left or right.
      def shift_block(lines:, block_start:, direction: :left, amount: 2, shift_contents_only: false)
        block_start = lines.index(block_start) if block_start.is_a? String
        block_range = (block_start..(find_block_end(starting_from: block_start, lines: lines)))
        block_range = (block_range.first + 1)..(block_range.last - 1) if shift_contents_only
        new_lines = []

        lines.each_with_index do |line, line_number|
          if block_range.cover?(line_number)
            # If we're shifting a block to the left, we want to safeguard
            # the String so it doesn't delete any excess characters.
            if direction == :left
              amount.times { line = line.gsub(/^ /, "") }
            elsif direction == :right
              line = "\s" * amount + line
            end
          end
          new_lines << line
        end

        new_lines
      end

      private

      def prepare_content_array(content)
        # Ensure content is an Array
        content = [content].flatten

        # Ensure there are no stray new lines within each string
        content = content.map { |line| line.split("\n") }.flatten

        # Ensure each new line has a line break at the end.
        content.map { |line| line.match?(/\n$/) ? line : "#{line}\n" }
      end
    end
  end
end