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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class Rewriter
|
|
5
|
+
module Structured
|
|
6
|
+
def remove_list_element(collection_node, element_node)
|
|
7
|
+
remove_list_elements(collection_node, [element_node])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def remove_list_elements(collection_node, element_nodes)
|
|
11
|
+
elements = collection_elements(collection_node)
|
|
12
|
+
indexes = element_indexes(elements, element_nodes)
|
|
13
|
+
return self if indexes.empty?
|
|
14
|
+
|
|
15
|
+
trailing_end = trailing_comma_end(elements.last)
|
|
16
|
+
start_offset = elements.first.location.start_offset
|
|
17
|
+
content = content_without_elements(
|
|
18
|
+
elements, indexes, start_offset: start_offset, end_offset: trailing_end
|
|
19
|
+
)
|
|
20
|
+
extra_ranges = indexes.flat_map { |index| heredoc_ranges(elements[index]) }
|
|
21
|
+
return replace(start_offset...trailing_end, content) if extra_ranges.empty?
|
|
22
|
+
|
|
23
|
+
transaction(raise_on_conflict: true) do |rewriter|
|
|
24
|
+
rewriter.replace(start_offset...trailing_end, content)
|
|
25
|
+
extra_ranges.each { |range| rewriter.remove(range) }
|
|
26
|
+
end
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def insert_list_element(collection_node, index, text)
|
|
31
|
+
elements = collection_elements(collection_node)
|
|
32
|
+
raise IndexError, 'list index is outside the collection' unless index.between?(0, elements.length)
|
|
33
|
+
|
|
34
|
+
if elements.empty?
|
|
35
|
+
opening = collection_node.respond_to?(:opening_loc) && collection_node.opening_loc
|
|
36
|
+
raise ArgumentError, 'cannot locate an empty collection body' unless opening
|
|
37
|
+
|
|
38
|
+
return insert_into_empty_delimited(collection_node, text)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if index < elements.length
|
|
42
|
+
target = elements[index]
|
|
43
|
+
return insert_before(target.location, "#{text}#{list_separator(collection_node, target)}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
last = elements.last
|
|
47
|
+
insert_after(last.location, "#{list_separator(collection_node, last)}#{text}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def element_indexes(elements, element_nodes)
|
|
53
|
+
element_nodes.map do |element|
|
|
54
|
+
elements.index { |candidate| candidate.equal?(element) } ||
|
|
55
|
+
raise(ArgumentError, 'element does not belong to collection')
|
|
56
|
+
end.uniq.sort
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def content_without_elements(elements, indexes, start_offset:, end_offset:)
|
|
60
|
+
content = @source_file.source.byteslice(start_offset, end_offset - start_offset).b
|
|
61
|
+
deletion_ranges(elements, indexes, end_offset).reverse_each do |from, to, replacement|
|
|
62
|
+
content.bytesplice(from - start_offset, to - from, replacement.b)
|
|
63
|
+
end
|
|
64
|
+
content.force_encoding(@source_file.source.encoding)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def list_separator(collection_node, adjacent_element)
|
|
68
|
+
separator = percent_list?(collection_node) ? '' : ','
|
|
69
|
+
return "#{separator} " unless multiline_collection?(collection_node)
|
|
70
|
+
|
|
71
|
+
"#{separator}#{@source_file.newline}#{indentation_of(adjacent_element)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def collection_elements(collection_node)
|
|
75
|
+
case collection_node
|
|
76
|
+
when Prism::ArgumentsNode then collection_node.arguments
|
|
77
|
+
when Prism::ArrayNode, Prism::KeywordHashNode, Prism::HashNode then collection_node.elements
|
|
78
|
+
else
|
|
79
|
+
raise ArgumentError, 'collection must be an arguments, array, or hash node'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deletion_ranges(elements, indexes, trailing_end)
|
|
84
|
+
removed = indexes.to_h { |index| [index, true] }
|
|
85
|
+
indexes.slice_when { |left, right| right != left + 1 }.map do |group|
|
|
86
|
+
deletion_range(elements, group, removed, trailing_end)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deletion_range(elements, group, removed, trailing_end)
|
|
91
|
+
first = group.first
|
|
92
|
+
last = group.last
|
|
93
|
+
following = ((last + 1)...elements.length).find { |index| !removed[index] }
|
|
94
|
+
return [elements[first].location.start_offset, elements[following].location.start_offset, ''] if following
|
|
95
|
+
|
|
96
|
+
preceding = (first - 1).downto(0).find { |index| !removed[index] }
|
|
97
|
+
return [elements.first.location.start_offset, trailing_end, ''] unless preceding
|
|
98
|
+
|
|
99
|
+
from = elements[preceding].location.end_offset
|
|
100
|
+
has_trailing_comma = last == elements.length - 1 && trailing_end > elements.last.location.end_offset
|
|
101
|
+
to = has_trailing_comma ? trailing_end : elements[last].location.end_offset
|
|
102
|
+
between = @source_file.source.byteslice(from, elements[first].location.start_offset - from)
|
|
103
|
+
replacement = tail_deletion_replacement(
|
|
104
|
+
between,
|
|
105
|
+
trailing_comma: has_trailing_comma,
|
|
106
|
+
line_break_follows: line_break_follows?(to)
|
|
107
|
+
)
|
|
108
|
+
[from, to, replacement]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tail_deletion_replacement(separator, trailing_comma:, line_break_follows:)
|
|
112
|
+
replacement = if trailing_comma
|
|
113
|
+
separator.include?('#') ? trim_trailing_horizontal_space(separator) : ','
|
|
114
|
+
elsif separator.include?('#')
|
|
115
|
+
trim_trailing_horizontal_space(separator.sub(',', ''))
|
|
116
|
+
else
|
|
117
|
+
return ''
|
|
118
|
+
end
|
|
119
|
+
line_break_follows ? replacement.delete_suffix("\r\n").delete_suffix("\n") : replacement
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def line_break_follows?(offset)
|
|
123
|
+
source = @source_file.source
|
|
124
|
+
offset += 1 while [32, 9].include?(source.getbyte(offset))
|
|
125
|
+
[10, 13].include?(source.getbyte(offset))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def trailing_comma_end(element)
|
|
129
|
+
offset = element.location.end_offset
|
|
130
|
+
source = @source_file.source
|
|
131
|
+
offset += 1 while [32, 9].include?(source.getbyte(offset))
|
|
132
|
+
source.getbyte(offset) == 44 ? offset + 1 : element.location.end_offset
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def multiline_collection?(collection_node)
|
|
136
|
+
return true if @source_file.slice(collection_node.location).include?("\n")
|
|
137
|
+
|
|
138
|
+
start_offset = collection_node.location.start_offset
|
|
139
|
+
prefix = @source_file.source.byteslice(@source_file.line_start(start_offset),
|
|
140
|
+
start_offset - @source_file.line_start(start_offset))
|
|
141
|
+
collection_node.is_a?(Prism::KeywordHashNode) && !prefix.empty? && prefix.strip.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def percent_list?(collection_node)
|
|
145
|
+
collection_node.is_a?(Prism::ArrayNode) &&
|
|
146
|
+
collection_node.opening_loc&.slice&.match?(/\A%[wWiI]/)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def trim_trailing_horizontal_space(text)
|
|
150
|
+
text.sub(/[ \t]+\z/, '')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def insert_into_empty_delimited(node, text)
|
|
154
|
+
opening = node.opening_loc
|
|
155
|
+
closing = node.closing_loc
|
|
156
|
+
return insert_after(opening, text) unless closing
|
|
157
|
+
|
|
158
|
+
source = @source_file.source
|
|
159
|
+
interior = source.byteslice(opening.end_offset, closing.start_offset - opening.end_offset)
|
|
160
|
+
return insert_after(opening, text) unless interior.include?("\n")
|
|
161
|
+
|
|
162
|
+
indentation = @source_file.indentation_at(closing.start_offset)
|
|
163
|
+
offset = @source_file.line_start(closing.start_offset)
|
|
164
|
+
insert_before(offset...offset, "#{indentation}#{@source_file.indent_unit}#{text}#{@source_file.newline}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class Rewriter
|
|
5
|
+
module Structured
|
|
6
|
+
def leading_comments(node)
|
|
7
|
+
attach_comments
|
|
8
|
+
node.location.leading_comments
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def trailing_comment(node)
|
|
12
|
+
attach_comments
|
|
13
|
+
node.location.trailing_comments.first
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def remove_with_comments(node)
|
|
17
|
+
comments = leading_comments(node)
|
|
18
|
+
trailing = trailing_comment(node)
|
|
19
|
+
start_offset = comments.empty? ? node.location.start_offset : comments.first.location.start_offset
|
|
20
|
+
end_offset = [node_end_offset(node), trailing&.location&.end_offset].compact.max
|
|
21
|
+
remove_lines_if_isolated(start_offset: start_offset, end_offset: end_offset)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def attach_comments
|
|
27
|
+
@source_file.attach_comments!
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'source_indenter'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
class Rewriter
|
|
7
|
+
module Structured
|
|
8
|
+
def indentation_of(node)
|
|
9
|
+
@source_file.indentation_at(node.location.start_offset)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def indent(text, level: 1)
|
|
13
|
+
prefix = @source_file.indent_unit * level
|
|
14
|
+
text.lines(chomp: false).map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dedent(text)
|
|
18
|
+
width = dedentation_width(text)
|
|
19
|
+
text.lines(chomp: false).map { |line| line.strip.empty? ? line : line.byteslice(width..) }.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reindent(text, to:)
|
|
23
|
+
prefix = indentation_of(to)
|
|
24
|
+
dedent(text).lines(chomp: false).map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def insert_into_body(node, source_text, position: :end)
|
|
28
|
+
statements = body_statements(node)
|
|
29
|
+
target, after, section_break = body_insertion_target(node, statements, position)
|
|
30
|
+
content = format_body_source(source_text, body_child_indentation(node, statements))
|
|
31
|
+
return insert_body_source_after(node, target, content) if after
|
|
32
|
+
|
|
33
|
+
insert_body_source_before(node, target, content, section_break: section_break)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove_line(node)
|
|
37
|
+
remove_lines_if_isolated(
|
|
38
|
+
start_offset: node.location.start_offset,
|
|
39
|
+
end_offset: node_end_offset(node)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def insert_line_before(node, text)
|
|
44
|
+
line_start = @source_file.line_start(node.location.start_offset)
|
|
45
|
+
content = format_body_source(text, indentation_of(node))
|
|
46
|
+
insert_before(line_start...line_start, "#{content}#{@source_file.newline}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def insert_line_after(node, text)
|
|
50
|
+
line = @source_file.line_location_with_newline(node_end_offset(node) - 1)
|
|
51
|
+
content = format_body_source(text, indentation_of(node))
|
|
52
|
+
prefix = line_terminated?(line) ? '' : @source_file.newline
|
|
53
|
+
insert_after(line, "#{prefix}#{content}#{@source_file.newline}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def comment_out(node)
|
|
57
|
+
first = @source_file.line_start(node.location.start_offset)
|
|
58
|
+
last = @source_file.line_end([node_end_offset(node) - 1, node.location.start_offset].max)
|
|
59
|
+
text = @source_file.source.byteslice(first, last - first)
|
|
60
|
+
commented = text.lines(chomp: false).map do |line|
|
|
61
|
+
indentation = line[/\A[ \t]*/]
|
|
62
|
+
"#{indentation}# #{line.delete_prefix(indentation)}"
|
|
63
|
+
end.join
|
|
64
|
+
replace(first...last, commented)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def dedentation_width(text)
|
|
70
|
+
text.lines.filter_map do |line|
|
|
71
|
+
line[/\A[ \t]*/].bytesize unless line.strip.empty?
|
|
72
|
+
end.min || 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remove_lines_if_isolated(start_offset:, end_offset:)
|
|
76
|
+
first = @source_file.line_location(start_offset)
|
|
77
|
+
last_offset = [end_offset - 1, start_offset].max
|
|
78
|
+
last = @source_file.line_location(last_offset)
|
|
79
|
+
before = @source_file.source.byteslice(first.start_offset, start_offset - first.start_offset)
|
|
80
|
+
after = @source_file.source.byteslice(end_offset...last.end_offset) || ''
|
|
81
|
+
return remove(start_offset...end_offset) unless before.strip.empty? && after.strip.empty?
|
|
82
|
+
|
|
83
|
+
ending = @source_file.line_location_with_newline(last_offset).end_offset
|
|
84
|
+
remove(first.start_offset...ending)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def body_child_indentation(node, statements)
|
|
88
|
+
first_statement = statements&.body&.first
|
|
89
|
+
if first_statement &&
|
|
90
|
+
@source_file.line_at(first_statement.location.start_offset) != @source_file.line_at(node.location.start_offset)
|
|
91
|
+
return indentation_of(first_statement)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
"#{indentation_of(node)}#{@source_file.indent_unit}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def insert_body_source_after(node, target, content)
|
|
98
|
+
if inline_body_end?(node, target)
|
|
99
|
+
return insert_after(target.location, "#{@source_file.newline}#{content}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
line = @source_file.line_location_with_newline(node_end_offset(target) - 1)
|
|
103
|
+
prefix = line_terminated?(line) ? '' : @source_file.newline
|
|
104
|
+
insert_after(line, "#{prefix}#{content}#{@source_file.newline}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def insert_body_source_before(node, target, content, section_break:)
|
|
108
|
+
return insert_inline_body_source(target, content) if inline_body_start?(node, target)
|
|
109
|
+
|
|
110
|
+
offset = if target
|
|
111
|
+
@source_file.line_start(target.location.start_offset)
|
|
112
|
+
else
|
|
113
|
+
body_insertion_offset(node)
|
|
114
|
+
end
|
|
115
|
+
prefix = needs_leading_newline?(offset) ? @source_file.newline : ''
|
|
116
|
+
suffix = section_break ? @source_file.newline * 2 : @source_file.newline
|
|
117
|
+
insert_before(offset...offset, "#{prefix}#{content}#{suffix}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def body_statements(node)
|
|
121
|
+
body = node.respond_to?(:body) ? node.body : nil
|
|
122
|
+
return body if body.is_a?(Prism::StatementsNode)
|
|
123
|
+
return body.statements if body.is_a?(Prism::BeginNode)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def body_closing_location(node)
|
|
127
|
+
return node.end_keyword_loc if node.respond_to?(:end_keyword_loc) && node.end_keyword_loc
|
|
128
|
+
return node.closing_loc if node.respond_to?(:closing_loc) && node.closing_loc
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, 'node does not have a delimited body'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def body_insertion_offset(node)
|
|
134
|
+
location = normal_body_boundary(node)
|
|
135
|
+
line_start = @source_file.line_start(location.start_offset)
|
|
136
|
+
prefix = @source_file.source.byteslice(line_start, location.start_offset - line_start)
|
|
137
|
+
prefix.strip.empty? ? line_start : location.start_offset
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normal_body_boundary(node)
|
|
141
|
+
body = node.respond_to?(:body) ? node.body : nil
|
|
142
|
+
if body.is_a?(Prism::BeginNode)
|
|
143
|
+
return body.rescue_clause.keyword_loc if body.rescue_clause
|
|
144
|
+
return body.else_clause.else_keyword_loc if body.else_clause
|
|
145
|
+
return body.ensure_clause.ensure_keyword_loc if body.ensure_clause
|
|
146
|
+
end
|
|
147
|
+
body_closing_location(node)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def body_insertion_target(node, statements, position)
|
|
151
|
+
body = statements&.body || []
|
|
152
|
+
case position
|
|
153
|
+
when :beginning then return [body.first, false, false]
|
|
154
|
+
when :end then return [nil, false, false]
|
|
155
|
+
when :before_private
|
|
156
|
+
marker = body.find { |child| visibility_marker?(child) }
|
|
157
|
+
return [marker, false, !marker.nil?]
|
|
158
|
+
when Hash
|
|
159
|
+
return [position.fetch(:after), true, false] if position.key?(:after)
|
|
160
|
+
return [position.fetch(:before), false, false] if position.key?(:before)
|
|
161
|
+
end
|
|
162
|
+
raise ArgumentError, 'unsupported body insertion position'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def visibility_marker?(node)
|
|
166
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
|
|
167
|
+
%i[private protected].include?(node.name)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def format_body_source(text, indentation)
|
|
171
|
+
SourceIndenter.new(text.to_s, indentation: indentation,
|
|
172
|
+
newline: @source_file.newline).call
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def inline_body_start?(node, target)
|
|
176
|
+
target && @source_file.line_at(target.location.start_offset) ==
|
|
177
|
+
@source_file.line_at(node.location.start_offset)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def inline_body_end?(node, target)
|
|
181
|
+
@source_file.line_at(target.location.end_offset) ==
|
|
182
|
+
@source_file.line_at(body_closing_location(node).start_offset)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def insert_inline_body_source(target, content)
|
|
186
|
+
indentation = content[/\A[ \t]*/]
|
|
187
|
+
replacement = "#{content.delete_prefix(indentation)}#{@source_file.newline}#{indentation}"
|
|
188
|
+
insert_before(target.location, replacement)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def needs_leading_newline?(offset)
|
|
192
|
+
offset.positive? && ![10, 13].include?(@source_file.source.getbyte(offset - 1))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def line_terminated?(location)
|
|
196
|
+
location.end_offset.positive? && @source_file.source.getbyte(location.end_offset - 1) == 10
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
class Rewriter
|
|
5
|
+
module Structured
|
|
6
|
+
class SourceIndenter
|
|
7
|
+
def initialize(source, indentation:, newline:)
|
|
8
|
+
@source = source
|
|
9
|
+
@indentation = indentation
|
|
10
|
+
@newline = newline
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
protected_ranges = protected_source_ranges
|
|
15
|
+
width = dedentation_width(protected_ranges)
|
|
16
|
+
formatted = String.new(encoding: @source.encoding)
|
|
17
|
+
offset = 0
|
|
18
|
+
|
|
19
|
+
@source.lines(chomp: false).each do |line|
|
|
20
|
+
body, line_ending = split_line_ending(line)
|
|
21
|
+
line_ending = @newline if line_ending && !protected_offset?(offset + body.bytesize, protected_ranges)
|
|
22
|
+
unless body.strip.empty? || protected_offset?(offset, protected_ranges)
|
|
23
|
+
leading_width = body[/\A[ \t]*/].bytesize
|
|
24
|
+
body = "#{@indentation}#{body.byteslice([width, leading_width].min..)}"
|
|
25
|
+
end
|
|
26
|
+
formatted << body << line_ending.to_s
|
|
27
|
+
offset += line.bytesize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
remove_final_line_ending(formatted)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def protected_source_ranges
|
|
36
|
+
parsed, prefix_length = parse_source
|
|
37
|
+
raise ArgumentError, 'source text must be valid Ruby' unless parsed.errors.empty?
|
|
38
|
+
|
|
39
|
+
queue = [parsed.value]
|
|
40
|
+
queue.each_with_object([]) do |node, ranges|
|
|
41
|
+
queue.concat(node.compact_child_nodes)
|
|
42
|
+
content = node.respond_to?(:content_loc) && node.content_loc
|
|
43
|
+
ranges << (content.start_offset...content.end_offset) if content
|
|
44
|
+
ranges << (node.closing_loc.start_offset...node.closing_loc.end_offset) if
|
|
45
|
+
node.respond_to?(:heredoc?) && node.heredoc? && node.closing_loc
|
|
46
|
+
end.map { |range| (range.begin - prefix_length)...(range.end - prefix_length) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_source
|
|
50
|
+
return [Prism.parse(@source), 0] if @source.encoding == Encoding::UTF_8 || @source.ascii_only?
|
|
51
|
+
|
|
52
|
+
prefix = "# encoding: #{@source.encoding.name}\n".encode(@source.encoding)
|
|
53
|
+
[Prism.parse(prefix + @source), prefix.bytesize]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dedentation_width(protected_ranges)
|
|
57
|
+
offset = 0
|
|
58
|
+
widths = @source.lines(chomp: false).filter_map do |line|
|
|
59
|
+
body, = split_line_ending(line)
|
|
60
|
+
protected = protected_offset?(offset, protected_ranges)
|
|
61
|
+
offset += line.bytesize
|
|
62
|
+
body[/\A[ \t]*/].bytesize unless body.strip.empty? || protected
|
|
63
|
+
end
|
|
64
|
+
widths.min || 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def split_line_ending(line)
|
|
68
|
+
return [line.byteslice(0, line.bytesize - 2), "\r\n"] if line.end_with?("\r\n")
|
|
69
|
+
return [line.byteslice(0, line.bytesize - 1), "\n"] if line.end_with?("\n")
|
|
70
|
+
|
|
71
|
+
[line, nil]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def protected_offset?(offset, ranges)
|
|
75
|
+
ranges.any? { |range| offset.between?(range.begin, range.end) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def remove_final_line_ending(text)
|
|
79
|
+
return text.byteslice(0, text.bytesize - 2) if text.end_with?("\r\n")
|
|
80
|
+
return text.byteslice(0, text.bytesize - 1) if text.end_with?("\n")
|
|
81
|
+
|
|
82
|
+
text
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_constant :SourceIndenter
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../astel'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
class Rewriter
|
|
7
|
+
module Structured
|
|
8
|
+
def find_keyword_argument(call_node, name)
|
|
9
|
+
keyword_hashes(call_node).each do |keyword_hash|
|
|
10
|
+
match = keyword_hash.elements.find { |element| keyword_name(element) == name.to_sym }
|
|
11
|
+
return match if match
|
|
12
|
+
end
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def remove_keyword_argument(call_node, name)
|
|
17
|
+
argument = find_keyword_argument(call_node, name)
|
|
18
|
+
return self unless argument
|
|
19
|
+
|
|
20
|
+
keyword_hash = keyword_hashes(call_node).find { |hash| hash.elements.include?(argument) }
|
|
21
|
+
return remove_sole_keyword_argument(call_node, keyword_hash) if keyword_hash.elements.one?
|
|
22
|
+
|
|
23
|
+
remove_list_element(keyword_hash, argument)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def replace_keyword_key(assoc_node, name)
|
|
27
|
+
if implicit_keyword_value?(assoc_node)
|
|
28
|
+
return replace(assoc_node.location, "#{name}: #{assoc_node.value.value.name}")
|
|
29
|
+
end
|
|
30
|
+
|
|
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
|
+
if implicit_keyword_value?(assoc_node)
|
|
37
|
+
return replace(assoc_node.location, "#{@source_file.slice(assoc_node.key.location)} #{replacement}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
replace_expression(assoc_node.value, replacement)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def insert_keyword_argument(call_node, text, position: :last)
|
|
44
|
+
raise ArgumentError, 'position must be :first or :last' unless %i[first last].include?(position)
|
|
45
|
+
|
|
46
|
+
arguments = call_node.arguments
|
|
47
|
+
return insert_first_keyword_argument(call_node, text) unless arguments
|
|
48
|
+
|
|
49
|
+
keyword_hash = keyword_hashes(call_node).first
|
|
50
|
+
return insert_into_keyword_hash(keyword_hash, text, position) if keyword_hash
|
|
51
|
+
|
|
52
|
+
index = arguments.arguments.length
|
|
53
|
+
insert_list_element(arguments, index, text)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def replace_expression(node, replacement)
|
|
59
|
+
ranges = heredoc_ranges(node)
|
|
60
|
+
return replace(node.location, replacement) if ranges.empty?
|
|
61
|
+
|
|
62
|
+
transaction(raise_on_conflict: true) do |rewriter|
|
|
63
|
+
rewriter.replace(node.location, replacement)
|
|
64
|
+
ranges.each { |range| rewriter.remove(range) }
|
|
65
|
+
end
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def remove_sole_keyword_argument(call_node, keyword_hash)
|
|
70
|
+
if call_node.arguments.arguments.one? && !call_node.opening_loc
|
|
71
|
+
preserved = remove_commented_keyword_before_block(call_node, keyword_hash)
|
|
72
|
+
return preserved if preserved
|
|
73
|
+
|
|
74
|
+
ending = call_node.block&.location&.start_offset || keyword_hash.location.end_offset
|
|
75
|
+
replacement = call_node.block ? ' ' : ''
|
|
76
|
+
return replace(call_node.message_loc.end_offset...ending, replacement)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
remove_list_element(call_node.arguments, keyword_hash)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove_commented_keyword_before_block(call_node, keyword_hash)
|
|
83
|
+
block = call_node.block
|
|
84
|
+
return unless block.is_a?(Prism::BlockArgumentNode)
|
|
85
|
+
|
|
86
|
+
source = @source_file.source
|
|
87
|
+
separator = source.byteslice(keyword_hash.location.end_offset,
|
|
88
|
+
block.location.start_offset - keyword_hash.location.end_offset)
|
|
89
|
+
return unless separator.include?('#')
|
|
90
|
+
|
|
91
|
+
separator = separator.sub(/\A[ \t]*,[ \t]*/, '')
|
|
92
|
+
replacement = "(#{separator}#{@source_file.slice(block.location)})"
|
|
93
|
+
replace(call_node.message_loc.end_offset...block.location.end_offset, replacement)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def insert_first_keyword_argument(call_node, text)
|
|
97
|
+
return insert_keyword_before_block(call_node, text) if call_node.block.is_a?(Prism::BlockArgumentNode)
|
|
98
|
+
return insert_into_empty_delimited(call_node, text) if call_node.opening_loc && call_node.closing_loc
|
|
99
|
+
return insert_after(call_node.message_loc, "(#{text})") if call_node.block
|
|
100
|
+
|
|
101
|
+
location = call_node.opening_loc || call_node.message_loc
|
|
102
|
+
insert_after(location, call_node.opening_loc ? text : " #{text}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def insert_into_keyword_hash(keyword_hash, text, position)
|
|
106
|
+
index = position == :first ? 0 : keyword_hash.elements.length
|
|
107
|
+
insert_list_element(keyword_hash, index, text)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def keyword_hashes(call_node)
|
|
111
|
+
call_node.arguments&.arguments&.grep(Prism::KeywordHashNode) || []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def keyword_name(assoc_node)
|
|
115
|
+
return unless assoc_node.is_a?(Prism::AssocNode)
|
|
116
|
+
|
|
117
|
+
key = assoc_node.key
|
|
118
|
+
key.unescaped.to_sym if key.is_a?(Prism::SymbolNode)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def implicit_keyword_value?(assoc_node)
|
|
122
|
+
assoc_node.value.is_a?(Prism::ImplicitNode)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def insert_keyword_before_block(call_node, text)
|
|
126
|
+
block = call_node.block
|
|
127
|
+
separator = if @source_file.line_at(block.location.start_offset) ==
|
|
128
|
+
@source_file.line_at((call_node.opening_loc || call_node.message_loc).end_offset)
|
|
129
|
+
', '
|
|
130
|
+
else
|
|
131
|
+
",#{@source_file.newline}#{indentation_of(block)}"
|
|
132
|
+
end
|
|
133
|
+
insert_before(block.location, "#{text}#{separator}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def heredoc_ranges(node)
|
|
137
|
+
queue = [node]
|
|
138
|
+
ranges = []
|
|
139
|
+
until queue.empty?
|
|
140
|
+
current = queue.shift
|
|
141
|
+
queue.concat(current.compact_child_nodes)
|
|
142
|
+
next unless current.respond_to?(:heredoc?) && current.heredoc? && current.closing_loc
|
|
143
|
+
|
|
144
|
+
content = current.content_loc if current.respond_to?(:content_loc)
|
|
145
|
+
start_offset = content&.start_offset || current.compact_child_nodes.map(&:location).map(&:start_offset).min
|
|
146
|
+
ranges << (start_offset...current.closing_loc.end_offset) if start_offset
|
|
147
|
+
end
|
|
148
|
+
ranges
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def node_end_offset(node)
|
|
152
|
+
[node.location.end_offset, *heredoc_ranges(node).map(&:end)].max
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
require_relative 'structured/collections'
|
|
159
|
+
require_relative 'structured/formatting'
|
|
160
|
+
require_relative 'structured/comments'
|
|
161
|
+
|
|
162
|
+
Astel::Rewriter.include(Astel::Rewriter::Structured)
|
|
163
|
+
Astel::Rewriter.private_constant(:Structured)
|