Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

+ Source::TreeRewriter: Improved merging and representations #703

Merged
merged 4 commits into from Jun 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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