lib/parser/source/rewriter.rb



module Parser
  module Source

    ##
    # {Rewriter} performs the heavy lifting in the source rewriting process.
    # It schedules code updates to be performed in the correct order and
    # verifies that no two updates _clobber_ each other, that is, attempt to
    # modify the same part of code.
    #
    # If it is detected that one update clobbers another one, an `:error` and
    # a `:note` diagnostics describing both updates are generated and passed to
    # the diagnostic engine. After that, an exception is raised.
    #
    # The default diagnostic engine consumer simply prints the diagnostics to `stderr`.
    #
    # @!attribute [r] source_buffer
    #  @return [Source::Buffer]
    #
    # @!attribute [r] diagnostics
    #  @return [Diagnostic::Engine]
    #
    # @api public
    #
    class Rewriter
      attr_reader :source_buffer
      attr_reader :diagnostics

      ##
      # @param [Source::Buffer] source_buffer
      #
      def initialize(source_buffer)
        @diagnostics = Diagnostic::Engine.new
        @diagnostics.consumer = lambda do |diag|
          $stderr.puts diag.render
        end

        @source_buffer = source_buffer
        @queue         = []
        @clobber       = 0
      end

      ##
      # Removes the source range.
      #
      # @param [Range] range
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      #
      def remove(range)
        append Rewriter::Action.new(range, '')
      end

      ##
      # Inserts new code before the given source range.
      #
      # @param [Range] range
      # @param [String] content
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      #
      def insert_before(range, content)
        append Rewriter::Action.new(range.begin, content)
      end

      ##
      # Inserts new code after the given source range.
      #
      # @param [Range] range
      # @param [String] content
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      #
      def insert_after(range, content)
        append Rewriter::Action.new(range.end, content)
      end

      ##
      # Replaces the code of the source range `range` with `content`.
      #
      # @param [Range] range
      # @param [String] content
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      #
      def replace(range, content)
        append Rewriter::Action.new(range, content)
      end

      ##
      # Applies all scheduled changes to the `source_buffer` and returns
      # modified source as a new string.
      #
      # @return [String]
      #
      def process
        if in_transaction?
          raise "Do not call #{self.class}##{__method__} inside a transaction"
        end

        adjustment = 0
        source     = @source_buffer.source.dup

        sorted_queue = @queue.sort_by.with_index do |action, index|
          [action.range.begin_pos, index]
        end

        sorted_queue.each do |action|
          begin_pos = action.range.begin_pos + adjustment
          end_pos   = begin_pos + action.range.length

          source[begin_pos...end_pos] = action.replacement

          adjustment += (action.replacement.length - action.range.length)
        end

        source
      end

      ##
      # Provides a protected block where a sequence of multiple rewrite actions
      # are handled atomic. If any of the action failed by clobbering,
      # all the actions are rolled back.
      #
      # @example
      #  begin
      #    rewriter.transaction do
      #      rewriter.insert_before(range_of_something, '(')
      #      rewriter.insert_after(range_of_something, ')')
      #    end
      #  rescue Parser::ClobberingError
      #  end
      #
      # @raise [RuntimeError] when no block is passed
      # @raise [RuntimeError] when already in a transaction
      #
      def transaction
        unless block_given?
          raise "#{self.class}##{__method__} requires block"
        end

        if in_transaction?
          raise 'Nested transaction is not supported'
        end

        @pending_queue = @queue.dup
        @pending_clobber = @clobber

        yield

        @queue = @pending_queue
        @clobber = @pending_clobber

        self
      ensure
        @pending_queue = nil
        @pending_clobber = nil
      end

      private

      def append(action)
        if (clobber_action = clobbered?(action.range))
          # cannot replace 3 characters with "foobar"
          diagnostic = Diagnostic.new(:error,
                                      :invalid_action,
                                      { :action => action },
                                      action.range)
          @diagnostics.process(diagnostic)

          # clobbered by: remove 3 characters
          diagnostic = Diagnostic.new(:note,
                                      :clobbered,
                                      { :action => clobber_action },
                                      clobber_action.range)
          @diagnostics.process(diagnostic)

          raise ClobberingError, "Parser::Source::Rewriter detected clobbering"
        else
          clobber(action.range)

          active_queue << action
        end

        self
      end

      def clobber(range)
        self.active_clobber = active_clobber | (2 ** range.size - 1) << range.begin_pos
      end

      def clobbered?(range)
        if active_clobber & ((2 ** range.size - 1) << range.begin_pos) != 0
          active_queue.find do |action|
            action.range.to_a & range.to_a
          end
        end
      end

      def in_transaction?
        !@pending_queue.nil?
      end

      def active_queue
        @pending_queue || @queue
      end

      def active_clobber
        @pending_clobber || @clobber
      end

      def active_clobber=(value)
        if @pending_clobber
          @pending_clobber = value
        else
          @clobber = value
        end
      end
    end

  end
end