astel 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +110 -60
- data/docs/codemod-guide.md +2 -2
- data/docs/refactor.md +2 -2
- data/lib/astel/diff.rb +3 -130
- data/lib/astel/dispatcher.rb +47 -31
- data/lib/astel/location.rb +12 -2
- data/lib/astel/node_ext.rb +2 -8
- data/lib/astel/node_pattern/lexer.rb +33 -12
- data/lib/astel/node_pattern/matcher_compiler.rb +8 -3
- data/lib/astel/node_pattern/parser.rb +16 -8
- data/lib/astel/node_pattern/predicate_compiler.rb +24 -10
- data/lib/astel/node_pattern.rb +18 -10
- data/lib/astel/node_source_text.rb +13 -0
- data/lib/astel/refactor.rb +2 -187
- data/lib/astel/rewriter/edit_index.rb +84 -0
- data/lib/astel/rewriter/structured/collections.rb +168 -0
- data/lib/astel/rewriter/structured/comments.rb +31 -0
- data/lib/astel/rewriter/structured/formatting.rb +200 -0
- data/lib/astel/rewriter/structured/source_indenter.rb +88 -0
- data/lib/astel/rewriter/structured.rb +163 -0
- data/lib/astel/rewriter.rb +85 -113
- data/lib/astel/source_file.rb +37 -24
- data/lib/astel/unified_diff.rb +205 -0
- data/lib/astel/version.rb +1 -1
- data/lib/astel.rb +1 -0
- metadata +9 -3
- data/lib/astel/refactor/comments.rb +0 -36
- data/lib/astel/refactor/formatting.rb +0 -138
|
@@ -5,6 +5,7 @@ module Astel
|
|
|
5
5
|
class Lexer
|
|
6
6
|
SINGLE = { '(' => :lparen, ')' => :rparen, '{' => :lbrace, '}' => :rbrace,
|
|
7
7
|
':' => :colon, '$' => :capture }.freeze
|
|
8
|
+
OPERATOR_CHARACTERS = '+-*/%<>=~&|^![]@`'.freeze
|
|
8
9
|
|
|
9
10
|
def initialize(source)
|
|
10
11
|
@source = source
|
|
@@ -34,8 +35,9 @@ module Astel
|
|
|
34
35
|
single = SINGLE[character]
|
|
35
36
|
return token(single, advance, line, column) if single
|
|
36
37
|
return read_string(line, column) if ['"', "'"].include?(character)
|
|
37
|
-
return read_number(line, column) if
|
|
38
|
-
return read_identifier(line, column) if
|
|
38
|
+
return read_number(line, column) if number_start?(character)
|
|
39
|
+
return read_identifier(line, column) if identifier_start?(character)
|
|
40
|
+
return read_operator(line, column) if OPERATOR_CHARACTERS.include?(character)
|
|
39
41
|
|
|
40
42
|
raise PatternError.new("unexpected character #{character.inspect}", line: line, column: column)
|
|
41
43
|
end
|
|
@@ -44,15 +46,7 @@ module Astel
|
|
|
44
46
|
quote = advance
|
|
45
47
|
value = +''
|
|
46
48
|
until eof? || current == quote
|
|
47
|
-
|
|
48
|
-
advance
|
|
49
|
-
raise PatternError.new('unterminated escape', line: @line, column: @column) if eof?
|
|
50
|
-
|
|
51
|
-
escaped = advance
|
|
52
|
-
value << { 'n' => "\n", 'r' => "\r", 't' => "\t" }.fetch(escaped, escaped)
|
|
53
|
-
else
|
|
54
|
-
value << advance
|
|
55
|
-
end
|
|
49
|
+
value << (current == '\\' ? read_escaped_character : advance)
|
|
56
50
|
end
|
|
57
51
|
raise PatternError.new('unterminated string', line: line, column: column) if eof?
|
|
58
52
|
|
|
@@ -60,6 +54,14 @@ module Astel
|
|
|
60
54
|
token(:literal, value.freeze, line, column)
|
|
61
55
|
end
|
|
62
56
|
|
|
57
|
+
def read_escaped_character
|
|
58
|
+
advance
|
|
59
|
+
raise PatternError.new('unterminated escape', line: @line, column: @column) if eof?
|
|
60
|
+
|
|
61
|
+
escaped = advance
|
|
62
|
+
{ 'n' => "\n", 'r' => "\r", 't' => "\t" }.fetch(escaped, escaped)
|
|
63
|
+
end
|
|
64
|
+
|
|
63
65
|
def read_number(line, column)
|
|
64
66
|
start = @index
|
|
65
67
|
advance if current == '-'
|
|
@@ -72,12 +74,31 @@ module Astel
|
|
|
72
74
|
|
|
73
75
|
def read_identifier(line, column)
|
|
74
76
|
start = @index
|
|
75
|
-
advance while !eof? &&
|
|
77
|
+
advance while !eof? && identifier_character?(current)
|
|
78
|
+
advance if !eof? && current == '='
|
|
76
79
|
value = @source[start...@index]
|
|
77
80
|
type = value == '_' ? :wildcard : :identifier
|
|
78
81
|
token(type, value, line, column)
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
def read_operator(line, column)
|
|
85
|
+
start = @index
|
|
86
|
+
advance while !eof? && OPERATOR_CHARACTERS.include?(current)
|
|
87
|
+
token(:identifier, @source[start...@index], line, column)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def number_start?(character)
|
|
91
|
+
character.match?(/[0-9]/) || (character == '-' && @source[@index + 1]&.match?(/[0-9]/))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def identifier_start?(character)
|
|
95
|
+
character.match?(/[[:alpha:]_]/)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def identifier_character?(character)
|
|
99
|
+
character.match?(/[[:alnum:]_?!]/)
|
|
100
|
+
end
|
|
101
|
+
|
|
81
102
|
def skip_whitespace
|
|
82
103
|
advance while !eof? && current.match?(/\s/)
|
|
83
104
|
end
|
|
@@ -50,15 +50,20 @@ module Astel
|
|
|
50
50
|
captures = []
|
|
51
51
|
|
|
52
52
|
fields.each do |name, field_pattern|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
conditions << "((#{variable} = #{value}.#{name}); #{condition})"
|
|
53
|
+
condition, field_captures = field_branch(name, field_pattern, value)
|
|
54
|
+
conditions << condition
|
|
56
55
|
captures.concat(field_captures)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
[conditions.join(' && '), captures]
|
|
60
59
|
end
|
|
61
60
|
|
|
61
|
+
def field_branch(name, pattern, value)
|
|
62
|
+
variable = next_variable('value')
|
|
63
|
+
condition, captures = branch_for(pattern, variable)
|
|
64
|
+
["((#{variable} = #{value}.#{name}); #{condition})", captures]
|
|
65
|
+
end
|
|
66
|
+
|
|
62
67
|
def next_variable(prefix)
|
|
63
68
|
@variable += 1
|
|
64
69
|
"#{prefix}_#{@variable}"
|
|
@@ -39,16 +39,21 @@ module Astel
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def parse_node
|
|
42
|
-
|
|
42
|
+
token = consume(:identifier, 'expected node type')
|
|
43
|
+
type = token.value.to_sym
|
|
43
44
|
fields = []
|
|
44
45
|
until accept(:rparen)
|
|
45
46
|
error('unterminated node pattern') if current.type == :eof
|
|
46
|
-
|
|
47
|
-
name = consume(:identifier, 'expected attribute name').value.to_sym
|
|
48
|
-
consume(:colon, "expected ':' after attribute name")
|
|
49
|
-
fields << [name, parse_pattern]
|
|
47
|
+
fields << parse_field
|
|
50
48
|
end
|
|
51
|
-
[:node, type, fields.freeze]
|
|
49
|
+
[:node, type, fields.freeze, token.line, token.column]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_field
|
|
53
|
+
token = consume(:identifier, 'expected attribute name')
|
|
54
|
+
name = token.value.to_sym
|
|
55
|
+
consume(:colon, "expected ':' after attribute name")
|
|
56
|
+
[name, parse_pattern, token.line, token.column]
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
def parse_or
|
|
@@ -59,8 +64,11 @@ module Astel
|
|
|
59
64
|
end
|
|
60
65
|
|
|
61
66
|
def parse_symbol
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
token = current
|
|
68
|
+
unless accept(:identifier) || accept(:wildcard) || (token.type == :literal && token.value.is_a?(String) && accept(:literal))
|
|
69
|
+
error('expected symbol name')
|
|
70
|
+
end
|
|
71
|
+
[:literal, token.value.to_sym]
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def accept(type)
|
|
@@ -28,7 +28,9 @@ module Astel
|
|
|
28
28
|
when :wildcard then "!#{value}.nil?"
|
|
29
29
|
when :capture then expression_for(arguments.fetch(0), value)
|
|
30
30
|
when :or then or_expression(arguments.fetch(0), value)
|
|
31
|
-
when :node
|
|
31
|
+
when :node
|
|
32
|
+
node_expression(arguments.fetch(0), arguments.fetch(1), value,
|
|
33
|
+
line: arguments.fetch(2), column: arguments.fetch(3))
|
|
32
34
|
else raise ArgumentError, "unknown pattern type: #{type}"
|
|
33
35
|
end
|
|
34
36
|
end
|
|
@@ -37,22 +39,34 @@ module Astel
|
|
|
37
39
|
alternatives.map { |alternative| "(#{expression_for(alternative, value)})" }.join(' || ')
|
|
38
40
|
end
|
|
39
41
|
|
|
40
|
-
def node_expression(type, fields, value)
|
|
42
|
+
def node_expression(type, fields, value, line:, column:)
|
|
41
43
|
node_class = NODE_CLASSES[type]
|
|
42
|
-
raise PatternError.new("unknown Prism node type #{type}", line:
|
|
44
|
+
raise PatternError.new("unknown Prism node type #{type}", line: line, column: column) unless node_class
|
|
43
45
|
|
|
44
46
|
conditions = ["#{node_class.name} === #{value}"]
|
|
45
|
-
fields.each do |name, pattern|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
variable = next_variable
|
|
51
|
-
conditions << "((#{variable} = #{value}.#{name}); #{expression_for(pattern, variable)})"
|
|
47
|
+
fields.each do |name, pattern, field_line, field_column|
|
|
48
|
+
conditions << field_condition(
|
|
49
|
+
node_class, type, name, pattern, value,
|
|
50
|
+
line: field_line, column: field_column
|
|
51
|
+
)
|
|
52
52
|
end
|
|
53
53
|
conditions.join(' && ')
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def field_condition(node_class, type, name, pattern, value, line:, column:)
|
|
57
|
+
unless node_attribute?(node_class, name)
|
|
58
|
+
raise PatternError.new("unknown #{type} attribute #{name}", line: line, column: column)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
variable = next_variable
|
|
62
|
+
"((#{variable} = #{value}.#{name}); #{expression_for(pattern, variable)})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def node_attribute?(node_class, name)
|
|
66
|
+
fields = node_class.instance_method(:copy).parameters.map(&:last) - %i[node_id flags]
|
|
67
|
+
fields.include?(name) || (name.to_s.end_with?('?') && node_class.instance_methods(false).include?(name))
|
|
68
|
+
end
|
|
69
|
+
|
|
56
70
|
def next_variable
|
|
57
71
|
@variable += 1
|
|
58
72
|
"value_#{@variable}"
|
data/lib/astel/node_pattern.rb
CHANGED
|
@@ -50,23 +50,23 @@ module Astel
|
|
|
50
50
|
cached = COMPILER_CACHE[source]
|
|
51
51
|
return cached if cached
|
|
52
52
|
|
|
53
|
-
tokens = Lexer.new(source).tokens
|
|
54
|
-
syntax = Parser.new(tokens).parse
|
|
55
|
-
predicate = PredicateCompiler.new.compile(syntax)
|
|
56
|
-
matcher = MatcherCompiler.new.compile(syntax) if captures?(syntax)
|
|
57
53
|
COMPILER_CACHE.shift if COMPILER_CACHE.length >= COMPILER_CACHE_LIMIT
|
|
58
|
-
COMPILER_CACHE[source.dup.freeze] =
|
|
54
|
+
COMPILER_CACHE[source.dup.freeze] = compile_matchers(source)
|
|
59
55
|
end
|
|
60
56
|
end
|
|
61
57
|
private_class_method :compiled_matchers
|
|
62
58
|
|
|
59
|
+
def self.compile_matchers(source)
|
|
60
|
+
syntax = Parser.new(Lexer.new(source).tokens).parse
|
|
61
|
+
predicate = PredicateCompiler.new.compile(syntax)
|
|
62
|
+
matcher = MatcherCompiler.new.compile(syntax) if captures?(syntax)
|
|
63
|
+
[predicate, matcher].freeze
|
|
64
|
+
end
|
|
65
|
+
private_class_method :compile_matchers
|
|
66
|
+
|
|
63
67
|
def initialize(predicate, matcher)
|
|
64
68
|
define_singleton_method(:match?, predicate)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
define_singleton_method(:match_captures, matcher)
|
|
68
|
-
singleton_class.send(:private, :match_captures)
|
|
69
|
-
extend CaptureMatching
|
|
69
|
+
install_capture_matcher(matcher) if matcher
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def match(node)
|
|
@@ -77,6 +77,14 @@ module Astel
|
|
|
77
77
|
captures
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def install_capture_matcher(matcher)
|
|
83
|
+
define_singleton_method(:match_captures, matcher)
|
|
84
|
+
singleton_class.send(:private, :match_captures)
|
|
85
|
+
extend CaptureMatching
|
|
86
|
+
end
|
|
87
|
+
|
|
80
88
|
def self.captures?(pattern)
|
|
81
89
|
type, *arguments = pattern
|
|
82
90
|
return true if type == :capture
|
data/lib/astel/refactor.rb
CHANGED
|
@@ -1,192 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
3
|
+
require_relative 'rewriter/structured'
|
|
4
4
|
|
|
5
5
|
module Astel
|
|
6
|
-
|
|
7
|
-
def find_keyword_argument(call_node, name)
|
|
8
|
-
keyword_hashes(call_node).each do |keyword_hash|
|
|
9
|
-
match = keyword_hash.elements.find { |element| keyword_name(element) == name.to_sym }
|
|
10
|
-
return match if match
|
|
11
|
-
end
|
|
12
|
-
nil
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def remove_keyword_argument(call_node, name)
|
|
16
|
-
argument = find_keyword_argument(call_node, name)
|
|
17
|
-
return self unless argument
|
|
18
|
-
|
|
19
|
-
keyword_hash = keyword_hashes(call_node).find { |hash| hash.elements.include?(argument) }
|
|
20
|
-
if keyword_hash.elements.one?
|
|
21
|
-
if call_node.arguments.arguments.one? && !call_node.opening_loc
|
|
22
|
-
return remove(call_node.message_loc.end_offset...keyword_hash.location.end_offset)
|
|
23
|
-
end
|
|
24
|
-
remove_list_element(call_node.arguments, keyword_hash)
|
|
25
|
-
else
|
|
26
|
-
remove_list_element(keyword_hash, argument)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def replace_keyword_key(assoc_node, name)
|
|
31
|
-
replacement = assoc_node.operator_loc ? ":#{name}" : "#{name}:"
|
|
32
|
-
replace(assoc_node.key.location, replacement)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def replace_keyword_value(assoc_node, replacement)
|
|
36
|
-
replace(assoc_node.value.location, replacement)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def insert_keyword_argument(call_node, text, position: :last)
|
|
40
|
-
raise ArgumentError, 'position must be :first or :last' unless %i[first last].include?(position)
|
|
41
|
-
|
|
42
|
-
arguments = call_node.arguments
|
|
43
|
-
unless arguments
|
|
44
|
-
location = call_node.opening_loc || call_node.message_loc
|
|
45
|
-
if call_node.opening_loc && call_node.closing_loc
|
|
46
|
-
return insert_into_empty_delimited(call_node, text)
|
|
47
|
-
end
|
|
48
|
-
return insert_after(location, call_node.opening_loc ? text : " #{text}")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
keyword_hash = keyword_hashes(call_node).first
|
|
52
|
-
if keyword_hash
|
|
53
|
-
index = position == :first ? 0 : keyword_hash.elements.length
|
|
54
|
-
return insert_list_element(keyword_hash, index, text)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
index = arguments.arguments.length
|
|
58
|
-
insert_list_element(arguments, index, text)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def remove_list_element(collection_node, element_node)
|
|
62
|
-
remove_list_elements(collection_node, [element_node])
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def remove_list_elements(collection_node, element_nodes)
|
|
66
|
-
elements = collection_elements(collection_node)
|
|
67
|
-
indexes = element_nodes.map do |element|
|
|
68
|
-
elements.index { |candidate| candidate.equal?(element) } ||
|
|
69
|
-
raise(ArgumentError, 'element does not belong to collection')
|
|
70
|
-
end.uniq.sort
|
|
71
|
-
return self if indexes.empty?
|
|
72
|
-
|
|
73
|
-
trailing_end = trailing_comma_end(elements.last)
|
|
74
|
-
replacements = deletion_ranges(elements, indexes, trailing_end)
|
|
75
|
-
start_offset = elements.first.location.start_offset
|
|
76
|
-
end_offset = trailing_end
|
|
77
|
-
content = @source_file.source.byteslice(start_offset, end_offset - start_offset).dup
|
|
78
|
-
replacements.reverse_each do |from, to, replacement|
|
|
79
|
-
content[(from - start_offset)...(to - start_offset)] = replacement
|
|
80
|
-
end
|
|
81
|
-
replace(start_offset...end_offset, content)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def insert_list_element(collection_node, index, text)
|
|
85
|
-
elements = collection_elements(collection_node)
|
|
86
|
-
raise IndexError, 'list index is outside the collection' unless index.between?(0, elements.length)
|
|
87
|
-
|
|
88
|
-
if elements.empty?
|
|
89
|
-
opening = collection_node.respond_to?(:opening_loc) && collection_node.opening_loc
|
|
90
|
-
raise ArgumentError, 'cannot locate an empty collection body' unless opening
|
|
91
|
-
|
|
92
|
-
return insert_into_empty_delimited(collection_node, text)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
if index < elements.length
|
|
96
|
-
target = elements[index]
|
|
97
|
-
separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(target)}" : ', '
|
|
98
|
-
return insert_before(target.location, "#{text}#{separator}")
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
last = elements.last
|
|
102
|
-
separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(last)}" : ', '
|
|
103
|
-
insert_after(last.location, "#{separator}#{text}")
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
def keyword_hashes(call_node)
|
|
109
|
-
call_node.arguments&.arguments&.grep(Prism::KeywordHashNode) || []
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def keyword_name(assoc_node)
|
|
113
|
-
key = assoc_node.key
|
|
114
|
-
key.unescaped.to_sym if key.is_a?(Prism::SymbolNode)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def collection_elements(collection_node)
|
|
118
|
-
case collection_node
|
|
119
|
-
when Prism::ArgumentsNode then collection_node.arguments
|
|
120
|
-
when Prism::ArrayNode, Prism::KeywordHashNode, Prism::HashNode then collection_node.elements
|
|
121
|
-
else
|
|
122
|
-
raise ArgumentError, 'collection must be an arguments, array, or hash node'
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def deletion_ranges(elements, indexes, trailing_end)
|
|
127
|
-
removed = indexes.to_h { |index| [index, true] }
|
|
128
|
-
groups = indexes.slice_when { |left, right| right != left + 1 }.to_a
|
|
129
|
-
groups.map do |group|
|
|
130
|
-
first = group.first
|
|
131
|
-
last = group.last
|
|
132
|
-
following = ((last + 1)...elements.length).find { |index| !removed[index] }
|
|
133
|
-
preceding = (first - 1).downto(0).find { |index| !removed[index] }
|
|
134
|
-
|
|
135
|
-
if following
|
|
136
|
-
[elements[first].location.start_offset, elements[following].location.start_offset, '']
|
|
137
|
-
elsif preceding
|
|
138
|
-
from = elements[preceding].location.end_offset
|
|
139
|
-
has_trailing_comma = last == elements.length - 1 && trailing_end > elements.last.location.end_offset
|
|
140
|
-
to = has_trailing_comma ? trailing_end : elements[last].location.end_offset
|
|
141
|
-
between = @source_file.source.byteslice(from, elements[first].location.start_offset - from)
|
|
142
|
-
replacement = if has_trailing_comma
|
|
143
|
-
between.include?('#') ? between.rstrip : ','
|
|
144
|
-
elsif between.include?('#')
|
|
145
|
-
between.sub(',', '').rstrip
|
|
146
|
-
else
|
|
147
|
-
''
|
|
148
|
-
end
|
|
149
|
-
[from, to, replacement]
|
|
150
|
-
else
|
|
151
|
-
[elements.first.location.start_offset, trailing_end, '']
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def trailing_comma_end(element)
|
|
157
|
-
offset = element.location.end_offset
|
|
158
|
-
source = @source_file.source
|
|
159
|
-
offset += 1 while [32, 9].include?(source.getbyte(offset))
|
|
160
|
-
source.getbyte(offset) == 44 ? offset + 1 : element.location.end_offset
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def multiline_collection?(collection_node)
|
|
164
|
-
return true if @source_file.slice(collection_node.location).include?("\n")
|
|
165
|
-
|
|
166
|
-
start_offset = collection_node.location.start_offset
|
|
167
|
-
prefix = @source_file.source.byteslice(@source_file.line_start(start_offset),
|
|
168
|
-
start_offset - @source_file.line_start(start_offset))
|
|
169
|
-
collection_node.is_a?(Prism::KeywordHashNode) && !prefix.empty? && prefix.strip.empty?
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def insert_into_empty_delimited(node, text)
|
|
173
|
-
opening = node.opening_loc
|
|
174
|
-
closing = node.closing_loc
|
|
175
|
-
return insert_after(opening, text) unless closing
|
|
176
|
-
|
|
177
|
-
source = @source_file.source
|
|
178
|
-
interior = source.byteslice(opening.end_offset, closing.start_offset - opening.end_offset)
|
|
179
|
-
return insert_after(opening, text) unless interior.include?("\n")
|
|
180
|
-
|
|
181
|
-
indentation = @source_file.indentation_at(closing.start_offset)
|
|
182
|
-
replacement = "#{@source_file.newline}#{indentation}#{@source_file.indent_unit}#{text}" \
|
|
183
|
-
"#{@source_file.newline}#{indentation}"
|
|
184
|
-
replace(opening.end_offset...closing.start_offset, replacement)
|
|
185
|
-
end
|
|
186
|
-
end
|
|
6
|
+
Refactor = Rewriter.const_get(:Structured, false)
|
|
187
7
|
end
|
|
188
|
-
|
|
189
|
-
require_relative 'refactor/formatting'
|
|
190
|
-
require_relative 'refactor/comments'
|
|
191
|
-
|
|
192
|
-
Astel::Rewriter.include(Astel::Refactor)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class Rewriter
|
|
5
|
+
class EditIndex
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
SPLIT_THRESHOLD = 256
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@blocks = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(edit)
|
|
15
|
+
if @blocks.empty?
|
|
16
|
+
@blocks << [edit]
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
block_index, block, edit_index = insertion_position(edit)
|
|
21
|
+
conflict = conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
22
|
+
return conflict if conflict
|
|
23
|
+
|
|
24
|
+
block.insert(edit_index, edit)
|
|
25
|
+
split(block_index, block) if block.length > SPLIT_THRESHOLD
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_conflict(edit)
|
|
30
|
+
return if @blocks.empty?
|
|
31
|
+
|
|
32
|
+
block_index, block, edit_index = insertion_position(edit)
|
|
33
|
+
conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def each
|
|
37
|
+
return enum_for(__method__) unless block_given?
|
|
38
|
+
|
|
39
|
+
@blocks.each do |block|
|
|
40
|
+
block.each { |edit| yield edit }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def insertion_position(edit)
|
|
47
|
+
block_index = block_index_for(edit)
|
|
48
|
+
block = @blocks.fetch(block_index)
|
|
49
|
+
edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
|
|
50
|
+
[block_index, block, edit_index]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def conflict_at(block_index, block, edit_index)
|
|
54
|
+
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
55
|
+
return following if following && yield(following)
|
|
56
|
+
|
|
57
|
+
preceding = if edit_index.positive?
|
|
58
|
+
block[edit_index - 1]
|
|
59
|
+
elsif block_index.positive?
|
|
60
|
+
@blocks[block_index - 1].last
|
|
61
|
+
end
|
|
62
|
+
return preceding if preceding && yield(preceding)
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def block_index_for(edit)
|
|
68
|
+
@blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ordered_after?(existing, edit)
|
|
72
|
+
existing.start_offset > edit.start_offset ||
|
|
73
|
+
(existing.start_offset == edit.start_offset && existing.end_offset > edit.end_offset) ||
|
|
74
|
+
(existing.start_offset == edit.start_offset && existing.end_offset == edit.end_offset &&
|
|
75
|
+
existing.sequence > edit.sequence)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def split(block_index, block)
|
|
79
|
+
middle = block.length / 2
|
|
80
|
+
@blocks.insert(block_index + 1, block.slice!(middle, block.length - middle))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|