tree_sitter 0.0.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/Makefile +116 -0
- data/README.md +482 -0
- data/ext/tree_sitter/Cargo.toml +18 -0
- data/ext/tree_sitter/extconf.rb +6 -0
- data/ext/tree_sitter/src/language.rs +152 -0
- data/ext/tree_sitter/src/lib.rs +140 -0
- data/ext/tree_sitter/src/node.rs +248 -0
- data/ext/tree_sitter/src/parser.rs +126 -0
- data/ext/tree_sitter/src/point.rs +45 -0
- data/ext/tree_sitter/src/query.rs +161 -0
- data/ext/tree_sitter/src/range.rs +50 -0
- data/ext/tree_sitter/src/tree.rs +38 -0
- data/lib/tree_sitter/formatting.rb +236 -0
- data/lib/tree_sitter/inserter.rb +306 -0
- data/lib/tree_sitter/query_rewriter.rb +314 -0
- data/lib/tree_sitter/refactor.rb +214 -0
- data/lib/tree_sitter/rewriter.rb +155 -0
- data/lib/tree_sitter/transformer.rb +324 -0
- data/lib/tree_sitter/version.rb +5 -0
- data/lib/tree_sitter.rb +25 -0
- metadata +113 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
|
|
5
|
+
module TreeSitter
|
|
6
|
+
# Syntax-aware insertions that respect indentation and formatting.
|
|
7
|
+
#
|
|
8
|
+
# @example Insert a new statement with proper indentation
|
|
9
|
+
# Inserter.new(source, tree)
|
|
10
|
+
# .at_end_of(block_node)
|
|
11
|
+
# .insert_statement("return result;")
|
|
12
|
+
# .rewrite
|
|
13
|
+
#
|
|
14
|
+
# @example Insert a sibling function
|
|
15
|
+
# Inserter.new(source, tree)
|
|
16
|
+
# .after(existing_fn)
|
|
17
|
+
# .insert_sibling("fn new_func() {}")
|
|
18
|
+
# .rewrite
|
|
19
|
+
#
|
|
20
|
+
class Inserter
|
|
21
|
+
# Represents a pending insertion
|
|
22
|
+
Insertion = Struct.new(:byte_pos, :content, :newline_before, :newline_after, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
attr_reader :source, :tree
|
|
25
|
+
|
|
26
|
+
# Initialize a new Inserter
|
|
27
|
+
#
|
|
28
|
+
# @param source [String] The source code
|
|
29
|
+
# @param tree [TreeSitter::Tree] The parsed syntax tree
|
|
30
|
+
# @param parser [TreeSitter::Parser, nil] Optional parser for re-parsing
|
|
31
|
+
def initialize(source, tree, parser: nil)
|
|
32
|
+
@source = source.dup.freeze
|
|
33
|
+
@tree = tree
|
|
34
|
+
@parser = parser
|
|
35
|
+
@indent_detector = Formatting::IndentationDetector.new(source)
|
|
36
|
+
@insertions = []
|
|
37
|
+
@insertion_point = nil
|
|
38
|
+
@insertion_context = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set insertion point at the beginning of a node's content (inside the node)
|
|
42
|
+
#
|
|
43
|
+
# @param node [TreeSitter::Node] Container node (e.g., a block)
|
|
44
|
+
# @return [self] For method chaining
|
|
45
|
+
def at_start_of(node)
|
|
46
|
+
# Find the first child's start, or just after the opening
|
|
47
|
+
# For blocks like { ... }, we want to insert after the opening brace
|
|
48
|
+
@insertion_context = :inside_start
|
|
49
|
+
@insertion_node = node
|
|
50
|
+
@target_indent_level = @indent_detector.level_at_byte(node.start_byte) + 1
|
|
51
|
+
|
|
52
|
+
# Find position just after opening delimiter
|
|
53
|
+
first_child = node.named_child(0)
|
|
54
|
+
if first_child
|
|
55
|
+
@insertion_point = first_child.start_byte
|
|
56
|
+
else
|
|
57
|
+
# Empty block - find end of opening line or after opening brace
|
|
58
|
+
node_text = @source[node.start_byte...node.end_byte]
|
|
59
|
+
@insertion_point = if (brace_pos = node_text.index("{"))
|
|
60
|
+
node.start_byte + brace_pos + 1
|
|
61
|
+
else
|
|
62
|
+
node.start_byte + 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Set insertion point at the end of a node's content (inside the node)
|
|
70
|
+
#
|
|
71
|
+
# @param node [TreeSitter::Node] Container node
|
|
72
|
+
# @return [self] For method chaining
|
|
73
|
+
def at_end_of(node)
|
|
74
|
+
@insertion_context = :inside_end
|
|
75
|
+
@insertion_node = node
|
|
76
|
+
@target_indent_level = @indent_detector.level_at_byte(node.start_byte) + 1
|
|
77
|
+
|
|
78
|
+
# Find position just before closing delimiter
|
|
79
|
+
node_text = @source[node.start_byte...node.end_byte]
|
|
80
|
+
@insertion_point = if (brace_pos = node_text.rindex("}"))
|
|
81
|
+
node.start_byte + brace_pos
|
|
82
|
+
else
|
|
83
|
+
node.end_byte
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Set insertion point before a node (as sibling)
|
|
90
|
+
#
|
|
91
|
+
# @param node [TreeSitter::Node] Reference node
|
|
92
|
+
# @return [self] For method chaining
|
|
93
|
+
def before(node)
|
|
94
|
+
@insertion_context = :before
|
|
95
|
+
@insertion_node = node
|
|
96
|
+
@insertion_point = node.start_byte
|
|
97
|
+
@target_indent_level = @indent_detector.level_at_byte(node.start_byte)
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Set insertion point after a node (as sibling)
|
|
102
|
+
#
|
|
103
|
+
# @param node [TreeSitter::Node] Reference node
|
|
104
|
+
# @return [self] For method chaining
|
|
105
|
+
def after(node)
|
|
106
|
+
@insertion_context = :after
|
|
107
|
+
@insertion_node = node
|
|
108
|
+
@insertion_point = node.end_byte
|
|
109
|
+
@target_indent_level = @indent_detector.level_at_byte(node.start_byte)
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Insert a statement with automatic indentation
|
|
114
|
+
#
|
|
115
|
+
# @param content [String] The statement to insert
|
|
116
|
+
# @param newline_before [Boolean] Add newline before (default: context-dependent)
|
|
117
|
+
# @param newline_after [Boolean] Add newline after (default: true)
|
|
118
|
+
# @return [self] For method chaining
|
|
119
|
+
def insert_statement(content, newline_before: nil, newline_after: true)
|
|
120
|
+
raise "No insertion point set. Call at_start_of, at_end_of, before, or after first." unless @insertion_point
|
|
121
|
+
|
|
122
|
+
# Determine newline_before based on context
|
|
123
|
+
newline_before = newline_before? if newline_before.nil?
|
|
124
|
+
|
|
125
|
+
# Adjust indentation of content
|
|
126
|
+
adjusted_content = adjust_content_indentation(content)
|
|
127
|
+
|
|
128
|
+
@insertions << Insertion.new(
|
|
129
|
+
byte_pos: @insertion_point,
|
|
130
|
+
content: adjusted_content,
|
|
131
|
+
newline_before: newline_before,
|
|
132
|
+
newline_after: newline_after,
|
|
133
|
+
)
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Insert raw content without indentation adjustment
|
|
138
|
+
#
|
|
139
|
+
# @param content [String] The content to insert
|
|
140
|
+
# @return [self] For method chaining
|
|
141
|
+
def insert_raw(content)
|
|
142
|
+
raise "No insertion point set. Call at_start_of, at_end_of, before, or after first." unless @insertion_point
|
|
143
|
+
|
|
144
|
+
@insertions << Insertion.new(
|
|
145
|
+
byte_pos: @insertion_point,
|
|
146
|
+
content: content,
|
|
147
|
+
newline_before: false,
|
|
148
|
+
newline_after: false,
|
|
149
|
+
)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Insert a sibling node with matching indentation
|
|
154
|
+
#
|
|
155
|
+
# @param content [String] The sibling content
|
|
156
|
+
# @param separator [String] Separator between siblings (default: newlines based on context)
|
|
157
|
+
# @return [self] For method chaining
|
|
158
|
+
def insert_sibling(content, separator: nil)
|
|
159
|
+
raise "No insertion point set. Call before or after first." unless @insertion_point
|
|
160
|
+
|
|
161
|
+
separator ||= "\n\n" # Default to blank line between top-level items
|
|
162
|
+
|
|
163
|
+
# Adjust indentation of content
|
|
164
|
+
adjusted_content = adjust_content_indentation(content)
|
|
165
|
+
|
|
166
|
+
# For after insertion, add separator before content
|
|
167
|
+
# For before insertion, add separator after content
|
|
168
|
+
full_content = case @insertion_context
|
|
169
|
+
when :after
|
|
170
|
+
separator + adjusted_content
|
|
171
|
+
when :before
|
|
172
|
+
adjusted_content + separator
|
|
173
|
+
else
|
|
174
|
+
adjusted_content
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@insertions << Insertion.new(
|
|
178
|
+
byte_pos: @insertion_point,
|
|
179
|
+
content: full_content,
|
|
180
|
+
newline_before: false,
|
|
181
|
+
newline_after: false,
|
|
182
|
+
)
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Insert a block with proper indentation (for block constructs)
|
|
187
|
+
#
|
|
188
|
+
# @param header [String] The block header (e.g., "if condition")
|
|
189
|
+
# @param body [String] The block body content (will be indented)
|
|
190
|
+
# @param open_brace [String] Opening delimiter (default: " {")
|
|
191
|
+
# @param close_brace [String] Closing delimiter (default: "}")
|
|
192
|
+
# @return [self] For method chaining
|
|
193
|
+
def insert_block(header, body, open_brace: " {", close_brace: "}")
|
|
194
|
+
raise "No insertion point set. Call at_start_of, at_end_of, before, or after first." unless @insertion_point
|
|
195
|
+
|
|
196
|
+
indent = @indent_detector.indent_string_for_level(@target_indent_level)
|
|
197
|
+
body_indent = @indent_detector.indent_string_for_level(@target_indent_level + 1)
|
|
198
|
+
|
|
199
|
+
# Build the block with proper indentation
|
|
200
|
+
indented_body = body.lines.map do |line|
|
|
201
|
+
if line.strip.empty?
|
|
202
|
+
line
|
|
203
|
+
else
|
|
204
|
+
body_indent + line.lstrip
|
|
205
|
+
end
|
|
206
|
+
end.join
|
|
207
|
+
|
|
208
|
+
block_content = "#{indent}#{header}#{open_brace}\n#{indented_body}\n#{indent}#{close_brace}"
|
|
209
|
+
|
|
210
|
+
newline_before = newline_before?
|
|
211
|
+
|
|
212
|
+
@insertions << Insertion.new(
|
|
213
|
+
byte_pos: @insertion_point,
|
|
214
|
+
content: block_content,
|
|
215
|
+
newline_before: newline_before,
|
|
216
|
+
newline_after: true,
|
|
217
|
+
)
|
|
218
|
+
self
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Apply all insertions
|
|
222
|
+
#
|
|
223
|
+
# @return [String] The source with insertions
|
|
224
|
+
def rewrite
|
|
225
|
+
return @source if @insertions.empty?
|
|
226
|
+
|
|
227
|
+
# Sort by position descending to apply from end to start
|
|
228
|
+
sorted = @insertions.sort_by { |ins| -ins.byte_pos }
|
|
229
|
+
|
|
230
|
+
result = @source.dup
|
|
231
|
+
sorted.each do |insertion|
|
|
232
|
+
content = insertion.content
|
|
233
|
+
content = "\n#{content}" if insertion.newline_before
|
|
234
|
+
content = "#{content}\n" if insertion.newline_after
|
|
235
|
+
|
|
236
|
+
result.insert(insertion.byte_pos, content)
|
|
237
|
+
end
|
|
238
|
+
result
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Apply insertions and return both source and new tree
|
|
242
|
+
#
|
|
243
|
+
# @return [Array<String, Tree>] The new source and re-parsed tree
|
|
244
|
+
def rewrite_with_tree
|
|
245
|
+
new_source = rewrite
|
|
246
|
+
|
|
247
|
+
parser = @parser || create_parser_from_tree
|
|
248
|
+
raise "No parser available for re-parsing" unless parser
|
|
249
|
+
|
|
250
|
+
new_tree = parser.parse(new_source)
|
|
251
|
+
[new_source, new_tree]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Reset insertion point to allow setting a new one
|
|
255
|
+
#
|
|
256
|
+
# @return [self] For method chaining
|
|
257
|
+
def reset_position
|
|
258
|
+
@insertion_point = nil
|
|
259
|
+
@insertion_context = nil
|
|
260
|
+
@insertion_node = nil
|
|
261
|
+
@target_indent_level = nil
|
|
262
|
+
self
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def newline_before?
|
|
268
|
+
case @insertion_context
|
|
269
|
+
when :inside_start
|
|
270
|
+
# After opening brace, usually need newline
|
|
271
|
+
# But check if there's already content on the same line
|
|
272
|
+
true
|
|
273
|
+
when :inside_end
|
|
274
|
+
# Before closing brace, check if we need newline
|
|
275
|
+
# Look at what's before the insertion point
|
|
276
|
+
before_text = @source[0...@insertion_point]
|
|
277
|
+
last_newline = before_text.rindex("\n")
|
|
278
|
+
content_after_newline = last_newline ? before_text[(last_newline + 1)..] : before_text
|
|
279
|
+
# If there's only whitespace after the last newline, we might not need another
|
|
280
|
+
!content_after_newline.strip.empty?
|
|
281
|
+
when :before, :after
|
|
282
|
+
# Siblings usually don't need newline before (handled by separator)
|
|
283
|
+
false
|
|
284
|
+
else
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def adjust_content_indentation(content)
|
|
290
|
+
return content if content.strip.empty?
|
|
291
|
+
|
|
292
|
+
@indent_detector.adjust_indentation(content.strip, @target_indent_level, current_level: 0)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def create_parser_from_tree
|
|
296
|
+
return unless @tree
|
|
297
|
+
|
|
298
|
+
parser = TreeSitter::Parser.new
|
|
299
|
+
lang = @tree.language
|
|
300
|
+
parser.language = lang.name if lang
|
|
301
|
+
parser
|
|
302
|
+
rescue StandardError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TreeSitter
|
|
4
|
+
# Query-based bulk editing for syntax tree nodes.
|
|
5
|
+
# Finds all nodes matching a tree-sitter query and applies transformations.
|
|
6
|
+
#
|
|
7
|
+
# @example Rename all function calls matching a pattern
|
|
8
|
+
# QueryRewriter.new(source, tree, language)
|
|
9
|
+
# .query('(call_expression function: (identifier) @fn_name)')
|
|
10
|
+
# .where { |match| match.captures.any? { |c| c.node.text == "old_name" } }
|
|
11
|
+
# .replace("@fn_name") { |node| "new_name" }
|
|
12
|
+
# .rewrite
|
|
13
|
+
#
|
|
14
|
+
# @example Remove all comments
|
|
15
|
+
# QueryRewriter.new(source, tree, language)
|
|
16
|
+
# .query('(line_comment) @comment')
|
|
17
|
+
# .remove("@comment")
|
|
18
|
+
# .rewrite
|
|
19
|
+
#
|
|
20
|
+
class QueryRewriter
|
|
21
|
+
# Represents a pending operation on a capture
|
|
22
|
+
Operation = Struct.new(:type, :capture_name, :transformer, :before, :after, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
attr_reader :source, :tree, :language, :edits
|
|
25
|
+
|
|
26
|
+
# Initialize a new QueryRewriter
|
|
27
|
+
#
|
|
28
|
+
# @param source [String] The source code to rewrite
|
|
29
|
+
# @param tree [TreeSitter::Tree] The parsed syntax tree
|
|
30
|
+
# @param language [TreeSitter::Language, String] The language for queries
|
|
31
|
+
# @param parser [TreeSitter::Parser, nil] Optional parser for re-parsing
|
|
32
|
+
def initialize(source, tree, language = nil, parser: nil)
|
|
33
|
+
@source = source.dup.freeze
|
|
34
|
+
@tree = tree
|
|
35
|
+
@language = resolve_language(language)
|
|
36
|
+
@parser = parser
|
|
37
|
+
@query_pattern = nil
|
|
38
|
+
@predicates = []
|
|
39
|
+
@operations = []
|
|
40
|
+
@edits = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Set the query pattern to match against
|
|
44
|
+
#
|
|
45
|
+
# @param pattern [String] Tree-sitter query pattern
|
|
46
|
+
# @return [self] For method chaining
|
|
47
|
+
def query(pattern)
|
|
48
|
+
@query_pattern = pattern
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Filter matches based on a predicate
|
|
53
|
+
#
|
|
54
|
+
# @yield [QueryMatch] Block that returns true for matches to keep
|
|
55
|
+
# @return [self] For method chaining
|
|
56
|
+
def where(&predicate)
|
|
57
|
+
@predicates << predicate
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replace captured nodes with new content
|
|
62
|
+
#
|
|
63
|
+
# @param capture_name [String] The @capture to replace (e.g., "@fn_name" or "fn_name")
|
|
64
|
+
# @yield [Node] Block that returns the replacement text (receives the captured node)
|
|
65
|
+
# @yieldreturn [String] The replacement text
|
|
66
|
+
# @return [self] For method chaining
|
|
67
|
+
def replace(capture_name, &transformer)
|
|
68
|
+
@operations << Operation.new(
|
|
69
|
+
type: :replace,
|
|
70
|
+
capture_name: normalize_capture_name(capture_name),
|
|
71
|
+
transformer: transformer || proc { "" },
|
|
72
|
+
)
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove captured nodes
|
|
77
|
+
#
|
|
78
|
+
# @param capture_name [String] The @capture to remove
|
|
79
|
+
# @return [self] For method chaining
|
|
80
|
+
def remove(capture_name)
|
|
81
|
+
@operations << Operation.new(
|
|
82
|
+
type: :remove,
|
|
83
|
+
capture_name: normalize_capture_name(capture_name),
|
|
84
|
+
)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Insert content before captured nodes
|
|
89
|
+
#
|
|
90
|
+
# @param capture_name [String] The @capture reference point
|
|
91
|
+
# @yield [Node] Block that returns the content to insert
|
|
92
|
+
# @yieldreturn [String] The content to insert
|
|
93
|
+
# @return [self] For method chaining
|
|
94
|
+
def insert_before(capture_name, content = nil, &content_generator)
|
|
95
|
+
generator = content_generator || proc { content.to_s }
|
|
96
|
+
@operations << Operation.new(
|
|
97
|
+
type: :insert_before,
|
|
98
|
+
capture_name: normalize_capture_name(capture_name),
|
|
99
|
+
transformer: generator,
|
|
100
|
+
)
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Insert content after captured nodes
|
|
105
|
+
#
|
|
106
|
+
# @param capture_name [String] The @capture reference point
|
|
107
|
+
# @yield [Node] Block that returns the content to insert
|
|
108
|
+
# @yieldreturn [String] The content to insert
|
|
109
|
+
# @return [self] For method chaining
|
|
110
|
+
def insert_after(capture_name, content = nil, &content_generator)
|
|
111
|
+
generator = content_generator || proc { content.to_s }
|
|
112
|
+
@operations << Operation.new(
|
|
113
|
+
type: :insert_after,
|
|
114
|
+
capture_name: normalize_capture_name(capture_name),
|
|
115
|
+
transformer: generator,
|
|
116
|
+
)
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wrap captured nodes with before/after content
|
|
121
|
+
#
|
|
122
|
+
# @param capture_name [String] The @capture to wrap
|
|
123
|
+
# @param before [String, nil] Content before (or use block)
|
|
124
|
+
# @param after [String, nil] Content after
|
|
125
|
+
# @yield [Node] Optional block that returns [before, after] tuple
|
|
126
|
+
# @return [self] For method chaining
|
|
127
|
+
def wrap(capture_name, before: nil, after: nil, &block)
|
|
128
|
+
@operations << if block
|
|
129
|
+
Operation.new(
|
|
130
|
+
type: :wrap_dynamic,
|
|
131
|
+
capture_name: normalize_capture_name(capture_name),
|
|
132
|
+
transformer: block,
|
|
133
|
+
)
|
|
134
|
+
else
|
|
135
|
+
Operation.new(
|
|
136
|
+
type: :wrap,
|
|
137
|
+
capture_name: normalize_capture_name(capture_name),
|
|
138
|
+
before: before.to_s,
|
|
139
|
+
after: after.to_s,
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Execute the query and collect all matches
|
|
146
|
+
#
|
|
147
|
+
# @return [Array<QueryMatch>] All matches found
|
|
148
|
+
def matches
|
|
149
|
+
return [] unless @query_pattern && @language
|
|
150
|
+
|
|
151
|
+
ts_query = TreeSitter::Query.new(@language, @query_pattern)
|
|
152
|
+
cursor = TreeSitter::QueryCursor.new
|
|
153
|
+
|
|
154
|
+
all_matches = cursor.matches(ts_query, @tree.root_node, @source)
|
|
155
|
+
|
|
156
|
+
# Apply filters
|
|
157
|
+
@predicates.reduce(all_matches) do |matches, predicate|
|
|
158
|
+
matches.select(&predicate)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Apply all accumulated edits
|
|
163
|
+
#
|
|
164
|
+
# @return [String] The rewritten source code
|
|
165
|
+
def rewrite
|
|
166
|
+
build_edits
|
|
167
|
+
apply_edits
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Apply edits and return both source and new tree
|
|
171
|
+
#
|
|
172
|
+
# @return [Array<String, Tree>] The new source and re-parsed tree
|
|
173
|
+
def rewrite_with_tree
|
|
174
|
+
new_source = rewrite
|
|
175
|
+
|
|
176
|
+
parser = @parser || create_parser_from_tree
|
|
177
|
+
raise "No parser available for re-parsing" unless parser
|
|
178
|
+
|
|
179
|
+
new_tree = parser.parse(new_source)
|
|
180
|
+
[new_source, new_tree]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Get a preview of all edits that would be applied
|
|
184
|
+
#
|
|
185
|
+
# @return [Array<Hash>] Array of edit descriptions
|
|
186
|
+
def preview_edits
|
|
187
|
+
build_edits
|
|
188
|
+
@edits.map do |edit|
|
|
189
|
+
{
|
|
190
|
+
start_byte: edit[:start_byte],
|
|
191
|
+
end_byte: edit[:end_byte],
|
|
192
|
+
original: @source[edit[:start_byte]...edit[:end_byte]],
|
|
193
|
+
replacement: edit[:replacement],
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def resolve_language(language)
|
|
201
|
+
case language
|
|
202
|
+
when TreeSitter::Language
|
|
203
|
+
language
|
|
204
|
+
when String
|
|
205
|
+
TreeSitter.language(language)
|
|
206
|
+
when nil
|
|
207
|
+
@tree&.language
|
|
208
|
+
else
|
|
209
|
+
raise ArgumentError, "Invalid language: #{language.class}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def normalize_capture_name(name)
|
|
214
|
+
name.to_s.delete_prefix("@")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def build_edits
|
|
218
|
+
@edits = []
|
|
219
|
+
found_matches = matches
|
|
220
|
+
|
|
221
|
+
found_matches.each do |match|
|
|
222
|
+
@operations.each do |operation|
|
|
223
|
+
# Find captures matching this operation
|
|
224
|
+
captures = match.captures.select { |c| c.name == operation.capture_name }
|
|
225
|
+
|
|
226
|
+
captures.each do |capture|
|
|
227
|
+
node = capture.node
|
|
228
|
+
range = node.range
|
|
229
|
+
|
|
230
|
+
case operation.type
|
|
231
|
+
when :replace
|
|
232
|
+
replacement = operation.transformer.call(node)
|
|
233
|
+
@edits << {
|
|
234
|
+
start_byte: range.start_byte,
|
|
235
|
+
end_byte: range.end_byte,
|
|
236
|
+
replacement: replacement.to_s,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
when :remove
|
|
240
|
+
@edits << {
|
|
241
|
+
start_byte: range.start_byte,
|
|
242
|
+
end_byte: range.end_byte,
|
|
243
|
+
replacement: "",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
when :insert_before
|
|
247
|
+
content = operation.transformer.call(node)
|
|
248
|
+
@edits << {
|
|
249
|
+
start_byte: range.start_byte,
|
|
250
|
+
end_byte: range.start_byte,
|
|
251
|
+
replacement: content.to_s,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
when :insert_after
|
|
255
|
+
content = operation.transformer.call(node)
|
|
256
|
+
@edits << {
|
|
257
|
+
start_byte: range.end_byte,
|
|
258
|
+
end_byte: range.end_byte,
|
|
259
|
+
replacement: content.to_s,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
when :wrap
|
|
263
|
+
@edits << {
|
|
264
|
+
start_byte: range.start_byte,
|
|
265
|
+
end_byte: range.start_byte,
|
|
266
|
+
replacement: operation.before,
|
|
267
|
+
}
|
|
268
|
+
@edits << {
|
|
269
|
+
start_byte: range.end_byte,
|
|
270
|
+
end_byte: range.end_byte,
|
|
271
|
+
replacement: operation.after,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
when :wrap_dynamic
|
|
275
|
+
before_text, after_text = operation.transformer.call(node)
|
|
276
|
+
@edits << {
|
|
277
|
+
start_byte: range.start_byte,
|
|
278
|
+
end_byte: range.start_byte,
|
|
279
|
+
replacement: before_text.to_s,
|
|
280
|
+
}
|
|
281
|
+
@edits << {
|
|
282
|
+
start_byte: range.end_byte,
|
|
283
|
+
end_byte: range.end_byte,
|
|
284
|
+
replacement: after_text.to_s,
|
|
285
|
+
}
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def apply_edits
|
|
293
|
+
# Sort by position descending to apply from end to start
|
|
294
|
+
sorted = @edits.sort_by { |e| [-e[:start_byte], -e[:end_byte]] }
|
|
295
|
+
|
|
296
|
+
result = @source.dup
|
|
297
|
+
sorted.each do |edit|
|
|
298
|
+
result[edit[:start_byte]...edit[:end_byte]] = edit[:replacement]
|
|
299
|
+
end
|
|
300
|
+
result
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def create_parser_from_tree
|
|
304
|
+
return unless @tree
|
|
305
|
+
|
|
306
|
+
parser = TreeSitter::Parser.new
|
|
307
|
+
lang = @tree.language
|
|
308
|
+
parser.language = lang.name if lang
|
|
309
|
+
parser
|
|
310
|
+
rescue StandardError
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|