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