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.
@@ -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
data/lib/utils/git.rb CHANGED
@@ -81,7 +81,7 @@ module EmergeCLI
81
81
  .split("\n")
82
82
  .map(&:strip)
83
83
  .find { |line| line.start_with?('HEAD branch: ') }
84
- &.split(' ')
84
+ &.split
85
85
  &.last
86
86
  end
87
87