# frozen_string_literal: true
module Parser
module Source
##
# {TreeRewriter} performs the heavy lifting in the source rewriting process.
# It schedules code updates to be performed in the correct order.
#
# For simple cases, the resulting source will be obvious.
#
# Examples for more complex cases follow. Assume these examples are acting on
# the source `'puts(:hello, :world)`. The methods #wrap, #remove, etc.
# receive a Range as first argument; for clarity, examples below use english
# sentences and a string of raw code instead.
#
# ## Overlapping ranges:
#
# Any two rewriting actions on overlapping ranges will fail and raise
# a `ClobberingError`, unless they are both deletions (covered next).
#
# * wrap ':hello, ' with '(' and ')'
# * wrap ', :world' with '(' and ')'
# => CloberringError
#
# ## Overlapping deletions:
#
# * remove ':hello, '
# * remove ', :world'
#
# The overlapping ranges are merged and `':hello, :world'` will be removed.
# This policy can be changed. `:crossing_deletions` defaults to `:accept`
# but can be set to `:warn` or `:raise`.
#
# ## Multiple actions at the same end points:
#
# Results will always be independent on the order they were given.
# Exception: rewriting actions done on exactly the same range (covered next).
#
# Example:
# * replace ', ' by ' => '
# * wrap ':hello, :world' with '{' and '}'
# * replace ':world' with ':everybody'
# * wrap ':world' with '[', ']'
#
# The resulting string will be `'puts({:hello => [:everybody]})'`
# and this result is independent on the order the instructions were given in.
#
# Note that if the two "replace" were given as a single replacement of ', :world'
# for ' => :everybody', the result would be a `ClobberingError` because of the wrap
# in square brackets.
#
# ## Multiple wraps on same range:
# * wrap ':hello' with '(' and ')'
# * wrap ':hello' with '[' and ']'
#
# The wraps are combined in order given and results would be `'puts([(:hello)], :world)'`.
#
# ## Multiple replacements on same range:
# * replace ':hello' by ':hi', then
# * replace ':hello' by ':hey'
#
# The replacements are made in the order given, so the latter replacement
# supersedes the former and ':hello' will be replaced by ':hey'.
#
# This policy can be changed. `:different_replacements` defaults to `:accept`
# but can be set to `:warn` or `:raise`.
#
# ## Swallowed insertions:
# wrap 'world' by '__', '__'
# replace ':hello, :world' with ':hi'
#
# A containing replacement will swallow the contained rewriting actions
# and `':hello, :world'` will be replaced by `':hi'`.
#
# This policy can be changed for swallowed insertions. `:swallowed_insertions`
# defaults to `:accept` but can be set to `:warn` or `:raise`
#
# ## Implementation
# The updates are organized in a tree, according to the ranges they act on
# (where children are strictly contained by their parent), hence the name.
#
# @!attribute [r] source_buffer
# @return [Source::Buffer]
#
# @!attribute [r] diagnostics
# @return [Diagnostic::Engine]
#
# @api public
#
class TreeRewriter
attr_reader :source_buffer
attr_reader :diagnostics
##
# @param [Source::Buffer] source_buffer
#
def initialize(source_buffer,
crossing_deletions: :accept,
different_replacements: :accept,
swallowed_insertions: :accept)
@diagnostics = Diagnostic::Engine.new
@diagnostics.consumer = -> diag { $stderr.puts diag.render }
@source_buffer = source_buffer
@in_transaction = false
@policy = {crossing_deletions: crossing_deletions,
different_replacements: different_replacements,
swallowed_insertions: swallowed_insertions}.freeze
check_policy_validity
@enforcer = method(:enforce_policy)
# We need a range that would be jugded as containing all other ranges,
# including 0...0 and size...size:
all_encompassing_range = @source_buffer.source_range.adjust(begin_pos: -1, end_pos: +1)
@action_root = TreeRewriter::Action.new(all_encompassing_range, @enforcer)
end
##
# Returns true iff no (non trivial) update has been recorded
#
# @return [Boolean]
#
def empty?
@action_root.empty?
end
##
# 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
# @raise [ClobberingError] when clobbering is detected
#
def merge!(with)
raise 'TreeRewriter are not for the same source_buffer' unless
source_buffer == with.source_buffer
@action_root = @action_root.combine(with.action_root)
self
end
##
# Returns a new rewriter that consists of the updates of the received
# and the given argument. Policies of the receiver are used.
#
# @param [Rewriter] with
# @return [Rewriter] merge of receiver and argument
# @raise [ClobberingError] when clobbering is detected
#
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`.
#
# @param [Range] range
# @param [String] content
# @return [Rewriter] self
# @raise [ClobberingError] when clobbering is detected
#
def replace(range, content)
combine(range, replacement: content)
end
##
# Inserts the given strings before and after the given range.
#
# @param [Range] range
# @param [String, nil] insert_before
# @param [String, nil] insert_after
# @return [Rewriter] self
# @raise [ClobberingError] when clobbering is detected
#
def wrap(range, insert_before, insert_after)
combine(range, insert_before: insert_before.to_s, insert_after: insert_after.to_s)
end
##
# Shortcut for `replace(range, '')`
#
# @param [Range] range
# @return [Rewriter] self
# @raise [ClobberingError] when clobbering is detected
#
def remove(range)
replace(range, ''.freeze)
end
##
# Shortcut for `wrap(range, content, nil)`
#
# @param [Range] range
# @param [String] content
# @return [Rewriter] self
# @raise [ClobberingError] when clobbering is detected
#
def insert_before(range, content)
wrap(range, content, nil)
end
##
# Shortcut for `wrap(range, nil, content)`
#
# @param [Range] range
# @param [String] content
# @return [Rewriter] self
# @raise [ClobberingError] when clobbering is detected
#
def insert_after(range, content)
wrap(range, nil, content)
end
##
# Applies all scheduled changes to the `source_buffer` and returns
# modified source as a new string.
#
# @return [String]
#
def process
source = @source_buffer.source
chunks = []
last_end = 0
@action_root.ordered_replacements.each do |range, replacement|
chunks << source[last_end...range.begin_pos] << replacement
last_end = range.end_pos
end
chunks << source[last_end...source.length]
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,
# all the actions are rolled back. Transactions can be nested.
#
# @raise [RuntimeError] when no block is passed
#
def transaction
unless block_given?
raise "#{self.class}##{__method__} requires block"
end
previous = @in_transaction
@in_transaction = true
restore_root = @action_root
yield
restore_root = nil
self
ensure
@action_root = restore_root if restore_root
@in_transaction = previous
end
def in_transaction?
@in_transaction
end
# :nodoc:
def inspect
"#<#{self.class} #{source_buffer.name}: #{action_summary}>"
end
##
# @api private
# @deprecated Use insert_after or wrap
#
def insert_before_multi(range, text)
self.class.warn_of_deprecation
insert_before(range, text)
end
##
# @api private
# @deprecated Use insert_after or wrap
#
def insert_after_multi(range, text)
self.class.warn_of_deprecation
insert_after(range, text)
end
DEPRECATION_WARNING = [
'TreeRewriter#insert_before_multi and insert_before_multi exist only for legacy compatibility.',
'Please update your code to use `wrap`, `insert_before` or `insert_after` instead.'
].join("\n").freeze
extend Deprecation
protected
attr_reader :action_root
private
def action_summary
replacements = as_replacements
case replacements.size
when 0 then return 'empty'
when 1..3 then #ok
else
replacements = replacements.first(3)
suffix = '…'
end
parts = replacements.map do |(range, str)|
if str.empty? # is this a deletion?
"-#{range.to_range}"
elsif range.size == 0 # is this an insertion?
"+#{str.inspect}@#{range.begin_pos}"
else # it is a replacement
"^#{str.inspect}@#{range.to_range}"
end
end
parts << suffix if suffix
parts.join(', ')
end
ACTIONS = %i[accept warn raise].freeze
def check_policy_validity
invalid = @policy.values - ACTIONS
raise ArgumentError, "Invalid policy: #{invalid.join(', ')}" unless invalid.empty?
end
def combine(range, attributes)
range = check_range_validity(range)
action = TreeRewriter::Action.new(range, @enforcer, **attributes)
@action_root = @action_root.combine(action)
self
end
def check_range_validity(range)
if range.begin_pos < 0 || range.end_pos > @source_buffer.source.size
raise IndexError, "The range #{range.to_range} is outside the bounds of the source"
end
range
end
def enforce_policy(event)
return if @policy[event] == :accept
return unless (values = yield)
trigger_policy(event, **values)
end
POLICY_TO_LEVEL = {warn: :warning, raise: :error}.freeze
def trigger_policy(event, range: raise, conflict: nil, **arguments)
action = @policy[event] || :raise
diag = Parser::Diagnostic.new(POLICY_TO_LEVEL[action], event, arguments, range)
@diagnostics.process(diag)
if conflict
range, *highlights = conflict
diag = Parser::Diagnostic.new(POLICY_TO_LEVEL[action], :"#{event}_conflict", arguments, range, highlights)
@diagnostics.process(diag)
end
raise Parser::ClobberingError, "Parser::Source::TreeRewriter detected clobbering" if action == :raise
end
end
end
end