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.
@@ -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)