emerge 0.2.2 → 0.4.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.
@@ -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