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
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
|
|
@@ -29,85 +31,6 @@ module Astel
|
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
class EditIndex
|
|
33
|
-
include Enumerable
|
|
34
|
-
|
|
35
|
-
SPLIT_THRESHOLD = 256
|
|
36
|
-
|
|
37
|
-
def initialize
|
|
38
|
-
@blocks = []
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def add(edit)
|
|
42
|
-
if @blocks.empty?
|
|
43
|
-
@blocks << [edit]
|
|
44
|
-
return
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
block_index, block, edit_index = insertion_position(edit)
|
|
48
|
-
conflict = conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
49
|
-
return conflict if conflict
|
|
50
|
-
|
|
51
|
-
block.insert(edit_index, edit)
|
|
52
|
-
split(block_index, block) if block.length > SPLIT_THRESHOLD
|
|
53
|
-
nil
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def find_conflict(edit)
|
|
57
|
-
return if @blocks.empty?
|
|
58
|
-
|
|
59
|
-
block_index, block, edit_index = insertion_position(edit)
|
|
60
|
-
conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def each
|
|
64
|
-
return enum_for(__method__) unless block_given?
|
|
65
|
-
|
|
66
|
-
@blocks.each do |block|
|
|
67
|
-
block.each { |edit| yield edit }
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
def insertion_position(edit)
|
|
74
|
-
block_index = block_index_for(edit)
|
|
75
|
-
block = @blocks.fetch(block_index)
|
|
76
|
-
edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
|
|
77
|
-
[block_index, block, edit_index]
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def conflict_at(block_index, block, edit_index)
|
|
81
|
-
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
82
|
-
return following if following && yield(following)
|
|
83
|
-
|
|
84
|
-
preceding = if edit_index.positive?
|
|
85
|
-
block[edit_index - 1]
|
|
86
|
-
elsif block_index.positive?
|
|
87
|
-
@blocks[block_index - 1].last
|
|
88
|
-
end
|
|
89
|
-
return preceding if preceding && yield(preceding)
|
|
90
|
-
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def block_index_for(edit)
|
|
95
|
-
@blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def ordered_after?(existing, edit)
|
|
99
|
-
existing.start_offset > edit.start_offset ||
|
|
100
|
-
(existing.start_offset == edit.start_offset && existing.end_offset > edit.end_offset) ||
|
|
101
|
-
(existing.start_offset == edit.start_offset && existing.end_offset == edit.end_offset &&
|
|
102
|
-
existing.sequence > edit.sequence)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def split(block_index, block)
|
|
106
|
-
middle = block.length / 2
|
|
107
|
-
@blocks.insert(block_index + 1, block.slice!(middle, block.length - middle))
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
34
|
attr_reader :source_file
|
|
112
35
|
|
|
113
36
|
def initialize(source_file, duplicate_insertions: :accept)
|
|
@@ -158,27 +81,16 @@ module Astel
|
|
|
158
81
|
end
|
|
159
82
|
|
|
160
83
|
def merge!(other)
|
|
161
|
-
|
|
162
|
-
|
|
84
|
+
validated_edits_from(other).each do |edit|
|
|
85
|
+
add_edit(edit.start_offset, edit.end_offset, edit.replacement)
|
|
163
86
|
end
|
|
164
|
-
|
|
165
|
-
validation_index = EditIndex.new
|
|
166
|
-
@edit_index.each { |edit| validation_index.add(edit) { false } }
|
|
167
|
-
other.edits.each do |edit|
|
|
168
|
-
conflict = validation_index.find_conflict(edit) { |existing| conflict?(edit, existing) }
|
|
169
|
-
raise ConflictError.new(edit, conflict) if conflict
|
|
170
|
-
|
|
171
|
-
validation_index.add(edit) { false }
|
|
172
|
-
end
|
|
173
|
-
other.edits.each { |edit| add_edit(edit.start_offset, edit.end_offset, edit.replacement) }
|
|
174
87
|
self
|
|
175
88
|
end
|
|
176
89
|
|
|
177
90
|
def transaction(raise_on_conflict: false)
|
|
178
|
-
staging =
|
|
179
|
-
staging.instance_variable_set(:@transaction_staging, true)
|
|
91
|
+
staging = transaction_staging
|
|
180
92
|
yield staging
|
|
181
|
-
return transaction_failed if staging
|
|
93
|
+
return transaction_failed if transaction_failed?(staging)
|
|
182
94
|
|
|
183
95
|
merge!(staging)
|
|
184
96
|
true
|
|
@@ -190,6 +102,14 @@ module Astel
|
|
|
190
102
|
end
|
|
191
103
|
|
|
192
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
|
|
193
113
|
source = @source_file.source
|
|
194
114
|
result = String.new(capacity: source.bytesize + @result_size_delta, encoding: source.encoding)
|
|
195
115
|
cursor = 0
|
|
@@ -201,25 +121,40 @@ module Astel
|
|
|
201
121
|
end
|
|
202
122
|
|
|
203
123
|
result << source.byteslice(cursor, source.bytesize - cursor)
|
|
204
|
-
validate_result(result, validate)
|
|
205
124
|
result
|
|
206
125
|
end
|
|
207
126
|
|
|
208
|
-
private
|
|
209
|
-
|
|
210
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)
|
|
211
134
|
validate_offsets(start_offset, end_offset)
|
|
212
|
-
|
|
213
|
-
unless Encoding.compatible?(@source_file.source, replacement)
|
|
214
|
-
raise Encoding::CompatibilityError,
|
|
215
|
-
"#{replacement.encoding} replacement is incompatible with #{@source_file.source.encoding} source"
|
|
216
|
-
end
|
|
217
|
-
edit = Edit.new(
|
|
135
|
+
Edit.new(
|
|
218
136
|
start_offset: start_offset,
|
|
219
137
|
end_offset: end_offset,
|
|
220
|
-
replacement: replacement
|
|
138
|
+
replacement: validated_replacement(replacement),
|
|
221
139
|
sequence: @sequence
|
|
222
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)
|
|
223
158
|
conflict = @edit_index.add(edit) { |existing| conflict?(edit, existing) }
|
|
224
159
|
raise ConflictError.new(edit, conflict) if conflict
|
|
225
160
|
|
|
@@ -227,7 +162,34 @@ module Astel
|
|
|
227
162
|
@result_size_delta += edit.replacement.bytesize - (edit.end_offset - edit.start_offset)
|
|
228
163
|
@edits << edit
|
|
229
164
|
@edits_snapshot = nil
|
|
230
|
-
|
|
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)
|
|
231
193
|
end
|
|
232
194
|
|
|
233
195
|
def offsets(location)
|
|
@@ -239,21 +201,31 @@ module Astel
|
|
|
239
201
|
end
|
|
240
202
|
|
|
241
203
|
def validate_offsets(start_offset, end_offset)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
245
210
|
end
|
|
246
211
|
|
|
247
212
|
def conflict?(left, right)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return left.start_offset > right.start_offset && left.start_offset < right.end_offset if left.insertion?
|
|
252
|
-
return right.start_offset > left.start_offset && right.start_offset < left.end_offset if right.insertion?
|
|
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?
|
|
253
216
|
|
|
254
217
|
left.start_offset < right.end_offset && right.start_offset < left.end_offset
|
|
255
218
|
end
|
|
256
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
|
+
|
|
257
229
|
def transaction_failed
|
|
258
230
|
@transaction_failed = true if @transaction_staging
|
|
259
231
|
false
|
data/lib/astel/source_file.rb
CHANGED
|
@@ -17,10 +17,7 @@ module Astel
|
|
|
17
17
|
def initialize(code, path:, version: nil)
|
|
18
18
|
@path = path.to_s.freeze
|
|
19
19
|
@version = normalize_version(version).freeze if version
|
|
20
|
-
|
|
21
|
-
options = { filepath: @path }
|
|
22
|
-
options[:version] = @version if @version
|
|
23
|
-
@parse_result = Prism.parse(raw_source, **options)
|
|
20
|
+
@parse_result = parse_source(code, path: @path, version: @version)
|
|
24
21
|
@source = @parse_result.source.source.freeze
|
|
25
22
|
@ast = @parse_result.value
|
|
26
23
|
@comments = @parse_result.comments.freeze
|
|
@@ -53,10 +50,8 @@ module Astel
|
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
def magic_comment?(name)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
candidate = line.start_with?("\uFEFF") ? line.delete_prefix("\uFEFF") : line
|
|
59
|
-
candidate.match?(pattern)
|
|
53
|
+
@parse_result.magic_comments.any? do |comment|
|
|
54
|
+
comment.key == name.to_s && comment.value == 'true' && effective_magic_comment?(comment)
|
|
60
55
|
end
|
|
61
56
|
end
|
|
62
57
|
|
|
@@ -85,9 +80,10 @@ module Astel
|
|
|
85
80
|
|
|
86
81
|
def line_end(byte_offset)
|
|
87
82
|
validate_offset!(byte_offset)
|
|
83
|
+
start = line_start(byte_offset)
|
|
88
84
|
ending = @parse_result.source.line_end(byte_offset)
|
|
89
|
-
ending -= 1 if ending
|
|
90
|
-
ending -= 1 if ending
|
|
85
|
+
ending -= 1 if ending > start && source.getbyte(ending - 1) == 10
|
|
86
|
+
ending -= 1 if ending > start && source.getbyte(ending - 1) == 13
|
|
91
87
|
ending
|
|
92
88
|
end
|
|
93
89
|
|
|
@@ -105,10 +101,7 @@ module Astel
|
|
|
105
101
|
end
|
|
106
102
|
|
|
107
103
|
def newline
|
|
108
|
-
@newline ||=
|
|
109
|
-
crlf = source.b.scan(/\r\n/).length
|
|
110
|
-
crlf > source.count("\n") - crlf ? "\r\n" : "\n"
|
|
111
|
-
end
|
|
104
|
+
@newline ||= crlf_dominant? ? "\r\n" : "\n"
|
|
112
105
|
end
|
|
113
106
|
|
|
114
107
|
def indentation_at(byte_offset)
|
|
@@ -117,20 +110,40 @@ module Astel
|
|
|
117
110
|
end
|
|
118
111
|
|
|
119
112
|
def indent_unit
|
|
120
|
-
@indent_unit ||=
|
|
121
|
-
indentations = lines.filter_map { |line| line[/\A[ \t]+/] }
|
|
122
|
-
if indentations.any? { |indentation| indentation.include?("\t") }
|
|
123
|
-
"\t"
|
|
124
|
-
else
|
|
125
|
-
widths = [0, *indentations.map(&:bytesize)].uniq.sort
|
|
126
|
-
width = widths.each_cons(2).map { |left, right| right - left }.min
|
|
127
|
-
width ? ' ' * width : ' '
|
|
128
|
-
end
|
|
129
|
-
end
|
|
113
|
+
@indent_unit ||= detected_indent_unit
|
|
130
114
|
end
|
|
131
115
|
|
|
132
116
|
private
|
|
133
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
|
+
|
|
134
147
|
def location(start_offset, end_offset)
|
|
135
148
|
prism_source = @parse_result.source
|
|
136
149
|
Location.new(
|
|
@@ -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
|
|
@@ -55,12 +55,18 @@ files:
|
|
|
55
55
|
- lib/astel/node_pattern/matcher_compiler.rb
|
|
56
56
|
- lib/astel/node_pattern/parser.rb
|
|
57
57
|
- lib/astel/node_pattern/predicate_compiler.rb
|
|
58
|
+
- lib/astel/node_source_text.rb
|
|
58
59
|
- lib/astel/node_type.rb
|
|
59
60
|
- lib/astel/refactor.rb
|
|
60
|
-
- lib/astel/refactor/comments.rb
|
|
61
|
-
- lib/astel/refactor/formatting.rb
|
|
62
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
|
|
63
68
|
- lib/astel/source_file.rb
|
|
69
|
+
- lib/astel/unified_diff.rb
|
|
64
70
|
- lib/astel/version.rb
|
|
65
71
|
homepage: https://rubygems.org/gems/astel
|
|
66
72
|
licenses:
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Astel
|
|
4
|
-
module Refactor
|
|
5
|
-
def leading_comments(node)
|
|
6
|
-
attach_comments
|
|
7
|
-
node.location.leading_comments
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def trailing_comment(node)
|
|
11
|
-
attach_comments
|
|
12
|
-
node.location.trailing_comments.first
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def remove_with_comments(node)
|
|
16
|
-
comments = leading_comments(node)
|
|
17
|
-
trailing = trailing_comment(node)
|
|
18
|
-
start_offset = comments.empty? ? node.location.start_offset : comments.first.location.start_offset
|
|
19
|
-
end_offset = trailing ? trailing.location.end_offset : node.location.end_offset
|
|
20
|
-
first = @source_file.line_location(start_offset)
|
|
21
|
-
last = @source_file.line_location(end_offset - 1)
|
|
22
|
-
before = @source_file.source.byteslice(first.start_offset, start_offset - first.start_offset)
|
|
23
|
-
after = @source_file.source.byteslice(end_offset, last.end_offset - end_offset)
|
|
24
|
-
return remove(start_offset...end_offset) unless before.strip.empty? && after.strip.empty?
|
|
25
|
-
|
|
26
|
-
ending = @source_file.line_location_with_newline(end_offset - 1).end_offset
|
|
27
|
-
remove(first.start_offset...ending)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def attach_comments
|
|
33
|
-
@source_file.attach_comments!
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|