astel 0.1.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 +26 -0
- data/README.md +166 -41
- data/docs/codemod-guide.md +51 -0
- data/docs/refactor.md +87 -0
- data/lib/astel/diff.rb +8 -0
- data/lib/astel/dispatcher.rb +47 -31
- data/lib/astel/location.rb +69 -0
- 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 +7 -0
- 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 +141 -70
- data/lib/astel/source_file.rb +111 -10
- data/lib/astel/unified_diff.rb +205 -0
- data/lib/astel/version.rb +1 -1
- data/lib/astel.rb +1 -0
- metadata +14 -2
data/lib/astel/rewriter.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'rewriter/edit_index'
|
|
4
|
+
|
|
3
5
|
module Astel
|
|
4
6
|
class Rewriter
|
|
5
7
|
class ConflictError < Error
|
|
@@ -13,79 +15,38 @@ module Astel
|
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
class ValidationError < Error
|
|
19
|
+
attr_reader :result, :parse_errors
|
|
20
|
+
|
|
21
|
+
def initialize(result, parse_errors)
|
|
22
|
+
@result = result
|
|
23
|
+
@parse_errors = parse_errors.freeze
|
|
24
|
+
super("rewritten source contains #{parse_errors.length} syntax error(s)")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
Edit = Data.define(:start_offset, :end_offset, :replacement, :sequence) do
|
|
17
29
|
def insertion?
|
|
18
30
|
start_offset == end_offset
|
|
19
31
|
end
|
|
20
32
|
end
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
include Enumerable
|
|
24
|
-
|
|
25
|
-
SPLIT_THRESHOLD = 256
|
|
26
|
-
|
|
27
|
-
def initialize
|
|
28
|
-
@blocks = []
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def add(edit)
|
|
32
|
-
if @blocks.empty?
|
|
33
|
-
@blocks << [edit]
|
|
34
|
-
return
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
block_index = block_index_for(edit)
|
|
38
|
-
block = @blocks.fetch(block_index)
|
|
39
|
-
edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
|
|
40
|
-
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
41
|
-
return following if following && yield(following)
|
|
42
|
-
|
|
43
|
-
preceding = if edit_index.positive?
|
|
44
|
-
block[edit_index - 1]
|
|
45
|
-
elsif block_index.positive?
|
|
46
|
-
@blocks[block_index - 1].last
|
|
47
|
-
end
|
|
48
|
-
return preceding if preceding && yield(preceding)
|
|
49
|
-
|
|
50
|
-
block.insert(edit_index, edit)
|
|
51
|
-
split(block_index, block) if block.length > SPLIT_THRESHOLD
|
|
52
|
-
nil
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def each
|
|
56
|
-
return enum_for(__method__) unless block_given?
|
|
57
|
-
|
|
58
|
-
@blocks.each do |block|
|
|
59
|
-
block.each { |edit| yield edit }
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
def block_index_for(edit)
|
|
66
|
-
@blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def ordered_after?(existing, edit)
|
|
70
|
-
existing.start_offset > edit.start_offset ||
|
|
71
|
-
(existing.start_offset == edit.start_offset && existing.end_offset > edit.end_offset) ||
|
|
72
|
-
(existing.start_offset == edit.start_offset && existing.end_offset == edit.end_offset &&
|
|
73
|
-
existing.sequence > edit.sequence)
|
|
74
|
-
end
|
|
34
|
+
attr_reader :source_file
|
|
75
35
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
36
|
+
def initialize(source_file, duplicate_insertions: :accept)
|
|
37
|
+
unless %i[accept raise].include?(duplicate_insertions)
|
|
38
|
+
raise ArgumentError, 'duplicate_insertions must be :accept or :raise'
|
|
79
39
|
end
|
|
80
|
-
end
|
|
81
40
|
|
|
82
|
-
def initialize(source_file)
|
|
83
41
|
@source_file = source_file
|
|
42
|
+
@duplicate_insertions = duplicate_insertions
|
|
84
43
|
@edits = []
|
|
85
44
|
@edits_snapshot = nil
|
|
86
45
|
@edit_index = EditIndex.new
|
|
87
46
|
@sequence = 0
|
|
88
47
|
@result_size_delta = 0
|
|
48
|
+
@transaction_staging = false
|
|
49
|
+
@transaction_failed = false
|
|
89
50
|
end
|
|
90
51
|
|
|
91
52
|
def edits
|
|
@@ -111,7 +72,44 @@ module Astel
|
|
|
111
72
|
replace(location, '')
|
|
112
73
|
end
|
|
113
74
|
|
|
114
|
-
def
|
|
75
|
+
def wrap(location, before, after)
|
|
76
|
+
transaction(raise_on_conflict: true) do |rewriter|
|
|
77
|
+
rewriter.insert_before(location, before)
|
|
78
|
+
rewriter.insert_after(location, after)
|
|
79
|
+
end
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def merge!(other)
|
|
84
|
+
validated_edits_from(other).each do |edit|
|
|
85
|
+
add_edit(edit.start_offset, edit.end_offset, edit.replacement)
|
|
86
|
+
end
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transaction(raise_on_conflict: false)
|
|
91
|
+
staging = transaction_staging
|
|
92
|
+
yield staging
|
|
93
|
+
return transaction_failed if transaction_failed?(staging)
|
|
94
|
+
|
|
95
|
+
merge!(staging)
|
|
96
|
+
true
|
|
97
|
+
rescue ConflictError
|
|
98
|
+
@transaction_failed = true if @transaction_staging
|
|
99
|
+
raise if raise_on_conflict
|
|
100
|
+
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def rewrite(validate: false)
|
|
105
|
+
result = apply_edits
|
|
106
|
+
validate_result(result, validate)
|
|
107
|
+
result
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def apply_edits
|
|
115
113
|
source = @source_file.source
|
|
116
114
|
result = String.new(capacity: source.bytesize + @result_size_delta, encoding: source.encoding)
|
|
117
115
|
cursor = 0
|
|
@@ -126,16 +124,37 @@ module Astel
|
|
|
126
124
|
result
|
|
127
125
|
end
|
|
128
126
|
|
|
129
|
-
private
|
|
130
|
-
|
|
131
127
|
def add_edit(start_offset, end_offset, replacement)
|
|
128
|
+
edit = build_edit(start_offset, end_offset, replacement)
|
|
129
|
+
register_edit(edit)
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_edit(start_offset, end_offset, replacement)
|
|
132
134
|
validate_offsets(start_offset, end_offset)
|
|
133
|
-
|
|
135
|
+
Edit.new(
|
|
134
136
|
start_offset: start_offset,
|
|
135
137
|
end_offset: end_offset,
|
|
136
|
-
replacement: replacement
|
|
138
|
+
replacement: validated_replacement(replacement),
|
|
137
139
|
sequence: @sequence
|
|
138
140
|
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validated_replacement(replacement)
|
|
144
|
+
replacement = replacement.to_s
|
|
145
|
+
unless replacement.valid_encoding?
|
|
146
|
+
raise Encoding::CompatibilityError, "#{replacement.encoding} replacement contains invalid bytes"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
replacement.encode(@source_file.source.encoding).freeze
|
|
151
|
+
rescue EncodingError
|
|
152
|
+
raise Encoding::CompatibilityError,
|
|
153
|
+
"#{replacement.encoding} replacement is incompatible with #{@source_file.source.encoding} source"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def register_edit(edit)
|
|
139
158
|
conflict = @edit_index.add(edit) { |existing| conflict?(edit, existing) }
|
|
140
159
|
raise ConflictError.new(edit, conflict) if conflict
|
|
141
160
|
|
|
@@ -143,7 +162,34 @@ module Astel
|
|
|
143
162
|
@result_size_delta += edit.replacement.bytesize - (edit.end_offset - edit.start_offset)
|
|
144
163
|
@edits << edit
|
|
145
164
|
@edits_snapshot = nil
|
|
146
|
-
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validated_edits_from(other)
|
|
168
|
+
raise ArgumentError, 'cannot merge edits for a different source' unless same_source_rewriter?(other)
|
|
169
|
+
|
|
170
|
+
validation_index = EditIndex.new
|
|
171
|
+
@edit_index.each { |edit| validation_index.add(edit) { false } }
|
|
172
|
+
other.edits.each do |edit|
|
|
173
|
+
conflict = validation_index.find_conflict(edit) { |existing| conflict?(edit, existing) }
|
|
174
|
+
raise ConflictError.new(edit, conflict) if conflict
|
|
175
|
+
|
|
176
|
+
validation_index.add(edit) { false }
|
|
177
|
+
end
|
|
178
|
+
other.edits
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def same_source_rewriter?(other)
|
|
182
|
+
other.is_a?(Rewriter) && other.source_file.equal?(@source_file)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def transaction_staging
|
|
186
|
+
staging = Rewriter.new(@source_file, duplicate_insertions: @duplicate_insertions)
|
|
187
|
+
staging.instance_variable_set(:@transaction_staging, true)
|
|
188
|
+
staging
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def transaction_failed?(staging)
|
|
192
|
+
staging.instance_variable_get(:@transaction_failed)
|
|
147
193
|
end
|
|
148
194
|
|
|
149
195
|
def offsets(location)
|
|
@@ -155,17 +201,42 @@ module Astel
|
|
|
155
201
|
end
|
|
156
202
|
|
|
157
203
|
def validate_offsets(start_offset, end_offset)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
raise RangeError, 'edit range is outside the source' unless valid_offsets?(start_offset, end_offset)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def valid_offsets?(start_offset, end_offset)
|
|
208
|
+
start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
|
|
209
|
+
start_offset >= 0 && start_offset <= end_offset && end_offset <= @source_file.source.bytesize
|
|
161
210
|
end
|
|
162
211
|
|
|
163
212
|
def conflict?(left, right)
|
|
164
|
-
return
|
|
165
|
-
return left
|
|
166
|
-
return right
|
|
213
|
+
return true if duplicate_insertion_conflict?(left, right)
|
|
214
|
+
return insertion_inside?(left, right) if left.insertion?
|
|
215
|
+
return insertion_inside?(right, left) if right.insertion?
|
|
167
216
|
|
|
168
217
|
left.start_offset < right.end_offset && right.start_offset < left.end_offset
|
|
169
218
|
end
|
|
219
|
+
|
|
220
|
+
def duplicate_insertion_conflict?(left, right)
|
|
221
|
+
@duplicate_insertions == :raise && left.insertion? && right.insertion? &&
|
|
222
|
+
left.start_offset == right.start_offset
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def insertion_inside?(insertion, edit)
|
|
226
|
+
insertion.start_offset > edit.start_offset && insertion.start_offset < edit.end_offset
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def transaction_failed
|
|
230
|
+
@transaction_failed = true if @transaction_staging
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def validate_result(result, validate)
|
|
235
|
+
return unless validate
|
|
236
|
+
raise ArgumentError, 'validate must be false or :parse' unless validate == :parse
|
|
237
|
+
|
|
238
|
+
parsed = SourceFile.from_string(result, path: @source_file.path, version: @source_file.version)
|
|
239
|
+
raise ValidationError.new(result, parsed.errors) unless parsed.valid?
|
|
240
|
+
end
|
|
170
241
|
end
|
|
171
242
|
end
|
data/lib/astel/source_file.rb
CHANGED
|
@@ -4,7 +4,7 @@ require 'prism'
|
|
|
4
4
|
|
|
5
5
|
module Astel
|
|
6
6
|
class SourceFile
|
|
7
|
-
attr_reader :path, :source, :ast, :comments, :errors, :parse_result
|
|
7
|
+
attr_reader :path, :source, :ast, :comments, :errors, :parse_result, :version
|
|
8
8
|
|
|
9
9
|
def self.parse(path:, version: nil)
|
|
10
10
|
from_string(File.binread(path), path: path, version: version)
|
|
@@ -16,10 +16,8 @@ module Astel
|
|
|
16
16
|
|
|
17
17
|
def initialize(code, path:, version: nil)
|
|
18
18
|
@path = path.to_s.freeze
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
options[:version] = normalize_version(version) if version
|
|
22
|
-
@parse_result = Prism.parse(raw_source, **options)
|
|
19
|
+
@version = normalize_version(version).freeze if version
|
|
20
|
+
@parse_result = parse_source(code, path: @path, version: @version)
|
|
23
21
|
@source = @parse_result.source.source.freeze
|
|
24
22
|
@ast = @parse_result.value
|
|
25
23
|
@comments = @parse_result.comments.freeze
|
|
@@ -47,20 +45,123 @@ module Astel
|
|
|
47
45
|
start_line: 1,
|
|
48
46
|
start_column: 0,
|
|
49
47
|
end_line: 1,
|
|
50
|
-
end_column: line.delete_suffix("\n").delete_suffix("\r").
|
|
48
|
+
end_column: line.delete_suffix("\n").delete_suffix("\r").bytesize
|
|
51
49
|
)
|
|
52
50
|
end
|
|
53
51
|
|
|
54
52
|
def magic_comment?(name)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
candidate = line.start_with?("\uFEFF") ? line.delete_prefix("\uFEFF") : line
|
|
58
|
-
candidate.match?(pattern)
|
|
53
|
+
@parse_result.magic_comments.any? do |comment|
|
|
54
|
+
comment.key == name.to_s && comment.value == 'true' && effective_magic_comment?(comment)
|
|
59
55
|
end
|
|
60
56
|
end
|
|
61
57
|
|
|
58
|
+
def attach_comments!
|
|
59
|
+
return self if @comments_attached
|
|
60
|
+
|
|
61
|
+
@parse_result.attach_comments!
|
|
62
|
+
@comments_attached = true
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def line_at(byte_offset)
|
|
67
|
+
validate_offset!(byte_offset)
|
|
68
|
+
@parse_result.source.line(byte_offset)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def column_at(byte_offset)
|
|
72
|
+
validate_offset!(byte_offset)
|
|
73
|
+
@parse_result.source.column(byte_offset)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def line_start(byte_offset)
|
|
77
|
+
validate_offset!(byte_offset)
|
|
78
|
+
@parse_result.source.line_start(byte_offset)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def line_end(byte_offset)
|
|
82
|
+
validate_offset!(byte_offset)
|
|
83
|
+
start = line_start(byte_offset)
|
|
84
|
+
ending = @parse_result.source.line_end(byte_offset)
|
|
85
|
+
ending -= 1 if ending > start && source.getbyte(ending - 1) == 10
|
|
86
|
+
ending -= 1 if ending > start && source.getbyte(ending - 1) == 13
|
|
87
|
+
ending
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def line_location(byte_offset)
|
|
91
|
+
location(line_start(byte_offset), line_end(byte_offset))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def line_location_with_newline(byte_offset)
|
|
95
|
+
validate_offset!(byte_offset)
|
|
96
|
+
location(line_start(byte_offset), @parse_result.source.line_end(byte_offset))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def slice(location)
|
|
100
|
+
source.byteslice(location.start_offset, location.end_offset - location.start_offset)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def newline
|
|
104
|
+
@newline ||= crlf_dominant? ? "\r\n" : "\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def indentation_at(byte_offset)
|
|
108
|
+
start_offset = line_start(byte_offset)
|
|
109
|
+
source.byteslice(start_offset, line_end(byte_offset) - start_offset)[/\A[ \t]*/]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def indent_unit
|
|
113
|
+
@indent_unit ||= detected_indent_unit
|
|
114
|
+
end
|
|
115
|
+
|
|
62
116
|
private
|
|
63
117
|
|
|
118
|
+
def effective_magic_comment?(comment)
|
|
119
|
+
line = @parse_result.source.line(comment.key_loc.start_offset)
|
|
120
|
+
line == 1 || (line == 2 && source.b.start_with?('#!'.b))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_source(code, path:, version:)
|
|
124
|
+
options = { filepath: path }
|
|
125
|
+
options[:version] = version if version
|
|
126
|
+
Prism.parse(code.dup.freeze, **options)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def crlf_dominant?
|
|
130
|
+
crlf_count = source.b.scan(/\r\n/).length
|
|
131
|
+
crlf_count > source.count("\n") - crlf_count
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def detected_indent_unit
|
|
135
|
+
indentations = lines.filter_map { |line| line[/\A[ \t]+/] }
|
|
136
|
+
return "\t" if indentations.any? { |indentation| indentation.include?("\t") }
|
|
137
|
+
|
|
138
|
+
space_indent_unit(indentations)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def space_indent_unit(indentations)
|
|
142
|
+
widths = [0, *indentations.map(&:bytesize)].uniq.sort
|
|
143
|
+
width = widths.each_cons(2).map { |left, right| right - left }.min
|
|
144
|
+
width ? ' ' * width : ' '
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def location(start_offset, end_offset)
|
|
148
|
+
prism_source = @parse_result.source
|
|
149
|
+
Location.new(
|
|
150
|
+
start_offset: start_offset,
|
|
151
|
+
end_offset: end_offset,
|
|
152
|
+
start_line: prism_source.line(start_offset),
|
|
153
|
+
start_column: prism_source.column(start_offset),
|
|
154
|
+
end_line: prism_source.line(end_offset),
|
|
155
|
+
end_column: prism_source.column(end_offset)
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_offset!(byte_offset)
|
|
160
|
+
unless byte_offset.is_a?(Integer) && byte_offset.between?(0, source.bytesize)
|
|
161
|
+
raise RangeError, 'offset is outside the source'
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
64
165
|
def normalize_version(version)
|
|
65
166
|
version.match?(/\A\d+\.\d+\z/) ? "#{version}.0" : version
|
|
66
167
|
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../astel'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
module UnifiedDiff
|
|
7
|
+
MYERS_TRACE_DISTANCE_LIMIT = 256
|
|
8
|
+
private_constant :MYERS_TRACE_DISTANCE_LIMIT
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def unified(before, after, path:, context: 3)
|
|
13
|
+
validate_options(path, context)
|
|
14
|
+
return '' if before == after
|
|
15
|
+
|
|
16
|
+
operations = operations(before.lines(chomp: false), after.lines(chomp: false))
|
|
17
|
+
annotated = annotate(operations)
|
|
18
|
+
hunks = hunk_ranges(annotated, context)
|
|
19
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
20
|
+
output << '--- a/'.b << path.to_s.b << "\n+++ b/".b << path.to_s.b << "\n".b
|
|
21
|
+
hunks.each { |range| append_hunk(output, annotated[range]) }
|
|
22
|
+
output
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def operations(before, after)
|
|
26
|
+
prefix = common_prefix_length(before, after)
|
|
27
|
+
suffix = common_suffix_length(before, after, prefix)
|
|
28
|
+
|
|
29
|
+
old_middle = before[prefix...(before.length - suffix)]
|
|
30
|
+
new_middle = after[prefix...(after.length - suffix)]
|
|
31
|
+
before.first(prefix).map { |line| [:equal, line] } +
|
|
32
|
+
myers_operations(old_middle, new_middle) +
|
|
33
|
+
(suffix.zero? ? [] : before.last(suffix).map { |line| [:equal, line] })
|
|
34
|
+
end
|
|
35
|
+
private_class_method :operations
|
|
36
|
+
|
|
37
|
+
def common_prefix_length(before, after)
|
|
38
|
+
length = 0
|
|
39
|
+
length += 1 while length < before.length && length < after.length && before[length] == after[length]
|
|
40
|
+
length
|
|
41
|
+
end
|
|
42
|
+
private_class_method :common_prefix_length
|
|
43
|
+
|
|
44
|
+
def common_suffix_length(before, after, prefix_length)
|
|
45
|
+
length = 0
|
|
46
|
+
while length < before.length - prefix_length && length < after.length - prefix_length &&
|
|
47
|
+
before[-length - 1] == after[-length - 1]
|
|
48
|
+
length += 1
|
|
49
|
+
end
|
|
50
|
+
length
|
|
51
|
+
end
|
|
52
|
+
private_class_method :common_suffix_length
|
|
53
|
+
|
|
54
|
+
def myers_operations(before, after)
|
|
55
|
+
frontier = Hash.new(0)
|
|
56
|
+
frontier[1] = 0
|
|
57
|
+
trace = []
|
|
58
|
+
|
|
59
|
+
(0..(before.length + after.length)).each do |distance|
|
|
60
|
+
# ponytail: use linear-space backtracking if minimal large rewrites become necessary.
|
|
61
|
+
return replacement_operations(before, after) if distance > MYERS_TRACE_DISTANCE_LIMIT
|
|
62
|
+
|
|
63
|
+
trace << frontier.dup
|
|
64
|
+
(-distance).step(distance, 2) do |diagonal|
|
|
65
|
+
old_index = next_old_index(frontier, diagonal, distance)
|
|
66
|
+
new_index = old_index - diagonal
|
|
67
|
+
while old_index < before.length && new_index < after.length && before[old_index] == after[new_index]
|
|
68
|
+
old_index += 1
|
|
69
|
+
new_index += 1
|
|
70
|
+
end
|
|
71
|
+
frontier[diagonal] = old_index
|
|
72
|
+
return backtrack_myers(before, after, trace, distance) if
|
|
73
|
+
old_index >= before.length && new_index >= after.length
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :myers_operations
|
|
78
|
+
|
|
79
|
+
def replacement_operations(before, after)
|
|
80
|
+
before.map { |line| [:delete, line] } + after.map { |line| [:insert, line] }
|
|
81
|
+
end
|
|
82
|
+
private_class_method :replacement_operations
|
|
83
|
+
|
|
84
|
+
def next_old_index(frontier, diagonal, distance)
|
|
85
|
+
if diagonal == -distance || (diagonal != distance && frontier[diagonal - 1] < frontier[diagonal + 1])
|
|
86
|
+
frontier[diagonal + 1]
|
|
87
|
+
else
|
|
88
|
+
frontier[diagonal - 1] + 1
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
private_class_method :next_old_index
|
|
92
|
+
|
|
93
|
+
def backtrack_myers(before, after, trace, distance)
|
|
94
|
+
old_index = before.length
|
|
95
|
+
new_index = after.length
|
|
96
|
+
operations = []
|
|
97
|
+
|
|
98
|
+
distance.downto(0) do |depth|
|
|
99
|
+
previous = trace[depth]
|
|
100
|
+
diagonal = old_index - new_index
|
|
101
|
+
previous_diagonal = if diagonal == -depth ||
|
|
102
|
+
(diagonal != depth && previous[diagonal - 1] < previous[diagonal + 1])
|
|
103
|
+
diagonal + 1
|
|
104
|
+
else
|
|
105
|
+
diagonal - 1
|
|
106
|
+
end
|
|
107
|
+
previous_old_index = previous[previous_diagonal]
|
|
108
|
+
previous_new_index = previous_old_index - previous_diagonal
|
|
109
|
+
|
|
110
|
+
while old_index > previous_old_index && new_index > previous_new_index
|
|
111
|
+
operations << [:equal, before[old_index - 1]]
|
|
112
|
+
old_index -= 1
|
|
113
|
+
new_index -= 1
|
|
114
|
+
end
|
|
115
|
+
next if depth.zero?
|
|
116
|
+
|
|
117
|
+
if old_index == previous_old_index
|
|
118
|
+
operations << [:insert, after[new_index - 1]]
|
|
119
|
+
new_index -= 1
|
|
120
|
+
else
|
|
121
|
+
operations << [:delete, before[old_index - 1]]
|
|
122
|
+
old_index -= 1
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
operations.reverse
|
|
127
|
+
end
|
|
128
|
+
private_class_method :backtrack_myers
|
|
129
|
+
|
|
130
|
+
def annotate(operations)
|
|
131
|
+
old_line = 1
|
|
132
|
+
new_line = 1
|
|
133
|
+
operations.map do |type, line|
|
|
134
|
+
entry = { type: type, line: line, old_line: old_line, new_line: new_line }
|
|
135
|
+
old_line += 1 unless type == :insert
|
|
136
|
+
new_line += 1 unless type == :delete
|
|
137
|
+
entry
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
private_class_method :annotate
|
|
141
|
+
|
|
142
|
+
def hunk_ranges(operations, context)
|
|
143
|
+
changed = operations.each_index.reject { |index| operations[index][:type] == :equal }
|
|
144
|
+
ranges = changed.map do |index|
|
|
145
|
+
[index - context, 0].max..[index + context, operations.length - 1].min
|
|
146
|
+
end
|
|
147
|
+
merge_adjacent_ranges(ranges)
|
|
148
|
+
end
|
|
149
|
+
private_class_method :hunk_ranges
|
|
150
|
+
|
|
151
|
+
def merge_adjacent_ranges(ranges)
|
|
152
|
+
ranges.each_with_object([]) do |range, merged|
|
|
153
|
+
if merged.last && range.begin <= merged.last.end + 1
|
|
154
|
+
merged[-1] = merged.last.begin..[merged.last.end, range.end].max
|
|
155
|
+
else
|
|
156
|
+
merged << range
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
private_class_method :merge_adjacent_ranges
|
|
161
|
+
|
|
162
|
+
def append_hunk(output, entries)
|
|
163
|
+
output << hunk_header(entries)
|
|
164
|
+
entries.each { |entry| append_hunk_entry(output, entry) }
|
|
165
|
+
end
|
|
166
|
+
private_class_method :append_hunk
|
|
167
|
+
|
|
168
|
+
def append_hunk_entry(output, entry)
|
|
169
|
+
marker = { equal: ' ', delete: '-', insert: '+' }.fetch(entry[:type])
|
|
170
|
+
output << marker << entry[:line].b
|
|
171
|
+
output << "\n\\n" unless entry[:line].end_with?("\n")
|
|
172
|
+
end
|
|
173
|
+
private_class_method :append_hunk_entry
|
|
174
|
+
|
|
175
|
+
def hunk_header(entries)
|
|
176
|
+
old_count = entries.count { |entry| entry[:type] != :insert }
|
|
177
|
+
new_count = entries.count { |entry| entry[:type] != :delete }
|
|
178
|
+
old_start = entries.first[:old_line] - (old_count.zero? ? 1 : 0)
|
|
179
|
+
new_start = entries.first[:new_line] - (new_count.zero? ? 1 : 0)
|
|
180
|
+
"@@ -#{range(old_start, old_count)} +#{range(new_start, new_count)} @@\n"
|
|
181
|
+
end
|
|
182
|
+
private_class_method :hunk_header
|
|
183
|
+
|
|
184
|
+
def range(start_line, count)
|
|
185
|
+
count == 1 ? start_line.to_s : "#{start_line},#{count}"
|
|
186
|
+
end
|
|
187
|
+
private_class_method :range
|
|
188
|
+
|
|
189
|
+
def validate_options(path, context)
|
|
190
|
+
if path.to_s.empty? || path.to_s.match?(/[\t\r\n]/)
|
|
191
|
+
raise ArgumentError, 'path must not be empty or contain control separators'
|
|
192
|
+
end
|
|
193
|
+
raise ArgumentError, 'context must be a non-negative integer' unless context.is_a?(Integer) && context >= 0
|
|
194
|
+
end
|
|
195
|
+
private_class_method :validate_options
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
module Diffable
|
|
199
|
+
def to_diff(context: 3, path: @source_file.path)
|
|
200
|
+
UnifiedDiff.unified(@source_file.source, rewrite, path: path, context: context)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
Astel::Rewriter.include(Astel::Diffable)
|
data/lib/astel/version.rb
CHANGED
data/lib/astel.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Astel
|
|
|
7
7
|
|
|
8
8
|
autoload :Location, File.expand_path('astel/location', __dir__)
|
|
9
9
|
autoload :SourceFile, File.expand_path('astel/source_file', __dir__)
|
|
10
|
+
autoload :NodeSourceText, File.expand_path('astel/node_source_text', __dir__)
|
|
10
11
|
autoload :NodeExt, File.expand_path('astel/node_ext', __dir__)
|
|
11
12
|
autoload :NodeType, File.expand_path('astel/node_type', __dir__)
|
|
12
13
|
autoload :Dispatcher, File.expand_path('astel/dispatcher', __dir__)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: astel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -43,7 +43,10 @@ files:
|
|
|
43
43
|
- benchmark/dispatch_bench.rb
|
|
44
44
|
- benchmark/node_pattern_bench.rb
|
|
45
45
|
- benchmark/rewriter_bench.rb
|
|
46
|
+
- docs/codemod-guide.md
|
|
47
|
+
- docs/refactor.md
|
|
46
48
|
- lib/astel.rb
|
|
49
|
+
- lib/astel/diff.rb
|
|
47
50
|
- lib/astel/dispatcher.rb
|
|
48
51
|
- lib/astel/location.rb
|
|
49
52
|
- lib/astel/node_ext.rb
|
|
@@ -52,9 +55,18 @@ files:
|
|
|
52
55
|
- lib/astel/node_pattern/matcher_compiler.rb
|
|
53
56
|
- lib/astel/node_pattern/parser.rb
|
|
54
57
|
- lib/astel/node_pattern/predicate_compiler.rb
|
|
58
|
+
- lib/astel/node_source_text.rb
|
|
55
59
|
- lib/astel/node_type.rb
|
|
60
|
+
- lib/astel/refactor.rb
|
|
56
61
|
- lib/astel/rewriter.rb
|
|
62
|
+
- lib/astel/rewriter/edit_index.rb
|
|
63
|
+
- lib/astel/rewriter/structured.rb
|
|
64
|
+
- lib/astel/rewriter/structured/collections.rb
|
|
65
|
+
- lib/astel/rewriter/structured/comments.rb
|
|
66
|
+
- lib/astel/rewriter/structured/formatting.rb
|
|
67
|
+
- lib/astel/rewriter/structured/source_indenter.rb
|
|
57
68
|
- lib/astel/source_file.rb
|
|
69
|
+
- lib/astel/unified_diff.rb
|
|
58
70
|
- lib/astel/version.rb
|
|
59
71
|
homepage: https://rubygems.org/gems/astel
|
|
60
72
|
licenses:
|
|
@@ -75,7 +87,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
75
87
|
- !ruby/object:Gem::Version
|
|
76
88
|
version: '0'
|
|
77
89
|
requirements: []
|
|
78
|
-
rubygems_version:
|
|
90
|
+
rubygems_version: 3.6.9
|
|
79
91
|
specification_version: 4
|
|
80
92
|
summary: Fast Prism AST traversal, matching, and source rewriting
|
|
81
93
|
test_files: []
|