Skip to content

Commit

Permalink
+ Source::TreeRewriter: Improved merging and representations (#703)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcandre committed Jun 9, 2020
1 parent b328d5f commit 430da9d
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 13 deletions.
2 changes: 1 addition & 1 deletion lib/parser/source/range.rb
Expand Up @@ -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
Expand Down
68 changes: 67 additions & 1 deletion lib/parser/source/tree_rewriter.rb
Expand Up @@ -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
Expand All @@ -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`.
#
Expand Down Expand Up @@ -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<Range, String>] 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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions lib/parser/source/tree_rewriter/action.rb
Expand Up @@ -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
Expand Down
120 changes: 109 additions & 11 deletions test/test_source_tree_rewriter.rb
Expand Up @@ -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
Expand Down Expand Up @@ -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

0 comments on commit 430da9d

Please sign in to comment.