emerge 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +47 -1
- data/lib/commands/config/orderfiles/orderfiles_ios.rb +0 -2
- data/lib/commands/config/snapshots/snapshots_ios.rb +5 -8
- data/lib/commands/global_options.rb +2 -0
- data/lib/commands/integrate/fastlane.rb +2 -2
- data/lib/commands/reaper/reaper.rb +201 -0
- data/lib/commands/upload/snapshots/snapshots.rb +4 -4
- data/lib/emerge_cli.rb +28 -19
- data/lib/reaper/ast_parser.rb +419 -0
- data/lib/reaper/code_deleter.rb +263 -0
- data/lib/utils/git.rb +1 -1
- data/lib/utils/github.rb +3 -3
- data/lib/utils/logger.rb +2 -2
- data/lib/utils/network.rb +33 -13
- data/lib/utils/version_check.rb +32 -0
- data/lib/version.rb +2 -2
- data/parsers/libtree-sitter-java-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-java-linux-x86_64.so +0 -0
- data/parsers/libtree-sitter-kotlin-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-kotlin-linux-x86_64.so +0 -0
- data/parsers/libtree-sitter-swift-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-swift-linux-x86_64.so +0 -0
- metadata +25 -126
@@ -0,0 +1,419 @@
|
|
1
|
+
require 'tree_sitter'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Reaper
|
5
|
+
# Parses the AST of a given file using Tree Sitter and allows us to find usages or delete types.
|
6
|
+
# This does have a lot of limitations since it only looks at a single file at a time,
|
7
|
+
# but can get us most of the way there.
|
8
|
+
class AstParser
|
9
|
+
DECLARATION_NODE_TYPES = {
|
10
|
+
'swift' => %i[class_declaration protocol_declaration],
|
11
|
+
'kotlin' => %i[class_declaration protocol_declaration interface_declaration object_declaration],
|
12
|
+
'java' => %i[class_declaration protocol_declaration interface_declaration]
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
IDENTIFIER_NODE_TYPES = {
|
16
|
+
'swift' => %i[simple_identifier qualified_name identifier type_identifier],
|
17
|
+
'kotlin' => %i[simple_identifier qualified_name identifier type_identifier],
|
18
|
+
'java' => %i[simple_identifier qualified_name identifier type_identifier]
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
COMMENT_AND_IMPORT_NODE_TYPES = {
|
22
|
+
'swift' => %i[comment import_declaration],
|
23
|
+
'kotlin' => %i[comment import_header],
|
24
|
+
'java' => %i[comment import_declaration]
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
attr_reader :parser, :language
|
28
|
+
|
29
|
+
def initialize(language)
|
30
|
+
@parser = TreeSitter::Parser.new
|
31
|
+
@language = language
|
32
|
+
@current_file_contents = nil
|
33
|
+
|
34
|
+
platform = case RUBY_PLATFORM
|
35
|
+
when /darwin/
|
36
|
+
'darwin'
|
37
|
+
when /linux/
|
38
|
+
'linux'
|
39
|
+
else
|
40
|
+
raise "Unsupported platform: #{RUBY_PLATFORM}"
|
41
|
+
end
|
42
|
+
|
43
|
+
arch = case RUBY_PLATFORM
|
44
|
+
when /x86_64|amd64/
|
45
|
+
'x86_64'
|
46
|
+
when /arm64|aarch64/
|
47
|
+
'arm64'
|
48
|
+
else
|
49
|
+
raise "Unsupported architecture: #{RUBY_PLATFORM}"
|
50
|
+
end
|
51
|
+
|
52
|
+
extension = platform == 'darwin' ? 'dylib' : 'so'
|
53
|
+
parser_file = "libtree-sitter-#{language}-#{platform}-#{arch}.#{extension}"
|
54
|
+
parser_path = File.join('parsers', parser_file)
|
55
|
+
|
56
|
+
case language
|
57
|
+
when 'swift'
|
58
|
+
@parser.language = TreeSitter::Language.load('swift', parser_path)
|
59
|
+
when 'kotlin'
|
60
|
+
@parser.language = TreeSitter::Language.load('kotlin', parser_path)
|
61
|
+
when 'java'
|
62
|
+
@parser.language = TreeSitter::Language.load('java', parser_path)
|
63
|
+
else
|
64
|
+
raise "Unsupported language: #{language}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Deletes a type from the given file contents.
|
69
|
+
# Returns the modified file contents if successful, otherwise nil.
|
70
|
+
# TODO(telkins): Look into the tree-sitter query API to see if it simplifies this.
|
71
|
+
def delete_type(file_contents:, type_name:)
|
72
|
+
@current_file_contents = file_contents
|
73
|
+
tree = @parser.parse_string(nil, file_contents)
|
74
|
+
cursor = TreeSitter::TreeCursor.new(tree.root_node)
|
75
|
+
nodes_to_process = [cursor.current_node]
|
76
|
+
lines_to_remove = []
|
77
|
+
|
78
|
+
while (node = nodes_to_process.shift)
|
79
|
+
Logger.debug "Processing node: #{node.type} #{node_text(node)}"
|
80
|
+
if declaration_node_types.include?(node.type)
|
81
|
+
type_identifier_node = find_type_identifier(node)
|
82
|
+
if type_identifier_node && fully_qualified_type_name(type_identifier_node) == type_name
|
83
|
+
remove_node(node, lines_to_remove)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
if extension?(node)
|
88
|
+
user_type_nodes = node.select { |n| n.type == :user_type }
|
89
|
+
if user_type_nodes.length >= 1 && fully_qualified_type_name(user_type_nodes[0]) == type_name
|
90
|
+
remove_node(node, lines_to_remove)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
node.each_named { |child| nodes_to_process.push(child) }
|
95
|
+
end
|
96
|
+
|
97
|
+
lines = file_contents.split("\n")
|
98
|
+
lines_to_remove.each do |range|
|
99
|
+
Logger.debug "Removing lines #{range[:start]} to #{range[:end]}"
|
100
|
+
(range[:start]..range[:end]).each { |i| lines[i] = nil }
|
101
|
+
|
102
|
+
# Remove extra newline after class declaration, but only if it's blank
|
103
|
+
if range[:end] + 1 < lines.length && !lines[range[:end] + 1].nil? && lines[range[:end] + 1].match?(/^\s*$/)
|
104
|
+
lines[range[:end] + 1] = nil
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
modified_source = lines.compact.join("\n")
|
109
|
+
new_tree = @parser.parse_string(nil, modified_source)
|
110
|
+
|
111
|
+
return nil if only_comments_and_imports?(TreeSitter::TreeCursor.new(new_tree.root_node))
|
112
|
+
|
113
|
+
# Preserve original newline state
|
114
|
+
had_final_newline = file_contents.end_with?("\n")
|
115
|
+
modified_source = modified_source.rstrip
|
116
|
+
had_final_newline ? "#{modified_source}\n" : modified_source
|
117
|
+
end
|
118
|
+
|
119
|
+
# Finds all usages of a given type in a file.
|
120
|
+
# TODO(telkins): Look into the tree-sitter query API to see if it simplifies this.
|
121
|
+
def find_usages(file_contents:, type_name:)
|
122
|
+
@current_file_contents = file_contents
|
123
|
+
tree = @parser.parse_string(nil, file_contents)
|
124
|
+
cursor = TreeSitter::TreeCursor.new(tree.root_node)
|
125
|
+
usages = []
|
126
|
+
nodes_to_process = [cursor.current_node]
|
127
|
+
|
128
|
+
while (node = nodes_to_process.shift)
|
129
|
+
identifier_type = identifier_node_types.include?(node.type)
|
130
|
+
declaration_type = if node == tree.root_node
|
131
|
+
false
|
132
|
+
else
|
133
|
+
declaration_node_types.include?(node.parent&.type)
|
134
|
+
end
|
135
|
+
if declaration_type && fully_qualified_type_name(node) == type_name
|
136
|
+
usages << { line: node.start_point.row, usage_type: 'declaration' }
|
137
|
+
elsif identifier_type && node_text(node) == type_name
|
138
|
+
usages << { line: node.start_point.row, usage_type: 'identifier' }
|
139
|
+
end
|
140
|
+
|
141
|
+
node.each { |child| nodes_to_process.push(child) }
|
142
|
+
end
|
143
|
+
|
144
|
+
usages
|
145
|
+
end
|
146
|
+
|
147
|
+
def delete_usage(file_contents:, type_name:)
|
148
|
+
@current_file_contents = file_contents
|
149
|
+
tree = @parser.parse_string(nil, file_contents)
|
150
|
+
cursor = TreeSitter::TreeCursor.new(tree.root_node)
|
151
|
+
nodes_to_process = [cursor.current_node]
|
152
|
+
nodes_to_remove = []
|
153
|
+
|
154
|
+
Logger.debug "Starting to scan for usages of #{type_name}"
|
155
|
+
|
156
|
+
while (node = nodes_to_process.shift)
|
157
|
+
identifier_type = identifier_node_types.include?(node.type)
|
158
|
+
if identifier_type && node_text(node) == type_name
|
159
|
+
Logger.debug "Found usage of #{type_name} in node type: #{node.type}"
|
160
|
+
removable_node = find_removable_parent(node)
|
161
|
+
if removable_node
|
162
|
+
Logger.debug "Will remove parent node of type: #{removable_node.type}"
|
163
|
+
Logger.debug "Node text to remove: #{node_text(removable_node)}"
|
164
|
+
nodes_to_remove << removable_node
|
165
|
+
else
|
166
|
+
Logger.debug 'No suitable parent node found for removal'
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
node.each { |child| nodes_to_process.push(child) }
|
171
|
+
end
|
172
|
+
|
173
|
+
return file_contents if nodes_to_remove.empty?
|
174
|
+
|
175
|
+
Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
|
176
|
+
remove_nodes_from_content(file_contents, nodes_to_remove)
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
def remove_node(node, lines_to_remove)
|
182
|
+
Logger.debug "Removing node: #{node.type}"
|
183
|
+
start_position = node.start_point.row
|
184
|
+
end_position = node.end_point.row
|
185
|
+
lines_to_remove << { start: start_position, end: end_position }
|
186
|
+
|
187
|
+
# Remove comments preceding the class declaration
|
188
|
+
predecessor = node.prev_named_sibling
|
189
|
+
return unless predecessor && predecessor.type == :comment
|
190
|
+
lines_to_remove << { start: predecessor.start_point.row, end: predecessor.end_point.row }
|
191
|
+
end
|
192
|
+
|
193
|
+
def extension?(node)
|
194
|
+
if node.type == :class_declaration
|
195
|
+
!node.find { |n| n.type == :extension }.nil?
|
196
|
+
else
|
197
|
+
false
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def only_comments_and_imports?(root)
|
202
|
+
types = comment_and_import_types
|
203
|
+
root.current_node.all? do |child|
|
204
|
+
types.include?(child.type)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Reaper expects a fully qualified type name, so we need to extract it from the AST.
|
209
|
+
# E.g. `MyModule.MyClass`
|
210
|
+
def fully_qualified_type_name(node)
|
211
|
+
class_name = node_text(node)
|
212
|
+
current_node = node
|
213
|
+
parent = find_parent_type_declaration(current_node)
|
214
|
+
|
215
|
+
while parent
|
216
|
+
type_identifier = find_type_identifier(parent)
|
217
|
+
user_type = find_user_type(parent)
|
218
|
+
|
219
|
+
if type_identifier && type_identifier != current_node
|
220
|
+
class_name = "#{node_text(type_identifier)}.#{class_name}"
|
221
|
+
current_node = type_identifier
|
222
|
+
elsif user_type && user_type != current_node
|
223
|
+
class_name = "#{node_text(user_type)}.#{class_name}"
|
224
|
+
current_node = user_type
|
225
|
+
end
|
226
|
+
|
227
|
+
parent = find_parent_type_declaration(parent)
|
228
|
+
end
|
229
|
+
|
230
|
+
Logger.debug "Fully qualified type name: #{class_name}"
|
231
|
+
class_name
|
232
|
+
end
|
233
|
+
|
234
|
+
def find_parent_type_declaration(node)
|
235
|
+
return nil unless node&.parent
|
236
|
+
|
237
|
+
current = node.parent
|
238
|
+
while current && !current.null?
|
239
|
+
return current if current.type && declaration_node_types.include?(current.type)
|
240
|
+
break unless current.parent && !current.parent.null?
|
241
|
+
current = current.parent
|
242
|
+
end
|
243
|
+
nil
|
244
|
+
end
|
245
|
+
|
246
|
+
def find_type_identifier(node)
|
247
|
+
node.find { |n| identifier_node_types.include?(n.type) }
|
248
|
+
end
|
249
|
+
|
250
|
+
def find_user_type(node)
|
251
|
+
node.find { |n| n.type == :user_type }
|
252
|
+
end
|
253
|
+
|
254
|
+
def declaration_node_types
|
255
|
+
DECLARATION_NODE_TYPES[language]
|
256
|
+
end
|
257
|
+
|
258
|
+
def identifier_node_types
|
259
|
+
IDENTIFIER_NODE_TYPES[language]
|
260
|
+
end
|
261
|
+
|
262
|
+
def comment_and_import_types
|
263
|
+
COMMENT_AND_IMPORT_NODE_TYPES[language]
|
264
|
+
end
|
265
|
+
|
266
|
+
def node_text(node)
|
267
|
+
return '' unless @current_file_contents
|
268
|
+
start_byte = node.start_byte
|
269
|
+
end_byte = node.end_byte
|
270
|
+
@current_file_contents[start_byte...end_byte]
|
271
|
+
end
|
272
|
+
|
273
|
+
def find_removable_parent(node)
|
274
|
+
current = node
|
275
|
+
Logger.debug "Finding removable parent for node type: #{node.type}"
|
276
|
+
|
277
|
+
while current && !current.null?
|
278
|
+
Logger.debug "Checking parent node type: #{current.type}"
|
279
|
+
case current.type
|
280
|
+
when :variable_declaration, # var foo: DeletedType
|
281
|
+
:parameter, # func example(param: DeletedType)
|
282
|
+
:type_annotation, # : DeletedType
|
283
|
+
:argument, # functionCall(param: DeletedType)
|
284
|
+
:import_declaration # import DeletedType
|
285
|
+
Logger.debug "Found removable parent node of type: #{current.type}"
|
286
|
+
return current
|
287
|
+
when :navigation_expression # NetworkDebugger.printStats
|
288
|
+
result = handle_navigation_expression(current)
|
289
|
+
return result if result
|
290
|
+
when :class_declaration, :function_declaration, :method_declaration
|
291
|
+
Logger.debug "Reached structural element, stopping at: #{current.type}"
|
292
|
+
break
|
293
|
+
end
|
294
|
+
current = current.parent
|
295
|
+
end
|
296
|
+
|
297
|
+
Logger.debug 'No better parent found, returning original node'
|
298
|
+
node
|
299
|
+
end
|
300
|
+
|
301
|
+
def handle_navigation_expression(navigation_node)
|
302
|
+
# If this navigation expression is part of a call, remove the entire call
|
303
|
+
parent_call = navigation_node.parent
|
304
|
+
return nil unless parent_call && parent_call.type == :call_expression
|
305
|
+
|
306
|
+
Logger.debug 'Found call expression containing navigation expression'
|
307
|
+
# Check if this call is the only statement in an if condition
|
308
|
+
if_statement = find_parent_if_statement(parent_call)
|
309
|
+
if if_statement && contains_single_statement?(if_statement)
|
310
|
+
Logger.debug 'Found if statement with single call, removing entire if block'
|
311
|
+
return if_statement
|
312
|
+
end
|
313
|
+
parent_call
|
314
|
+
end
|
315
|
+
|
316
|
+
def find_parent_if_statement(node)
|
317
|
+
current = node
|
318
|
+
Logger.debug "Looking for parent if statement starting from node type: #{node.type}"
|
319
|
+
while current && !current.null?
|
320
|
+
Logger.debug " Checking node type: #{current.type}"
|
321
|
+
if current.type == :if_statement
|
322
|
+
Logger.debug ' Found parent if statement'
|
323
|
+
return current
|
324
|
+
end
|
325
|
+
current = current.parent
|
326
|
+
end
|
327
|
+
Logger.debug ' No parent if statement found'
|
328
|
+
nil
|
329
|
+
end
|
330
|
+
|
331
|
+
def contains_single_statement?(if_statement)
|
332
|
+
Logger.debug 'Checking if statement for single statement'
|
333
|
+
# Find the block/body of the if statement - try different field names based on language
|
334
|
+
block = if_statement.child_by_field_name('consequence') ||
|
335
|
+
if_statement.child_by_field_name('body') ||
|
336
|
+
if_statement.find { |child| child.type == :statements }
|
337
|
+
|
338
|
+
unless block
|
339
|
+
Logger.debug ' No block found in if statement. Node structure:'
|
340
|
+
Logger.debug " If statement type: #{if_statement.type}"
|
341
|
+
Logger.debug ' Children types:'
|
342
|
+
if_statement.each do |child|
|
343
|
+
Logger.debug " - #{child.type} (text: #{node_text(child)[0..50]}...)"
|
344
|
+
end
|
345
|
+
return false
|
346
|
+
end
|
347
|
+
|
348
|
+
Logger.debug " Found block of type: #{block.type}"
|
349
|
+
|
350
|
+
relevant_children = block.reject do |child|
|
351
|
+
%i[comment line_break whitespace].include?(child.type)
|
352
|
+
end
|
353
|
+
|
354
|
+
Logger.debug " Found #{relevant_children.length} significant children in if block"
|
355
|
+
relevant_children.each do |child|
|
356
|
+
Logger.debug " Child type: #{child.type}, text: #{node_text(child)[0..50]}..."
|
357
|
+
end
|
358
|
+
|
359
|
+
relevant_children.length == 1
|
360
|
+
end
|
361
|
+
|
362
|
+
def remove_nodes_from_content(content, nodes)
|
363
|
+
# Sort nodes by their position in reverse order to avoid offset issues
|
364
|
+
nodes.sort_by! { |n| -n.start_byte }
|
365
|
+
|
366
|
+
# Check if original file had final newline
|
367
|
+
had_final_newline = content.end_with?("\n")
|
368
|
+
|
369
|
+
# Remove each node and clean up surrounding blank lines
|
370
|
+
modified_contents = content.dup
|
371
|
+
nodes.each do |node|
|
372
|
+
modified_contents = remove_single_node(modified_contents, node)
|
373
|
+
end
|
374
|
+
|
375
|
+
# Restore the original newline state at the end of the file
|
376
|
+
modified_contents.chomp!
|
377
|
+
had_final_newline ? "#{modified_contents}\n" : modified_contents
|
378
|
+
end
|
379
|
+
|
380
|
+
def remove_single_node(content, node)
|
381
|
+
had_final_newline = content.end_with?("\n")
|
382
|
+
|
383
|
+
# Remove the node's content
|
384
|
+
start_byte = node.start_byte
|
385
|
+
end_byte = node.end_byte
|
386
|
+
Logger.debug "Removing text: #{content[start_byte...end_byte]}"
|
387
|
+
content[start_byte...end_byte] = ''
|
388
|
+
|
389
|
+
# Clean up any blank lines created by the removal
|
390
|
+
content = cleanup_blank_lines(content, node.start_point.row, node.end_point.row)
|
391
|
+
|
392
|
+
had_final_newline ? "#{content}\n" : content
|
393
|
+
end
|
394
|
+
|
395
|
+
def cleanup_blank_lines(content, start_line, end_line)
|
396
|
+
lines = content.split("\n")
|
397
|
+
|
398
|
+
# Check for consecutive blank lines around the removed content
|
399
|
+
lines[start_line - 1] = nil if consecutive_blank_lines?(lines, start_line, end_line)
|
400
|
+
|
401
|
+
# Remove any blank lines left in the removed node's place
|
402
|
+
(start_line..end_line).each do |i|
|
403
|
+
lines[i] = nil if lines[i]&.match?(/^\s*$/)
|
404
|
+
end
|
405
|
+
|
406
|
+
lines.compact.join("\n")
|
407
|
+
end
|
408
|
+
|
409
|
+
def consecutive_blank_lines?(lines, start_line, end_line)
|
410
|
+
return false unless start_line > 0 && end_line + 1 < lines.length
|
411
|
+
|
412
|
+
prev_line = lines[start_line - 1]
|
413
|
+
next_line = lines[end_line + 1]
|
414
|
+
|
415
|
+
prev_line&.match?(/^\s*$/) && next_line&.match?(/^\s*$/)
|
416
|
+
end
|
417
|
+
end
|
418
|
+
end
|
419
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require 'xcodeproj'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Reaper
|
5
|
+
class CodeDeleter
|
6
|
+
def initialize(project_root:, platform:, profiler:, skip_delete_usages: false)
|
7
|
+
@project_root = File.expand_path(project_root)
|
8
|
+
@platform = platform
|
9
|
+
@profiler = profiler
|
10
|
+
@skip_delete_usages = skip_delete_usages
|
11
|
+
Logger.debug "Initialized CodeDeleter with project root: #{@project_root}, platform: #{@platform}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def delete_types(types)
|
15
|
+
Logger.debug "Project root: #{@project_root}"
|
16
|
+
|
17
|
+
types.each do |class_info|
|
18
|
+
Logger.info "Deleting #{class_info['class_name']}"
|
19
|
+
|
20
|
+
type_name = parse_type_name(class_info['class_name'])
|
21
|
+
Logger.debug "Parsed type name: #{type_name}"
|
22
|
+
|
23
|
+
# Remove line number from path if present
|
24
|
+
paths = class_info['paths']&.map { |path| path.sub(/:\d+$/, '') }
|
25
|
+
found_usages = @profiler.measure('find_type_in_project') do
|
26
|
+
find_type_in_project(type_name)
|
27
|
+
end
|
28
|
+
|
29
|
+
if paths.nil? || paths.empty?
|
30
|
+
Logger.info "No paths provided for #{type_name}, using found usages instead..."
|
31
|
+
paths = found_usages
|
32
|
+
.select { |usage| usage[:usages].any? { |u| u[:usage_type] == 'declaration' } }
|
33
|
+
.map { |usage| usage[:path] }
|
34
|
+
if paths.empty?
|
35
|
+
Logger.warn "Could not find any files containing #{type_name}"
|
36
|
+
next
|
37
|
+
end
|
38
|
+
Logger.info "Found #{type_name} in: #{paths.join(', ')}"
|
39
|
+
end
|
40
|
+
|
41
|
+
# First pass: Delete declarations
|
42
|
+
paths.each do |path|
|
43
|
+
Logger.debug "Processing path: #{path}"
|
44
|
+
@profiler.measure('delete_type_from_file') do
|
45
|
+
delete_type_from_file(path, type_name)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Second pass: Delete remaining usages (unless skipped)
|
50
|
+
if @skip_delete_usages
|
51
|
+
Logger.info 'Skipping delete usages'
|
52
|
+
else
|
53
|
+
identifier_usages = found_usages.select do |usage|
|
54
|
+
usage[:usages].any? { |u| u[:usage_type] == 'identifier' }
|
55
|
+
end
|
56
|
+
identifier_usage_paths = identifier_usages.map { |usage| usage[:path] }.uniq
|
57
|
+
if identifier_usage_paths.empty?
|
58
|
+
Logger.info 'No identifier usages found, skipping delete usages'
|
59
|
+
else
|
60
|
+
identifier_usage_paths.each do |path|
|
61
|
+
Logger.debug "Processing usages in path: #{path}"
|
62
|
+
@profiler.measure('delete_usages_from_file') do
|
63
|
+
delete_usages_from_file(path, type_name)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def parse_type_name(type_name)
|
74
|
+
# Remove first module prefix for Swift types if present
|
75
|
+
if @platform == 'ios' && type_name.include?('.')
|
76
|
+
type_name.split('.')[1..].join('.')
|
77
|
+
# For Android, strip package name and just use the class name
|
78
|
+
elsif @platform == 'android' && type_name.include?('.')
|
79
|
+
# rubocop:disable Layout/LineLength
|
80
|
+
# Handle cases like "com.emergetools.hackernews.data.remote.ItemResponse $NullResponse (HackerNewsBaseClient.kt)"
|
81
|
+
# rubocop:enable Layout/LineLength
|
82
|
+
has_nested_class = type_name.include?('$')
|
83
|
+
parts = type_name.split
|
84
|
+
if parts.length == 0
|
85
|
+
type_name
|
86
|
+
elsif has_nested_class && parts.length > 1
|
87
|
+
base_name = parts[0].split('.').last
|
88
|
+
nested_class = parts[1].match(/\$(.+)/).captures.first
|
89
|
+
"#{base_name}.#{nested_class}"
|
90
|
+
else
|
91
|
+
parts[0].split('.').last
|
92
|
+
end
|
93
|
+
else
|
94
|
+
type_name
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def delete_type_from_file(path, type_name)
|
99
|
+
full_path = resolve_file_path(path)
|
100
|
+
return unless full_path
|
101
|
+
|
102
|
+
Logger.debug "Processing file: #{full_path}"
|
103
|
+
begin
|
104
|
+
original_contents = @profiler.measure('read_file') { File.read(full_path) }
|
105
|
+
parser = make_parser_for_file(full_path)
|
106
|
+
modified_contents = @profiler.measure('parse_and_delete_type') do
|
107
|
+
parser.delete_type(
|
108
|
+
file_contents: original_contents,
|
109
|
+
type_name: type_name
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
if modified_contents.nil?
|
114
|
+
@profiler.measure('delete_file') do
|
115
|
+
File.delete(full_path)
|
116
|
+
end
|
117
|
+
if parser.language == 'swift'
|
118
|
+
@profiler.measure('delete_type_from_xcode_project') do
|
119
|
+
delete_type_from_xcode_project(full_path)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
Logger.info "Deleted file #{full_path} as it only contained #{type_name}"
|
123
|
+
elsif modified_contents != original_contents
|
124
|
+
@profiler.measure('write_file') do
|
125
|
+
File.write(full_path, modified_contents)
|
126
|
+
end
|
127
|
+
Logger.info "Successfully deleted #{type_name} from #{full_path}"
|
128
|
+
else
|
129
|
+
Logger.warn "No changes made to #{full_path} for #{type_name}"
|
130
|
+
end
|
131
|
+
rescue StandardError => e
|
132
|
+
Logger.error "Failed to delete #{type_name} from #{full_path}: #{e.message}"
|
133
|
+
Logger.error e.backtrace.join("\n")
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def delete_type_from_xcode_project(file_path)
|
138
|
+
xcodeproj_path = Dir.glob(File.join(@project_root, '**/*.xcodeproj')).first
|
139
|
+
if xcodeproj_path.nil?
|
140
|
+
Logger.warn "No Xcode project found in #{@project_root}"
|
141
|
+
return
|
142
|
+
end
|
143
|
+
|
144
|
+
begin
|
145
|
+
project = Xcodeproj::Project.open(xcodeproj_path)
|
146
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s
|
147
|
+
|
148
|
+
file_ref = project.files.find { |f| f.real_path.to_s.end_with?(relative_path) }
|
149
|
+
if file_ref
|
150
|
+
file_ref.remove_from_project
|
151
|
+
project.save
|
152
|
+
Logger.info "Removed #{relative_path} from Xcode project"
|
153
|
+
else
|
154
|
+
Logger.warn "Could not find #{relative_path} in Xcode project"
|
155
|
+
end
|
156
|
+
rescue StandardError => e
|
157
|
+
Logger.error "Failed to update Xcode project: #{e.message}"
|
158
|
+
Logger.error e.backtrace.join("\n")
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def find_type_in_project(type_name)
|
163
|
+
found_usages = []
|
164
|
+
source_patterns = case @platform&.downcase
|
165
|
+
when 'ios'
|
166
|
+
{ 'swift' => '**/*.swift' }
|
167
|
+
when 'android'
|
168
|
+
{
|
169
|
+
'kotlin' => '**/*.kt',
|
170
|
+
'java' => '**/*.java'
|
171
|
+
}
|
172
|
+
else
|
173
|
+
raise "Unsupported platform: #{@platform}"
|
174
|
+
end
|
175
|
+
|
176
|
+
source_patterns.each do |language, pattern|
|
177
|
+
Dir.glob(File.join(@project_root, pattern)).reject { |path| path.include?('/build/') }.each do |file_path|
|
178
|
+
Logger.debug "Scanning #{file_path} for #{type_name}"
|
179
|
+
contents = File.read(file_path)
|
180
|
+
parser = make_parser_for_file(file_path)
|
181
|
+
usages = parser.find_usages(file_contents: contents, type_name: type_name)
|
182
|
+
|
183
|
+
if usages.any?
|
184
|
+
Logger.debug "✅ Found #{type_name} in #{file_path}"
|
185
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s
|
186
|
+
found_usages << {
|
187
|
+
path: relative_path,
|
188
|
+
usages: usages,
|
189
|
+
language: language
|
190
|
+
}
|
191
|
+
end
|
192
|
+
rescue StandardError => e
|
193
|
+
Logger.warn "Error scanning #{file_path}: #{e.message}"
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
found_usages
|
198
|
+
end
|
199
|
+
|
200
|
+
def delete_usages_from_file(path, type_name)
|
201
|
+
full_path = resolve_file_path(path)
|
202
|
+
return unless full_path
|
203
|
+
|
204
|
+
begin
|
205
|
+
original_contents = File.read(full_path)
|
206
|
+
parser = make_parser_for_file(full_path)
|
207
|
+
Logger.debug "Deleting usages of #{type_name} from #{full_path}"
|
208
|
+
modified_contents = parser.delete_usage(
|
209
|
+
file_contents: original_contents,
|
210
|
+
type_name: type_name
|
211
|
+
)
|
212
|
+
|
213
|
+
if modified_contents != original_contents
|
214
|
+
File.write(full_path, modified_contents)
|
215
|
+
Logger.info "Successfully removed usages of #{type_name} from #{full_path}"
|
216
|
+
end
|
217
|
+
rescue StandardError => e
|
218
|
+
Logger.error "Failed to delete usages of #{type_name} from #{full_path}: #{e.message}"
|
219
|
+
Logger.error e.backtrace.join("\n")
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def resolve_file_path(path)
|
224
|
+
# If path starts with /, treat it as relative to project root
|
225
|
+
if path.start_with?('/')
|
226
|
+
path = path[1..] # Remove leading slash
|
227
|
+
full_path = File.join(@project_root, path)
|
228
|
+
return full_path if File.exist?(full_path)
|
229
|
+
end
|
230
|
+
|
231
|
+
# Try direct path first
|
232
|
+
full_path = File.join(@project_root, path)
|
233
|
+
return full_path if File.exist?(full_path)
|
234
|
+
|
235
|
+
# If not found, search recursively
|
236
|
+
Logger.debug "File not found at #{full_path}, searching in project..."
|
237
|
+
matching_files = Dir.glob(File.join(@project_root, '**', path))
|
238
|
+
.reject { |p| p.include?('/build/') }
|
239
|
+
|
240
|
+
if matching_files.empty?
|
241
|
+
Logger.warn "Could not find #{path} in project"
|
242
|
+
return nil
|
243
|
+
elsif matching_files.length > 1
|
244
|
+
Logger.warn "Found multiple matches for #{path}: #{matching_files.join(', ')}"
|
245
|
+
Logger.warn "Using first match: #{matching_files.first}"
|
246
|
+
end
|
247
|
+
|
248
|
+
matching_files.first
|
249
|
+
end
|
250
|
+
|
251
|
+
def make_parser_for_file(file_path)
|
252
|
+
language = case File.extname(file_path)
|
253
|
+
when '.swift' then 'swift'
|
254
|
+
when '.kt' then 'kotlin'
|
255
|
+
when '.java' then 'java'
|
256
|
+
else
|
257
|
+
raise "Unsupported file type for #{file_path}"
|
258
|
+
end
|
259
|
+
AstParser.new(language)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|