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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "query_rewriter"
|
|
4
|
+
require_relative "transformer"
|
|
5
|
+
require_relative "inserter"
|
|
6
|
+
|
|
7
|
+
module TreeSitter
|
|
8
|
+
# High-level refactoring operations built on QueryRewriter and Transformer.
|
|
9
|
+
# These methods provide common code transformation patterns with a simple API.
|
|
10
|
+
#
|
|
11
|
+
# @example Rename a function throughout the code
|
|
12
|
+
# TreeSitter::Refactor.rename_symbol(source, tree, lang,
|
|
13
|
+
# from: "old_name", to: "new_name")
|
|
14
|
+
#
|
|
15
|
+
module Refactor
|
|
16
|
+
class << self
|
|
17
|
+
# Rename a symbol (function, variable, type) throughout the code
|
|
18
|
+
#
|
|
19
|
+
# @param source [String] Source code
|
|
20
|
+
# @param tree [Tree] Parsed syntax tree
|
|
21
|
+
# @param language [Language] Language for queries
|
|
22
|
+
# @param from [String] Original name
|
|
23
|
+
# @param to [String] New name
|
|
24
|
+
# @param kind [Symbol] Type of symbol (:identifier, :function, :type, :variable)
|
|
25
|
+
# @return [String] Modified source code
|
|
26
|
+
def rename_symbol(source, tree, language, from:, to:, kind: :identifier)
|
|
27
|
+
query_pattern = build_rename_query(kind)
|
|
28
|
+
|
|
29
|
+
QueryRewriter.new(source, tree, language)
|
|
30
|
+
.query(query_pattern)
|
|
31
|
+
.where { |m| match_has_text?(m, from) }
|
|
32
|
+
.replace("@name") { to }
|
|
33
|
+
.rewrite
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Rename a struct/class field and its usages
|
|
37
|
+
#
|
|
38
|
+
# @param source [String] Source code
|
|
39
|
+
# @param tree [Tree] Parsed syntax tree
|
|
40
|
+
# @param language [Language] Language for queries
|
|
41
|
+
# @param struct_name [String, nil] Name of struct/class (nil for all)
|
|
42
|
+
# @param from [String] Old field name
|
|
43
|
+
# @param to [String] New field name
|
|
44
|
+
# @return [String] Modified source code
|
|
45
|
+
def rename_field(source, tree, language, struct_name: nil, from:, to:)
|
|
46
|
+
# Query for field declarations and field accesses
|
|
47
|
+
query_pattern = <<~QUERY
|
|
48
|
+
[
|
|
49
|
+
(field_declaration name: (field_identifier) @name)
|
|
50
|
+
(field_expression field: (field_identifier) @name)
|
|
51
|
+
(field_identifier) @name
|
|
52
|
+
]
|
|
53
|
+
QUERY
|
|
54
|
+
|
|
55
|
+
QueryRewriter.new(source, tree, language)
|
|
56
|
+
.query(query_pattern)
|
|
57
|
+
.where { |m| match_has_text?(m, from) }
|
|
58
|
+
.replace("@name") { to }
|
|
59
|
+
.rewrite
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Extract code into a new function
|
|
63
|
+
#
|
|
64
|
+
# @param source [String] Source code
|
|
65
|
+
# @param tree [Tree] Parsed syntax tree
|
|
66
|
+
# @param language [Language] Language for queries
|
|
67
|
+
# @param node [Node] Node to extract
|
|
68
|
+
# @param name [String] Name for extracted function
|
|
69
|
+
# @param parameters [Array<String>] Parameter names
|
|
70
|
+
# @param insert_after [Node, nil] Where to insert the new function
|
|
71
|
+
# @return [String] Modified source code
|
|
72
|
+
def extract_function(source, tree, language, node:, name:, parameters: [], insert_after: nil)
|
|
73
|
+
# Build function call
|
|
74
|
+
param_list = parameters.join(", ")
|
|
75
|
+
call_reference = parameters.empty? ? "#{name}()" : "#{name}(#{param_list})"
|
|
76
|
+
|
|
77
|
+
# Build function definition
|
|
78
|
+
node_text = source[node.start_byte...node.end_byte]
|
|
79
|
+
param_decl = parameters.map { |p| "#{p}: _" }.join(", ")
|
|
80
|
+
fn_def = "fn #{name}(#{param_decl}) {\n #{node_text}\n}"
|
|
81
|
+
|
|
82
|
+
# Determine where to insert
|
|
83
|
+
insert_target = insert_after || find_enclosing_function(node) || tree.root_node
|
|
84
|
+
|
|
85
|
+
Transformer.new(source, tree)
|
|
86
|
+
.extract(node, to: insert_target, reference: call_reference) { fn_def }
|
|
87
|
+
.rewrite
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Inline a variable (replace usages with its value)
|
|
91
|
+
#
|
|
92
|
+
# @param source [String] Source code
|
|
93
|
+
# @param tree [Tree] Parsed syntax tree
|
|
94
|
+
# @param language [Language] Language for queries
|
|
95
|
+
# @param name [String] Variable name to inline
|
|
96
|
+
# @param scope [Node, nil] Scope to limit inlining (nil for entire tree)
|
|
97
|
+
# @return [String] Modified source code
|
|
98
|
+
def inline_variable(source, tree, language, name:, scope: nil)
|
|
99
|
+
# Find the variable declaration and its value
|
|
100
|
+
decl_query = "(let_declaration pattern: (identifier) @var_name value: (_) @value)"
|
|
101
|
+
|
|
102
|
+
query = TreeSitter::Query.new(language, decl_query)
|
|
103
|
+
cursor = TreeSitter::QueryCursor.new
|
|
104
|
+
root = scope || tree.root_node
|
|
105
|
+
matches = cursor.matches(query, root, source)
|
|
106
|
+
|
|
107
|
+
# Find the declaration for our variable
|
|
108
|
+
decl_match = matches.find do |m|
|
|
109
|
+
m.captures.any? { |c| c.name == "var_name" && c.node.text == name }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return source unless decl_match
|
|
113
|
+
|
|
114
|
+
# Get the value to inline
|
|
115
|
+
value_capture = decl_match.captures.find { |c| c.name == "value" }
|
|
116
|
+
return source unless value_capture
|
|
117
|
+
|
|
118
|
+
value_text = value_capture.node.text
|
|
119
|
+
|
|
120
|
+
# Find all usages and replace
|
|
121
|
+
usage_query = "(identifier) @usage"
|
|
122
|
+
|
|
123
|
+
QueryRewriter.new(source, tree, language)
|
|
124
|
+
.query(usage_query)
|
|
125
|
+
.where { |m| match_has_text?(m, name) && !declaration?(m, source) }
|
|
126
|
+
.replace("@usage") { value_text }
|
|
127
|
+
.rewrite
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Add an attribute/annotation to items matching a query
|
|
131
|
+
#
|
|
132
|
+
# @param source [String] Source code
|
|
133
|
+
# @param tree [Tree] Parsed syntax tree
|
|
134
|
+
# @param language [Language] Language for queries
|
|
135
|
+
# @param query_pattern [String] Query to match items
|
|
136
|
+
# @param attribute [String] Attribute to add (e.g., "#[derive(Debug)]")
|
|
137
|
+
# @return [String] Modified source code
|
|
138
|
+
def add_attribute(source, tree, language, query_pattern:, attribute:)
|
|
139
|
+
QueryRewriter.new(source, tree, language)
|
|
140
|
+
.query(query_pattern)
|
|
141
|
+
.insert_before("@item") { "#{attribute}\n" }
|
|
142
|
+
.rewrite
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Remove items matching a query
|
|
146
|
+
#
|
|
147
|
+
# @param source [String] Source code
|
|
148
|
+
# @param tree [Tree] Parsed syntax tree
|
|
149
|
+
# @param language [Language] Language for queries
|
|
150
|
+
# @param query_pattern [String] Query to match items to remove
|
|
151
|
+
# @param capture_name [String] Name of capture to remove
|
|
152
|
+
# @return [String] Modified source code
|
|
153
|
+
def remove_matching(source, tree, language, query_pattern:, capture_name: "@item")
|
|
154
|
+
QueryRewriter.new(source, tree, language)
|
|
155
|
+
.query(query_pattern)
|
|
156
|
+
.remove(capture_name)
|
|
157
|
+
.rewrite
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def build_rename_query(kind)
|
|
163
|
+
case kind
|
|
164
|
+
when :function
|
|
165
|
+
<<~QUERY
|
|
166
|
+
[
|
|
167
|
+
(function_item name: (identifier) @name)
|
|
168
|
+
(call_expression function: (identifier) @name)
|
|
169
|
+
]
|
|
170
|
+
QUERY
|
|
171
|
+
when :type
|
|
172
|
+
<<~QUERY
|
|
173
|
+
[
|
|
174
|
+
(struct_item name: (type_identifier) @name)
|
|
175
|
+
(enum_item name: (type_identifier) @name)
|
|
176
|
+
(type_identifier) @name
|
|
177
|
+
]
|
|
178
|
+
QUERY
|
|
179
|
+
when :variable
|
|
180
|
+
"(identifier) @name"
|
|
181
|
+
else
|
|
182
|
+
"(identifier) @name"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def match_has_text?(match, text)
|
|
187
|
+
match.captures.any? { |c| c.node.text == text }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
DECLARATION_TYPES = ["let_declaration", "parameter", "function_item"].freeze
|
|
191
|
+
|
|
192
|
+
def declaration?(match, _source)
|
|
193
|
+
# Check if this identifier is part of a declaration pattern
|
|
194
|
+
match.captures.any? do |c|
|
|
195
|
+
parent = c.node.parent
|
|
196
|
+
next false unless parent
|
|
197
|
+
|
|
198
|
+
# Common declaration patterns
|
|
199
|
+
DECLARATION_TYPES.include?(parent.type)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def find_enclosing_function(node)
|
|
204
|
+
current = node.parent
|
|
205
|
+
while current
|
|
206
|
+
return current if current.type == "function_item"
|
|
207
|
+
|
|
208
|
+
current = current.parent
|
|
209
|
+
end
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TreeSitter
|
|
4
|
+
# Rewriter class for applying edits to parsed source code.
|
|
5
|
+
# Inspired by Parser::TreeRewriter from the parser gem.
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage
|
|
8
|
+
# tree = TreeSitter::Parser.new.tap { |p| p.language = "rust" }.parse(source)
|
|
9
|
+
# fn_name = tree.root_node.child(0).child_by_field_name("name")
|
|
10
|
+
#
|
|
11
|
+
# new_source = TreeSitter::Rewriter.new(source, tree)
|
|
12
|
+
# .replace(fn_name, "new_name")
|
|
13
|
+
# .rewrite
|
|
14
|
+
#
|
|
15
|
+
class Rewriter
|
|
16
|
+
# Represents a single edit operation
|
|
17
|
+
Edit = Struct.new(:start_byte, :end_byte, :replacement, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
attr_reader :source, :tree, :edits
|
|
20
|
+
|
|
21
|
+
# Initialize a new Rewriter
|
|
22
|
+
#
|
|
23
|
+
# @param source [String] The source code to rewrite
|
|
24
|
+
# @param tree [TreeSitter::Tree, nil] Optional parsed tree (will parse if not provided)
|
|
25
|
+
# @param parser [TreeSitter::Parser, nil] Optional parser for re-parsing (needed if tree not provided)
|
|
26
|
+
def initialize(source, tree = nil, parser: nil)
|
|
27
|
+
@source = source.dup.freeze
|
|
28
|
+
@tree = tree
|
|
29
|
+
@parser = parser
|
|
30
|
+
@edits = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Remove the text at the given node or range
|
|
34
|
+
#
|
|
35
|
+
# @param node_or_range [TreeSitter::Node, TreeSitter::Range] The node or range to remove
|
|
36
|
+
# @return [self] Returns self for method chaining
|
|
37
|
+
def remove(node_or_range)
|
|
38
|
+
replace(node_or_range, "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Replace the text at the given node or range with new content
|
|
42
|
+
#
|
|
43
|
+
# @param node_or_range [TreeSitter::Node, TreeSitter::Range] The node or range to replace
|
|
44
|
+
# @param content [String] The replacement content
|
|
45
|
+
# @return [self] Returns self for method chaining
|
|
46
|
+
def replace(node_or_range, content)
|
|
47
|
+
range = normalize_range(node_or_range)
|
|
48
|
+
@edits << Edit.new(
|
|
49
|
+
start_byte: range.start_byte,
|
|
50
|
+
end_byte: range.end_byte,
|
|
51
|
+
replacement: content.to_s,
|
|
52
|
+
)
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Insert text before the given node or range
|
|
57
|
+
#
|
|
58
|
+
# @param node_or_range [TreeSitter::Node, TreeSitter::Range] The node or range
|
|
59
|
+
# @param content [String] The content to insert
|
|
60
|
+
# @return [self] Returns self for method chaining
|
|
61
|
+
def insert_before(node_or_range, content)
|
|
62
|
+
range = normalize_range(node_or_range)
|
|
63
|
+
@edits << Edit.new(
|
|
64
|
+
start_byte: range.start_byte,
|
|
65
|
+
end_byte: range.start_byte,
|
|
66
|
+
replacement: content.to_s,
|
|
67
|
+
)
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Insert text after the given node or range
|
|
72
|
+
#
|
|
73
|
+
# @param node_or_range [TreeSitter::Node, TreeSitter::Range] The node or range
|
|
74
|
+
# @param content [String] The content to insert
|
|
75
|
+
# @return [self] Returns self for method chaining
|
|
76
|
+
def insert_after(node_or_range, content)
|
|
77
|
+
range = normalize_range(node_or_range)
|
|
78
|
+
@edits << Edit.new(
|
|
79
|
+
start_byte: range.end_byte,
|
|
80
|
+
end_byte: range.end_byte,
|
|
81
|
+
replacement: content.to_s,
|
|
82
|
+
)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wrap the node or range with before and after text
|
|
87
|
+
#
|
|
88
|
+
# @param node_or_range [TreeSitter::Node, TreeSitter::Range] The node or range to wrap
|
|
89
|
+
# @param before_text [String] Text to insert before
|
|
90
|
+
# @param after_text [String] Text to insert after
|
|
91
|
+
# @return [self] Returns self for method chaining
|
|
92
|
+
def wrap(node_or_range, before_text, after_text)
|
|
93
|
+
insert_before(node_or_range, before_text)
|
|
94
|
+
insert_after(node_or_range, after_text)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Apply all accumulated edits and return the new source code
|
|
99
|
+
#
|
|
100
|
+
# Edits are applied in reverse order (from end to start) to preserve
|
|
101
|
+
# byte positions of earlier edits.
|
|
102
|
+
#
|
|
103
|
+
# @return [String] The rewritten source code
|
|
104
|
+
def rewrite
|
|
105
|
+
# Sort edits by position descending to apply from end to start
|
|
106
|
+
# This prevents earlier edits from invalidating later positions
|
|
107
|
+
sorted = @edits.sort_by { |e| [-e.start_byte, -e.end_byte] }
|
|
108
|
+
|
|
109
|
+
result = @source.dup
|
|
110
|
+
sorted.each do |edit|
|
|
111
|
+
result[edit.start_byte...edit.end_byte] = edit.replacement
|
|
112
|
+
end
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Apply edits and return both the new source and a new parse tree
|
|
117
|
+
#
|
|
118
|
+
# @return [Array<String, TreeSitter::Tree>] The new source and tree
|
|
119
|
+
# @raise [RuntimeError] If no parser is available for re-parsing
|
|
120
|
+
def rewrite_with_tree
|
|
121
|
+
new_source = rewrite
|
|
122
|
+
|
|
123
|
+
parser = @parser || create_parser_from_tree
|
|
124
|
+
raise "No parser available for re-parsing" unless parser
|
|
125
|
+
|
|
126
|
+
new_tree = parser.parse(new_source)
|
|
127
|
+
[new_source, new_tree]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def normalize_range(node_or_range)
|
|
133
|
+
case node_or_range
|
|
134
|
+
when TreeSitter::Node
|
|
135
|
+
node_or_range.range
|
|
136
|
+
when TreeSitter::Range
|
|
137
|
+
node_or_range
|
|
138
|
+
else
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"Expected TreeSitter::Node or TreeSitter::Range, got #{node_or_range.class}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def create_parser_from_tree
|
|
145
|
+
return unless @tree
|
|
146
|
+
|
|
147
|
+
parser = TreeSitter::Parser.new
|
|
148
|
+
lang = @tree.language
|
|
149
|
+
parser.language = lang.name if lang
|
|
150
|
+
parser
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TreeSitter
|
|
4
|
+
# Structural transformations for syntax tree nodes.
|
|
5
|
+
# Provides operations for moving, copying, swapping, and reordering nodes.
|
|
6
|
+
#
|
|
7
|
+
# @example Swap two function arguments
|
|
8
|
+
# Transformer.new(source, tree)
|
|
9
|
+
# .swap(arg1_node, arg2_node)
|
|
10
|
+
# .rewrite
|
|
11
|
+
#
|
|
12
|
+
# @example Move a function to a different location
|
|
13
|
+
# Transformer.new(source, tree)
|
|
14
|
+
# .move(fn_node, after: other_fn_node)
|
|
15
|
+
# .rewrite
|
|
16
|
+
#
|
|
17
|
+
class Transformer
|
|
18
|
+
# Represents a pending structural operation
|
|
19
|
+
Operation = Struct.new(:type, :params, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
attr_reader :source, :tree, :operations
|
|
22
|
+
|
|
23
|
+
# Initialize a new Transformer
|
|
24
|
+
#
|
|
25
|
+
# @param source [String] The source code to transform
|
|
26
|
+
# @param tree [TreeSitter::Tree] The parsed syntax tree
|
|
27
|
+
# @param parser [TreeSitter::Parser, nil] Optional parser for re-parsing
|
|
28
|
+
def initialize(source, tree, parser: nil)
|
|
29
|
+
@source = source.dup.freeze
|
|
30
|
+
@tree = tree
|
|
31
|
+
@parser = parser
|
|
32
|
+
@operations = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Move a node to a new location (removes from original, inserts at target)
|
|
36
|
+
#
|
|
37
|
+
# @param node [TreeSitter::Node] The node to move
|
|
38
|
+
# @param before [TreeSitter::Node, nil] Insert before this node
|
|
39
|
+
# @param after [TreeSitter::Node, nil] Insert after this node
|
|
40
|
+
# @param separator [String] Separator to use (default: newline)
|
|
41
|
+
# @return [self] For method chaining
|
|
42
|
+
def move(node, before: nil, after: nil, separator: "\n")
|
|
43
|
+
raise ArgumentError, "Must specify either before: or after:" if before.nil? && after.nil?
|
|
44
|
+
raise ArgumentError, "Cannot specify both before: and after:" if before && after
|
|
45
|
+
|
|
46
|
+
@operations << Operation.new(
|
|
47
|
+
type: :move,
|
|
48
|
+
params: { node: node, before: before, after: after, separator: separator },
|
|
49
|
+
)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Copy a node to a new location (original remains)
|
|
54
|
+
#
|
|
55
|
+
# @param node [TreeSitter::Node] The node to copy
|
|
56
|
+
# @param before [TreeSitter::Node, nil] Insert before this node
|
|
57
|
+
# @param after [TreeSitter::Node, nil] Insert after this node
|
|
58
|
+
# @param separator [String] Separator to use (default: newline)
|
|
59
|
+
# @return [self] For method chaining
|
|
60
|
+
def copy(node, before: nil, after: nil, separator: "\n")
|
|
61
|
+
raise ArgumentError, "Must specify either before: or after:" if before.nil? && after.nil?
|
|
62
|
+
raise ArgumentError, "Cannot specify both before: and after:" if before && after
|
|
63
|
+
|
|
64
|
+
@operations << Operation.new(
|
|
65
|
+
type: :copy,
|
|
66
|
+
params: { node: node, before: before, after: after, separator: separator },
|
|
67
|
+
)
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Swap two nodes
|
|
72
|
+
#
|
|
73
|
+
# @param node_a [TreeSitter::Node] First node
|
|
74
|
+
# @param node_b [TreeSitter::Node] Second node
|
|
75
|
+
# @return [self] For method chaining
|
|
76
|
+
def swap(node_a, node_b)
|
|
77
|
+
validate_non_overlapping(node_a, node_b)
|
|
78
|
+
|
|
79
|
+
@operations << Operation.new(
|
|
80
|
+
type: :swap,
|
|
81
|
+
params: { node_a: node_a, node_b: node_b },
|
|
82
|
+
)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reorder children of a parent node
|
|
87
|
+
#
|
|
88
|
+
# @param parent [TreeSitter::Node] The parent node
|
|
89
|
+
# @param order [Array<Integer>] New order as array of indices
|
|
90
|
+
# @return [self] For method chaining
|
|
91
|
+
# @example Reverse first three children: reorder_children(parent, [2, 1, 0, 3, 4])
|
|
92
|
+
def reorder_children(parent, order)
|
|
93
|
+
@operations << Operation.new(
|
|
94
|
+
type: :reorder,
|
|
95
|
+
params: { parent: parent, order: order },
|
|
96
|
+
)
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Extract node content to a new location with a reference
|
|
101
|
+
#
|
|
102
|
+
# @param node [TreeSitter::Node] Node to extract
|
|
103
|
+
# @param to [TreeSitter::Node] Where to place extracted content (inserted after)
|
|
104
|
+
# @param reference [String] Reference to leave in place of original
|
|
105
|
+
# @yield [String] Optional block to transform extracted content
|
|
106
|
+
# @return [self] For method chaining
|
|
107
|
+
def extract(node, to:, reference:, &wrapper)
|
|
108
|
+
@operations << Operation.new(
|
|
109
|
+
type: :extract,
|
|
110
|
+
params: { node: node, to: to, reference: reference, wrapper: wrapper },
|
|
111
|
+
)
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Duplicate a node immediately after itself
|
|
116
|
+
#
|
|
117
|
+
# @param node [TreeSitter::Node] Node to duplicate
|
|
118
|
+
# @param separator [String] Separator between original and copy
|
|
119
|
+
# @yield [String] Optional block to transform the copy
|
|
120
|
+
# @return [self] For method chaining
|
|
121
|
+
def duplicate(node, separator: "\n", &transformer)
|
|
122
|
+
@operations << Operation.new(
|
|
123
|
+
type: :duplicate,
|
|
124
|
+
params: { node: node, separator: separator, transformer: transformer },
|
|
125
|
+
)
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Apply all accumulated operations
|
|
130
|
+
#
|
|
131
|
+
# @return [String] The transformed source code
|
|
132
|
+
def rewrite
|
|
133
|
+
edits = build_edits
|
|
134
|
+
apply_edits(edits)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Apply operations and return both source and new tree
|
|
138
|
+
#
|
|
139
|
+
# @return [Array<String, Tree>] The new source and re-parsed tree
|
|
140
|
+
def rewrite_with_tree
|
|
141
|
+
new_source = rewrite
|
|
142
|
+
|
|
143
|
+
parser = @parser || create_parser_from_tree
|
|
144
|
+
raise "No parser available for re-parsing" unless parser
|
|
145
|
+
|
|
146
|
+
new_tree = parser.parse(new_source)
|
|
147
|
+
[new_source, new_tree]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def build_edits
|
|
153
|
+
edits = []
|
|
154
|
+
|
|
155
|
+
@operations.each do |op|
|
|
156
|
+
case op.type
|
|
157
|
+
when :swap
|
|
158
|
+
edits.concat(build_swap_edits(op.params))
|
|
159
|
+
when :move
|
|
160
|
+
edits.concat(build_move_edits(op.params))
|
|
161
|
+
when :copy
|
|
162
|
+
edits.concat(build_copy_edits(op.params))
|
|
163
|
+
when :reorder
|
|
164
|
+
edits.concat(build_reorder_edits(op.params))
|
|
165
|
+
when :extract
|
|
166
|
+
edits.concat(build_extract_edits(op.params))
|
|
167
|
+
when :duplicate
|
|
168
|
+
edits.concat(build_duplicate_edits(op.params))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
edits
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_swap_edits(params)
|
|
176
|
+
node_a = params[:node_a]
|
|
177
|
+
node_b = params[:node_b]
|
|
178
|
+
|
|
179
|
+
text_a = node_text(node_a)
|
|
180
|
+
text_b = node_text(node_b)
|
|
181
|
+
|
|
182
|
+
[
|
|
183
|
+
{ start_byte: node_a.start_byte, end_byte: node_a.end_byte, replacement: text_b },
|
|
184
|
+
{ start_byte: node_b.start_byte, end_byte: node_b.end_byte, replacement: text_a },
|
|
185
|
+
]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_move_edits(params)
|
|
189
|
+
node = params[:node]
|
|
190
|
+
before = params[:before]
|
|
191
|
+
after = params[:after]
|
|
192
|
+
separator = params[:separator]
|
|
193
|
+
|
|
194
|
+
text = node_text(node)
|
|
195
|
+
edits = []
|
|
196
|
+
|
|
197
|
+
# Remove from original location
|
|
198
|
+
edits << { start_byte: node.start_byte, end_byte: node.end_byte, replacement: "" }
|
|
199
|
+
|
|
200
|
+
# Insert at new location
|
|
201
|
+
if before
|
|
202
|
+
edits << { start_byte: before.start_byte, end_byte: before.start_byte, replacement: text + separator }
|
|
203
|
+
elsif after
|
|
204
|
+
edits << { start_byte: after.end_byte, end_byte: after.end_byte, replacement: separator + text }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
edits
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_copy_edits(params)
|
|
211
|
+
node = params[:node]
|
|
212
|
+
before = params[:before]
|
|
213
|
+
after = params[:after]
|
|
214
|
+
separator = params[:separator]
|
|
215
|
+
|
|
216
|
+
text = node_text(node)
|
|
217
|
+
|
|
218
|
+
if before
|
|
219
|
+
[{ start_byte: before.start_byte, end_byte: before.start_byte, replacement: text + separator }]
|
|
220
|
+
elsif after
|
|
221
|
+
[{ start_byte: after.end_byte, end_byte: after.end_byte, replacement: separator + text }]
|
|
222
|
+
else
|
|
223
|
+
[]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def build_reorder_edits(params)
|
|
228
|
+
parent = params[:parent]
|
|
229
|
+
order = params[:order]
|
|
230
|
+
|
|
231
|
+
# Get all named children
|
|
232
|
+
children = []
|
|
233
|
+
parent.named_children.each { |child| children << child }
|
|
234
|
+
|
|
235
|
+
return [] if children.empty?
|
|
236
|
+
|
|
237
|
+
# Validate order indices
|
|
238
|
+
raise ArgumentError, "Order indices out of range" unless order.all? { |i| i >= 0 && i < children.length }
|
|
239
|
+
|
|
240
|
+
# Build content for each position in new order
|
|
241
|
+
new_contents = order.map { |i| node_text(children[i]) }
|
|
242
|
+
|
|
243
|
+
# Create edits to replace each child with its new content
|
|
244
|
+
edits = []
|
|
245
|
+
children.each_with_index do |child, idx|
|
|
246
|
+
new_text = new_contents[idx] || node_text(child)
|
|
247
|
+
next if new_text == node_text(child)
|
|
248
|
+
|
|
249
|
+
edits << { start_byte: child.start_byte, end_byte: child.end_byte, replacement: new_text }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
edits
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_extract_edits(params)
|
|
256
|
+
node = params[:node]
|
|
257
|
+
to = params[:to]
|
|
258
|
+
reference = params[:reference]
|
|
259
|
+
wrapper = params[:wrapper]
|
|
260
|
+
|
|
261
|
+
text = node_text(node)
|
|
262
|
+
extracted = wrapper ? wrapper.call(text) : text
|
|
263
|
+
|
|
264
|
+
[
|
|
265
|
+
# Replace original with reference
|
|
266
|
+
{ start_byte: node.start_byte, end_byte: node.end_byte, replacement: reference },
|
|
267
|
+
# Insert extracted content at target
|
|
268
|
+
{ start_byte: to.end_byte, end_byte: to.end_byte, replacement: "\n\n" + extracted },
|
|
269
|
+
]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def build_duplicate_edits(params)
|
|
273
|
+
node = params[:node]
|
|
274
|
+
separator = params[:separator]
|
|
275
|
+
transformer = params[:transformer]
|
|
276
|
+
|
|
277
|
+
text = node_text(node)
|
|
278
|
+
copy_text = transformer ? transformer.call(text) : text
|
|
279
|
+
|
|
280
|
+
[{ start_byte: node.end_byte, end_byte: node.end_byte, replacement: separator + copy_text }]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def apply_edits(edits)
|
|
284
|
+
# Sort by position descending to apply from end to start
|
|
285
|
+
sorted = edits.sort_by { |e| [-e[:start_byte], -e[:end_byte]] }
|
|
286
|
+
|
|
287
|
+
result = @source.dup
|
|
288
|
+
sorted.each do |edit|
|
|
289
|
+
result[edit[:start_byte]...edit[:end_byte]] = edit[:replacement]
|
|
290
|
+
end
|
|
291
|
+
result
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def node_text(node)
|
|
295
|
+
@source[node.start_byte...node.end_byte]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def validate_non_overlapping(*nodes)
|
|
299
|
+
ranges = nodes.map { |n| (n.start_byte...n.end_byte) }
|
|
300
|
+
|
|
301
|
+
ranges.combination(2).each do |r1, r2|
|
|
302
|
+
if ranges_overlap?(r1, r2)
|
|
303
|
+
raise ArgumentError, "Nodes must not overlap"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def ranges_overlap?(r1, r2)
|
|
309
|
+
r1.cover?(r2.begin) || r1.cover?(r2.end - 1) ||
|
|
310
|
+
r2.cover?(r1.begin) || r2.cover?(r1.end - 1)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def create_parser_from_tree
|
|
314
|
+
return unless @tree
|
|
315
|
+
|
|
316
|
+
parser = TreeSitter::Parser.new
|
|
317
|
+
lang = @tree.language
|
|
318
|
+
parser.language = lang.name if lang
|
|
319
|
+
parser
|
|
320
|
+
rescue StandardError
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|