emerge 0.2.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  require 'tree_sitter'
2
2
 
3
- module Emerge
3
+ module EmergeCLI
4
4
  module Reaper
5
5
  # Parses the AST of a given file using Tree Sitter and allows us to find usages or delete types.
6
6
  # This does have a lot of limitations since it only looks at a single file at a time,
@@ -8,7 +8,7 @@ module Emerge
8
8
  class AstParser
9
9
  DECLARATION_NODE_TYPES = {
10
10
  'swift' => %i[class_declaration protocol_declaration],
11
- 'kotlin' => %i[class_declaration protocol_declaration interface_declaration],
11
+ 'kotlin' => %i[class_declaration protocol_declaration interface_declaration object_declaration],
12
12
  'java' => %i[class_declaration protocol_declaration interface_declaration]
13
13
  }.freeze
14
14
 
@@ -76,6 +76,7 @@ module Emerge
76
76
  lines_to_remove = []
77
77
 
78
78
  while (node = nodes_to_process.shift)
79
+ Logger.debug "Processing node: #{node.type} #{node_text(node)}"
79
80
  if declaration_node_types.include?(node.type)
80
81
  type_identifier_node = find_type_identifier(node)
81
82
  if type_identifier_node && fully_qualified_type_name(type_identifier_node) == type_name
@@ -95,6 +96,7 @@ module Emerge
95
96
 
96
97
  lines = file_contents.split("\n")
97
98
  lines_to_remove.each do |range|
99
+ Logger.debug "Removing lines #{range[:start]} to #{range[:end]}"
98
100
  (range[:start]..range[:end]).each { |i| lines[i] = nil }
99
101
 
100
102
  # Remove extra newline after class declaration, but only if it's blank
@@ -107,7 +109,11 @@ module Emerge
107
109
  new_tree = @parser.parse_string(nil, modified_source)
108
110
 
109
111
  return nil if only_comments_and_imports?(TreeSitter::TreeCursor.new(new_tree.root_node))
110
- modified_source
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
111
117
  end
112
118
 
113
119
  # Finds all usages of a given type in a file.
@@ -138,9 +144,42 @@ module Emerge
138
144
  usages
139
145
  end
140
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
+
141
179
  private
142
180
 
143
181
  def remove_node(node, lines_to_remove)
182
+ Logger.debug "Removing node: #{node.type}"
144
183
  start_position = node.start_point.row
145
184
  end_position = node.end_point.row
146
185
  lines_to_remove << { start: start_position, end: end_position }
@@ -188,6 +227,7 @@ module Emerge
188
227
  parent = find_parent_type_declaration(parent)
189
228
  end
190
229
 
230
+ Logger.debug "Fully qualified type name: #{class_name}"
191
231
  class_name
192
232
  end
193
233
 
@@ -229,6 +269,151 @@ module Emerge
229
269
  end_byte = node.end_byte
230
270
  @current_file_contents[start_byte...end_byte]
231
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
232
417
  end
233
418
  end
234
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
@@ -59,9 +59,21 @@ module EmergeCLI
59
59
  end
60
60
 
61
61
  def self.previous_sha
62
+ command = 'git rev-list --count HEAD'
63
+ Logger.debug command
64
+ count_stdout, _, count_status = Open3.capture3(command)
65
+
66
+ if !count_status.success? || count_stdout.strip.to_i <= 1
67
+ Logger.error 'Detected shallow clone while trying to get the previous commit. ' \
68
+ 'Please clone with full history using: git clone --no-single-branch ' \
69
+ 'or configure CI with fetch-depth: 0'
70
+ return nil
71
+ end
72
+
62
73
  command = 'git rev-parse HEAD^'
63
74
  Logger.debug command
64
- stdout, _, status = Open3.capture3(command)
75
+ stdout, stderr, status = Open3.capture3(command)
76
+ Logger.error "Failed to get previous SHA: #{stdout}, #{stderr}" if !status.success?
65
77
  stdout.strip if status.success?
66
78
  end
67
79