whitequark/parser

View on GitHub
lib/parser/source/rewriter.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

module Parser
  module Source

    ##
    # {Rewriter} is deprecated. Use {TreeRewriter} instead.
    #
    # TreeRewriter has simplified semantics, and customizable policies
    # with regards to clobbering. Please read the documentation.
    #
    # Keep in mind:
    # - Rewriter was discarding the `end_pos` of the given range for `insert_before`,
    #   and the `begin_pos` for `insert_after`. These are meaningful in TreeRewriter.
    # - TreeRewriter's wrap/insert_before/insert_after are multiple by default, while
    #   Rewriter would raise clobbering errors if the non '_multi' version was called.
    # - The TreeRewriter policy closest to Rewriter's behavior is:
    #       different_replacements: :raise,
    #       swallowed_insertions: :raise,
    #       crossing_deletions: :accept
    #
    # @!attribute [r] source_buffer
    #  @return [Source::Buffer]
    #
    # @!attribute [r] diagnostics
    #  @return [Diagnostic::Engine]
    #
    # @api public
    # @deprecated Use {TreeRewriter}
    #
    class Rewriter
      attr_reader :source_buffer
      attr_reader :diagnostics

      ##
      # @param [Source::Buffer] source_buffer
      # @deprecated Use {TreeRewriter}
      #
      def initialize(source_buffer)
        self.class.warn_of_deprecation
        @diagnostics = Diagnostic::Engine.new
        @diagnostics.consumer = lambda do |diag|
          $stderr.puts diag.render
        end

        @source_buffer = source_buffer
        @queue         = []
        @clobber       = 0
        @insertions    = 0 # clobbered zero-length positions; index 0 is the far left

        @insert_before_multi_order = 0
        @insert_after_multi_order = 0

        @pending_queue = nil
        @pending_clobber = nil
        @pending_insertions = nil
      end

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

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

      ##
      # Inserts new code before and after the given source range.
      #
      # @param [Range] range
      # @param [String] before
      # @param [String] after
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      # @deprecated Use {TreeRewriter#wrap}
      #
      def wrap(range, before, after)
        append Rewriter::Action.new(range.begin, before)
        append Rewriter::Action.new(range.end, after)
      end

      ##
      # Inserts new code before the given source range by allowing other
      # insertions at the same position.
      # Note that an insertion with latter invocation comes _before_ earlier
      # insertion at the same position in the rewritten source.
      #
      # @example Inserting '[('
      #   rewriter.
      #     insert_before_multi(range, '(').
      #     insert_before_multi(range, '[').
      #     process
      #
      # @param [Range] range
      # @param [String] content
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      # @deprecated Use {TreeRewriter#insert_before}
      #
      def insert_before_multi(range, content)
        @insert_before_multi_order -= 1
        append Rewriter::Action.new(range.begin, content, true, @insert_before_multi_order)
      end

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

      ##
      # Inserts new code after the given source range by allowing other
      # insertions at the same position.
      # Note that an insertion with latter invocation comes _after_ earlier
      # insertion at the same position in the rewritten source.
      #
      # @example Inserting ')]'
      #   rewriter.
      #     insert_after_multi(range, ')').
      #     insert_after_multi(range, ']').
      #     process
      #
      # @param [Range] range
      # @param [String] content
      # @return [Rewriter] self
      # @raise [ClobberingError] when clobbering is detected
      # @deprecated Use {TreeRewriter#insert_after}
      #
      def insert_after_multi(range, content)
        @insert_after_multi_order += 1
        append Rewriter::Action.new(range.end, content, true, @insert_after_multi_order)
      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
      # @deprecated Use {TreeRewriter#replace}
      #
      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]
      # @deprecated Use {TreeRewriter#process}
      #
      def process
        if in_transaction?
          raise "Do not call #{self.class}##{__method__} inside a transaction"
        end

        adjustment = 0
        source     = @source_buffer.source.dup

        @queue.sort.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 atomically. If any of the actions 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
      # @deprecated Use {TreeRewriter#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
        @pending_insertions = @insertions

        yield

        @queue = @pending_queue
        @clobber = @pending_clobber
        @insertions = @pending_insertions

        self
      ensure
        @pending_queue = nil
        @pending_clobber = nil
        @pending_insertions = nil
      end

      private

      # Schedule a code update. If it overlaps with another update, check
      # whether they conflict, and raise a clobbering error if they do.
      # (As a special case, zero-length ranges at the same position are
      # considered to "overlap".) Otherwise, merge them.
      #
      # Updates which are adjacent to each other, but do not overlap, are also
      # merged.
      #
      # RULES:
      #
      # - Insertion ("replacing" a zero-length range):
      #   - Two insertions at the same point conflict. This is true even
      #     if the earlier insertion has already been merged with an adjacent
      #     update, and even if they are both inserting the same text.
      #   - An insertion never conflicts with a replace or remove operation
      #     on its right or left side, which does not overlap it (in other
      #     words, which does not update BOTH its right and left sides).
      #   - An insertion always conflicts with a remove operation which spans
      #     both its sides.
      #   - An insertion conflicts with a replace operation which spans both its
      #     sides, unless the replacement text is longer than the replaced text
      #     by the size of the insertion (or more), and the portion of
      #     replacement text immediately after the insertion position is
      #     identical to the inserted text.
      #
      # - Removal operations never conflict with each other.
      #
      # - Replacement operations:
      #   - Take the portion of each replacement text which falls within:
      #     - The other operation's replaced region
      #     - The other operation's replacement text, if it extends past the
      #       end of its own replaced region (in other words, if the replacement
      #       text is longer than the text it replaces)
      #   - If and only if the taken texts are identical for both operations,
      #     they do not conflict.
      #
      def append(action)
        range = action.range

        # Is this an insertion?
        if range.empty?
          # Replacing nothing with... nothing?
          return self if action.replacement.empty?

          if !action.allow_multiple_insertions? && (conflicting = clobbered_insertion?(range))
            raise_clobber_error(action, [conflicting])
          end

          record_insertion(range)

          if (adjacent = adjacent_updates?(range))
            conflicting = adjacent.find do |a|
              a.range.overlaps?(range) &&
                !replace_compatible_with_insertion?(a, action)
            end
            raise_clobber_error(action, [conflicting]) if conflicting

            merge_actions!(action, adjacent)
          else
            active_queue << action
          end
        else
          # It's a replace or remove operation.
          if (insertions = adjacent_insertions?(range))
            insertions.each do |insertion|
              if range.overlaps?(insertion.range) &&
                 !replace_compatible_with_insertion?(action, insertion)
                raise_clobber_error(action, [insertion])
              else
                action = merge_actions(action, [insertion])
                active_queue.delete(insertion)
              end
            end
          end

          if (adjacent = adjacent_updates?(range))
            if can_merge?(action, adjacent)
              record_replace(range)
              merge_actions!(action, adjacent)
            else
              raise_clobber_error(action, adjacent)
            end
          else
            record_replace(range)
            active_queue << action
          end
        end

        self
      end

      def record_insertion(range)
        self.active_insertions = active_insertions | (1 << range.begin_pos)
      end

      def record_replace(range)
        self.active_clobber = active_clobber | clobbered_position_mask(range)
      end

      def clobbered_position_mask(range)
        ((1 << range.size) - 1) << range.begin_pos
      end

      def adjacent_position_mask(range)
        ((1 << (range.size + 2)) - 1) << (range.begin_pos - 1)
      end

      def adjacent_insertion_mask(range)
        ((1 << (range.size + 1)) - 1) << range.begin_pos
      end

      def clobbered_insertion?(insertion)
        insertion_pos = insertion.begin_pos
        if active_insertions & (1 << insertion_pos) != 0
          # The clobbered insertion may have already been merged with other
          # updates, so it won't necessarily have the same begin_pos.
          active_queue.find do |a|
            a.range.begin_pos <= insertion_pos && insertion_pos <= a.range.end_pos
          end
        end
      end

      def adjacent_insertions?(range)
        # Just retrieve insertions which have not been merged with an adjacent
        # remove or replace.
        if active_insertions & adjacent_insertion_mask(range) != 0
          result = active_queue.select do |a|
            a.range.empty? && adjacent?(range, a.range)
          end
          result.empty? ? nil : result
        end
      end

      def adjacent_updates?(range)
        if active_clobber & adjacent_position_mask(range) != 0
          active_queue.select { |a| adjacent?(range, a.range) }
        end
      end

      def replace_compatible_with_insertion?(replace, insertion)
        (replace.replacement.length - replace.range.size) >= insertion.range.size &&
          (offset = insertion.range.begin_pos - replace.range.begin_pos) &&
          replace.replacement[offset, insertion.replacement.length] == insertion.replacement
      end

      def can_merge?(action, existing)
        # Compare 2 replace/remove operations (neither is an insertion)
        range = action.range

        existing.all? do |other|
          overlap = range.intersect(other.range)
          next true if overlap.nil? # adjacent, not overlapping

          repl1_offset = overlap.begin_pos - range.begin_pos
          repl2_offset = overlap.begin_pos - other.range.begin_pos
          repl1_length = [other.range.length - repl2_offset,
                          other.replacement.length  - repl2_offset].max
          repl2_length = [range.length - repl1_offset,
                          action.replacement.length - repl1_offset].max

          replacement1 = action.replacement[repl1_offset, repl1_length] || ''.freeze
          replacement2 = other.replacement[repl2_offset, repl2_length] || ''.freeze
          replacement1 == replacement2
        end
      end

      def merge_actions(action, existing)
        actions = existing.push(action).sort_by do |a|
          [a.range.begin_pos, a.range.end_pos]
        end
        range = actions.first.range.join(actions.max_by { |a| a.range.end_pos }.range)

        Rewriter::Action.new(range, merge_replacements(actions))
      end

      def merge_actions!(action, existing)
        new_action = merge_actions(action, existing)
        active_queue.delete(action)
        replace_actions(existing, new_action)
      end

      def merge_replacements(actions)
        result    = ''.dup
        prev_act  = nil

        actions.each do |act|
          if !prev_act || act.range.disjoint?(prev_act.range)
            result << act.replacement
          else
            prev_end = [prev_act.range.begin_pos + prev_act.replacement.length,
                        prev_act.range.end_pos].max
            offset   = prev_end - act.range.begin_pos
            result << act.replacement[offset..-1] if offset < act.replacement.size
          end

          prev_act = act
        end

        result
      end

      def replace_actions(old, updated)
        old.each { |act| active_queue.delete(act) }
        active_queue << updated
      end

      def raise_clobber_error(action, existing)
        # 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 => existing[0] },
                                    existing[0].range)
        @diagnostics.process(diagnostic)

        raise ClobberingError, "Parser::Source::Rewriter detected clobbering"
      end

      def in_transaction?
        !@pending_queue.nil?
      end

      def active_queue
        @pending_queue || @queue
      end

      def active_clobber
        @pending_clobber || @clobber
      end

      def active_insertions
        @pending_insertions || @insertions
      end

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

      def active_insertions=(value)
        if @pending_insertions
          @pending_insertions = value
        else
          @insertions = value
        end
      end

      def adjacent?(range1, range2)
        range1.begin_pos <= range2.end_pos && range2.begin_pos <= range1.end_pos
      end

      DEPRECATION_WARNING = [
        'Parser::Source::Rewriter is deprecated.',
        'Please update your code to use Parser::Source::TreeRewriter instead'
      ].join("\n").freeze

      extend Deprecation
    end

  end
end