diff --git a/lib/parser/source/range.rb b/lib/parser/source/range.rb index 9463651b5..1850ae527 100644 --- a/lib/parser/source/range.rb +++ b/lib/parser/source/range.rb @@ -13,7 +13,7 @@ module Source # ^^ # # @!attribute [r] source_buffer - # @return [Parser::Diagnostic::Engine] + # @return [Parser::Source::Buffer] # # @!attribute [r] begin_pos # @return [Integer] index of the first character in the range diff --git a/lib/parser/source/tree_rewriter.rb b/lib/parser/source/tree_rewriter.rb index 1baf7ba09..9e0ec8fef 100644 --- a/lib/parser/source/tree_rewriter.rb +++ b/lib/parser/source/tree_rewriter.rb @@ -129,6 +129,8 @@ def empty? ## # Merges the updates of argument with the receiver. # Policies of the receiver are used. + # This action is atomic in that it won't change the receiver + # unless it succeeds. # # @param [Rewriter] with # @return [Rewriter] self @@ -154,6 +156,32 @@ def merge(with) dup.merge!(with) end + ## + # For special cases where one needs to merge a rewriter attached to a different source_buffer + # or that needs to be offset. Policies of the receiver are used. + # + # @param [TreeRewriter] rewriter from different source_buffer + # @param [Integer] offset + # @return [Rewriter] self + # @raise [IndexError] if action ranges (once offset) don't fit the current buffer + # + def import!(foreign_rewriter, offset: 0) + return self if foreign_rewriter.empty? + + contracted = foreign_rewriter.action_root.contract + merge_effective_range = ::Parser::Source::Range.new( + @source_buffer, + contracted.range.begin_pos + offset, + contracted.range.end_pos + offset, + ) + check_range_validity(merge_effective_range) + + merge_with = contracted.moved(@source_buffer, offset) + + @action_root = @action_root.combine(merge_with) + self + end + ## # Replaces the code of the source range `range` with `content`. # @@ -234,6 +262,44 @@ def process chunks.join end + ## + # Returns a representation of the rewriter as an ordered list of replacements. + # + # rewriter.as_replacements # => [ [1...1, '('], + # [2...4, 'foo'], + # [5...6, ''], + # [6...6, '!'], + # [10...10, ')'], + # ] + # + # This representation is sufficient to recreate the result of `process` but it is + # not sufficient to recreate completely the rewriter for further merging/actions. + # See `as_nested_actions` + # + # @return [Array] an ordered list of pairs of range & replacement + # + def as_replacements + @action_root.ordered_replacements + end + + ## + # Returns a representation of the rewriter as nested insertions (:wrap) and replacements. + # + # rewriter.as_actions # =>[ [:wrap, 1...10, '(', ')'], + # [:wrap, 2...6, '', '!'], # aka "insert_after" + # [:replace, 2...4, 'foo'], + # [:replace, 5...6, ''], # aka "removal" + # ], + # + # Contrary to `as_replacements`, this representation is sufficient to recreate exactly + # the rewriter. + # + # @return [Array<(Symbol, Range, String{, String})>] + # + def as_nested_actions + @action_root.nested_actions + end + ## # Provides a protected block where a sequence of multiple rewrite actions # are handled atomically. If any of the actions failed by clobbering, @@ -310,7 +376,7 @@ def combine(range, attributes) def check_range_validity(range) if range.begin_pos < 0 || range.end_pos > @source_buffer.source.size - raise IndexError, "The range #{range} is outside the bounds of the source" + raise IndexError, "The range #{range.to_range} is outside the bounds of the source" end range end diff --git a/lib/parser/source/tree_rewriter/action.rb b/lib/parser/source/tree_rewriter/action.rb index 69b5799bd..26cc02600 100644 --- a/lib/parser/source/tree_rewriter/action.rb +++ b/lib/parser/source/tree_rewriter/action.rb @@ -46,10 +46,49 @@ def ordered_replacements reps end + def nested_actions + actions = [] + actions << [:wrap, @range, @insert_before, @insert_after] if !@insert_before.empty? || + !@insert_after.empty? + actions << [:replace, @range, @replacement] if @replacement + actions.concat(@children.flat_map(&:nested_actions)) + end + def insertion? !insert_before.empty? || !insert_after.empty? || (replacement && !replacement.empty?) end + ## + # A root action has its range set to the whole source range, even + # though it typically do not act on that range. + # This method returns the action as if it was a child action with + # its range contracted. + # @return [Action] + def contract + raise 'Empty actions can not be contracted' if empty? + return self if insertion? + range = @range.with( + begin_pos: children.first.range.begin_pos, + end_pos: children.last.range.end_pos, + ) + with(range: range) + end + + ## + # @return [Action] that has been moved to the given source_buffer and with the given offset + # No check is done on validity of resulting range. + def moved(source_buffer, offset) + moved_range = ::Parser::Source::Range.new( + source_buffer, + @range.begin_pos + offset, + @range.end_pos + offset + ) + with( + range: moved_range, + children: children.map { |child| child.moved(source_buffer, offset) } + ) + end + protected attr_reader :children diff --git a/test/test_source_tree_rewriter.rb b/test/test_source_tree_rewriter.rb index 282d566b1..4fca87be1 100644 --- a/test/test_source_tree_rewriter.rb +++ b/test/test_source_tree_rewriter.rb @@ -3,20 +3,25 @@ require 'helper' class TestSourceTreeRewriter < Minitest::Test - def setup - @buf = Parser::Source::Buffer.new('(rewriter)', - source: 'puts(:hello, :world)') + module Setup + def setup + @buf = Parser::Source::Buffer.new('(rewriter)', + source: 'puts(:hello, :world)') + + @hello = range(5, 6) + @ll = range(8, 2) + @comma_space = range(11,2) + @world = range(13,6) + @whole = range(0, @buf.source.length) + end - @hello = range(5, 6) - @ll = range(7, 2) - @comma_space = range(11,2) - @world = range(13,6) - @whole = range(0, @buf.source.length) + def range(from, len = nil) + from, len = from.begin, from.end - from.begin unless len + Parser::Source::Range.new(@buf, from, from + len) + end end - def range(from, len) - Parser::Source::Range.new(@buf, from, from + len) - end + include Setup # Returns either: # - yield rewriter @@ -260,4 +265,97 @@ def test_merge [:wrap, @hello.join(@world), '@', '@'], ]) end + + def representation_example + Parser::Source::TreeRewriter.new(@buf) + .wrap(range(1...10), '(', ')') + .insert_after(range(2...6), '!') + .replace(range(2...4), 'foo') + .remove(range(5...6)) + end + + def test_nested_actions + result = representation_example.as_nested_actions + + assert_equal( [ [:wrap, 1...10, '(', ')'], + [:wrap, 2...6, '', '!'], # aka "insert_after" + [:replace, 2...4, 'foo'], + [:replace, 5...6, ''], # aka "removal" + ], + result.each {|arr| arr[1] = arr[1].to_range } + ) + end + + def test_ordered_replacements + result = representation_example.as_replacements + + assert_equal( [ [ 1...1, '('], + [ 2...4, 'foo'], + [ 5...6, ''], + [ 6...6, '!'], + [ 10...10, ')'], + ], + result.map {|r, s| [r.to_range, s]} + ) + end +end + +class TestSourceTreeRewriterImport < Minitest::Test + include TestSourceTreeRewriter::Setup + def setup + super + @buf2 = Parser::Source::Buffer.new('(rewriter 2)', + source: ':hello') + + @rewriter = Parser::Source::TreeRewriter.new(@buf) + + @rewriter2 = Parser::Source::TreeRewriter.new(@buf2) + + @hello2 = range2(0, 6) + @ll2 = range2(3, 2) + end + + def range2(from, len) + Parser::Source::Range.new(@buf2, from, from + len) + end + + def test_import_with_offset + @rewriter2.wrap(@hello2, '[', ']') + @rewriter.wrap(@hello.join(@world), '{', '}') + @rewriter.import!(@rewriter2, offset: @hello.begin_pos) + assert_equal 'puts({[:hello], :world})', @rewriter.process + end + + def test_import_with_offset_from_bigger_source + @rewriter2.wrap(@ll2, '[', ']') + @rewriter.wrap(@hello, '{', '}') + @rewriter2.import!(@rewriter, offset: -@hello.begin_pos) + assert_equal '{:he[ll]o}', @rewriter2.process + end + + def test_import_with_offset_and_self + @rewriter.wrap(@ll, '[', ']') + @rewriter.import!(@rewriter, offset: +3) + @rewriter.replace(range(8,1), '**') + assert_equal 'puts(:he[**l]o[, ]:world)', @rewriter.process + @rewriter.import!(@rewriter, offset: -6) + assert_equal 'pu[**s]([:h]e[**l]o[, ]:world)', @rewriter.process + end + + def test_import_with_invalid_offset + @rewriter.wrap(@ll, '[', ']') + m = @rewriter.dup.import!(@rewriter, offset: -@ll.begin_pos) + assert_equal '[pu]ts(:he[ll]o, :world)', m.process + off = @buf.source.size - @ll.end_pos + m = @rewriter.dup.import!(@rewriter, offset: off) + assert_equal 'puts(:he[ll]o, :worl[d)]', m.process + assert_raises { @rewriter.import!(@rewriter, offset: -@ll.begin_pos - 1) } + assert_raises { @rewriter.import!(@rewriter, offset: off + 1) } + assert_equal 'puts(:he[ll]o, :world)', @rewriter.process # Test atomicity of import! + end + + def test_empty_import + assert_equal @rewriter, @rewriter.import!(@rewriter2) + assert_equal @rewriter, @rewriter.import!(@rewriter, offset: 42) + end end