emerge 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a44399f9df18a4ce4d32984e12eaadb2fa921c34409d8e4daa0dd1b4acddd2c
4
- data.tar.gz: 855d965c1478cbc23eee27f5f99281d1a144b7c40197bdb693fac7d93f0be72d
3
+ metadata.gz: f78e2be6724a6620a5135d20910b99fe76c8a959b00f6703bdad0c0d2c043742
4
+ data.tar.gz: 8366d036be25ec0b3da721346b1c71dcfcdccd2bf7b7f2bea9f3e6838cf62a8b
5
5
  SHA512:
6
- metadata.gz: 9d005834cf05f3d463df75af935e5e5ad075fe98cac3a05169df0e9f29c50366731f99b606af924ec33afce6fe332cd0e0ebccdd4768d52fa2dff59628e28b68
7
- data.tar.gz: 184aad1844eacdf8aa3693ab5ff45acb36f11a16d039f258137f807f355f822b5f31565450927f5b6db453300785df287dcb5dc7c68227d635ccc7dc498a83ba
6
+ metadata.gz: 4359ea66070503dc5dc98d1040a39598df3d760bad78393724baa0f689caf18c5e26d2875375ba3751f2fb95d4641e078ff4111084c7acf51b63118955e6a3cc
7
+ data.tar.gz: 13d4cc4f14a5f6e794592ce12eb5f6acd785fa5d51fbb7d9241e69c78ab7317a5f7b374dbcf06b2bf641cff890b1a7baa941ad3c76f306e207bbc069492875f0
data/README.md CHANGED
@@ -108,8 +108,33 @@ emerge upload snapshots \
108
108
  --project-root /my/awesomeapp/android/repo
109
109
  ```
110
110
 
111
- ## Building
111
+ ## Reaper
112
112
 
113
- This depends on [Tree Sitter](https://tree-sitter.github.io/tree-sitter/) for part of its functionality.
113
+ Experimental support has been added to interactively examine [Reaper](https://docs.emergetools.com/docs/reaper) results and also **delete them from your codebase**.
114
114
 
115
- In order to parse language grammars for Swift and Kotlin, both of which are third-party language grammars, we also depend on [tsdl](https://github.com/stackmystack/tsdl). This downloads and compiles the language grammars into dylibs for us to use.
115
+ Use the `reaper` subcommand to get started, e.g.:
116
+
117
+ ```shell
118
+ emerge reaper --upload-id 40f1dfe7-6c57-47c3-bc52-b621aec0ba8d \
119
+ --project-root /path/to/your/repo
120
+ ```
121
+
122
+ After which it will prompt you to select classes to delete.
123
+
124
+ ### How it works
125
+
126
+ Under the hood we are using [Tree Sitter](https://tree-sitter.github.io/tree-sitter/) to parse your source files into an AST which is then used for deletions. There are some obvious limitations to this approach, namely that Tree Sitter is designed for source code editors and only looks at a single file at a time. We are exploring some better long-term approaches but this works well enough for now!
127
+
128
+ ### Supported languages
129
+
130
+ We currently support the following languages:
131
+
132
+ - Swift
133
+ - Kotlin
134
+ - Java
135
+
136
+ Please open an issue if you need an additional language grammar.
137
+
138
+ ### Building
139
+
140
+ Because many of the language grammars we use are third-party, we have to package them with our CLI tool as shared libraries for distribution. We depend on [tsdl](https://github.com/stackmystack/tsdl) to build the grammars from our `parsers.toml` file.
@@ -9,6 +9,8 @@ module EmergeCLI
9
9
  def before(args)
10
10
  log_level = args[:debug] ? ::Logger::DEBUG : ::Logger::INFO
11
11
  EmergeCLI::Logger.configure(log_level)
12
+
13
+ EmergeCLI::Utils::VersionCheck.new.check_version
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,201 @@
1
+ require 'dry/cli'
2
+ require 'json'
3
+ require 'tty-prompt'
4
+
5
+ module EmergeCLI
6
+ module Commands
7
+ class Reaper < EmergeCLI::Commands::GlobalOptions
8
+ desc 'Analyze dead code from an Emerge upload'
9
+
10
+ option :upload_id, type: :string, required: true, desc: 'Upload ID to analyze'
11
+ option :project_root, type: :string, required: true,
12
+ desc: 'Root directory of the project, defaults to current directory'
13
+
14
+ option :api_token, type: :string, required: false,
15
+ desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
16
+
17
+ option :profile, type: :boolean, default: false, desc: 'Enable performance profiling metrics'
18
+
19
+ option :skip_delete_usages, type: :boolean, default: false,
20
+ desc: 'Skip deleting usages of the type (experimental feature)'
21
+
22
+ def initialize(network: nil)
23
+ @network = network
24
+ end
25
+
26
+ def call(**options)
27
+ @options = options
28
+ @profiler = EmergeCLI::Profiler.new(enabled: options[:profile])
29
+ @prompt = TTY::Prompt.new
30
+ before(options)
31
+ success = false
32
+
33
+ begin
34
+ api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
35
+ raise 'API token is required' unless api_token
36
+
37
+ @network ||= EmergeCLI::Network.new(api_token:)
38
+ project_root = @options[:project_root] || Dir.pwd
39
+
40
+ Sync do
41
+ all_data = @profiler.measure('fetch_dead_code') { fetch_all_dead_code(@options[:upload_id]) }
42
+ result = @profiler.measure('parse_dead_code') { DeadCodeResult.new(all_data) }
43
+
44
+ Logger.info result.to_s
45
+
46
+ selected_types = prompt_class_selection(result.filtered_unseen_classes, result.metadata['platform'])
47
+ Logger.info 'Selected classes:'
48
+ selected_types.each do |selected_class|
49
+ Logger.info " - #{selected_class['class_name']}"
50
+ end
51
+
52
+ confirmed = confirm_deletion(selected_types.length)
53
+ if !confirmed
54
+ Logger.info 'Operation cancelled'
55
+ return false
56
+ end
57
+
58
+ Logger.info 'Proceeding with deletion...'
59
+ platform = result.metadata['platform']
60
+ deleter = EmergeCLI::Reaper::CodeDeleter.new(
61
+ project_root: project_root,
62
+ platform: platform,
63
+ profiler: @profiler,
64
+ skip_delete_usages: options[:skip_delete_usages]
65
+ )
66
+ @profiler.measure('delete_types') { deleter.delete_types(selected_types) }
67
+ end
68
+
69
+ @profiler.report if @options[:profile]
70
+ success = true
71
+ rescue StandardError => e
72
+ Logger.error "Failed to analyze dead code: #{e.message}"
73
+ raise e
74
+ ensure
75
+ @network&.close
76
+ end
77
+
78
+ success
79
+ end
80
+
81
+ private
82
+
83
+ def fetch_all_dead_code(upload_id)
84
+ Logger.info 'Fetching dead code analysis (this may take a while for large codebases)...'
85
+
86
+ page = 1
87
+ combined_data = nil
88
+
89
+ loop do
90
+ response = fetch_dead_code_page(upload_id, page)
91
+ data = JSON.parse(response.read)
92
+
93
+ if combined_data.nil?
94
+ combined_data = data
95
+ else
96
+ combined_data['dead_code'].concat(data.fetch('dead_code', []))
97
+ end
98
+
99
+ current_page = data.dig('pagination', 'current_page')
100
+ total_pages = data.dig('pagination', 'total_pages')
101
+
102
+ break unless current_page && total_pages && current_page < total_pages
103
+
104
+ page += 1
105
+ Logger.info "Fetching page #{page} of #{total_pages}..."
106
+ end
107
+
108
+ combined_data
109
+ end
110
+
111
+ def fetch_dead_code_page(upload_id, page)
112
+ @network.post(
113
+ path: '/deadCode/export',
114
+ query: {
115
+ uploadId: upload_id,
116
+ page: page
117
+ },
118
+ headers: { 'Accept' => 'application/json' },
119
+ body: nil
120
+ )
121
+ end
122
+
123
+ def prompt_class_selection(unseen_classes, platform)
124
+ return nil if unseen_classes.empty?
125
+
126
+ choices = unseen_classes.map do |item|
127
+ display_name = if item['paths']&.first && platform == 'ios'
128
+ "#{item['class_name']} (#{item['paths'].first})"
129
+ else
130
+ item['class_name']
131
+ end
132
+ {
133
+ name: display_name,
134
+ value: item
135
+ }
136
+ end
137
+
138
+ @prompt.multi_select(
139
+ 'Select classes to delete:'.blue,
140
+ choices,
141
+ per_page: 15,
142
+ echo: false,
143
+ filter: true,
144
+ min: 1
145
+ )
146
+ end
147
+
148
+ def confirm_deletion(count)
149
+ @prompt.yes?("Are you sure you want to delete #{count} type#{count > 1 ? 's' : ''}?")
150
+ end
151
+
152
+ class DeadCodeResult
153
+ attr_reader :metadata, :dead_code, :counts, :pagination
154
+
155
+ def initialize(data)
156
+ @metadata = data['metadata']
157
+ @dead_code = data['dead_code']
158
+ @counts = data['counts']
159
+ @pagination = data['pagination']
160
+ end
161
+
162
+ def filtered_unseen_classes
163
+ @filtered_unseen_classes ||= dead_code
164
+ .reject { |item| item['seen'] }
165
+ .reject do |item|
166
+ paths = item['paths']
167
+ next false if paths.nil? || paths.empty?
168
+
169
+ next true if paths.any? do |path|
170
+ path.include?('/SourcePackages/checkouts/') ||
171
+ path.include?('/Pods/') ||
172
+ path.include?('/Carthage/') ||
173
+ path.include?('/Vendor/') ||
174
+ path.include?('/Sources/') ||
175
+ path.include?('/DerivedSources/')
176
+ end
177
+
178
+ next false if paths.none? do |path|
179
+ path.end_with?('.swift', '.java', '.kt')
180
+ end
181
+ end
182
+ end
183
+
184
+ def to_s
185
+ <<~SUMMARY.yellow
186
+
187
+ Dead Code Analysis Results:
188
+ App ID: #{@metadata['app_id']}
189
+ App Version: #{@metadata['version']}
190
+ Platform: #{@metadata['platform']}
191
+
192
+ Statistics:
193
+ - Total User Sessions: #{@counts['user_sessions']}
194
+ - Seen Classes: #{@counts['seen_classes']}
195
+ - Unseen Classes: #{@counts['unseen_classes']}
196
+ SUMMARY
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
data/lib/emerge_cli.rb CHANGED
@@ -9,8 +9,10 @@ require_relative 'commands/upload/snapshots/client_libraries/default'
9
9
  require_relative 'commands/integrate/fastlane'
10
10
  require_relative 'commands/config/snapshots/snapshots_ios'
11
11
  require_relative 'commands/config/orderfiles/orderfiles_ios'
12
+ require_relative 'commands/reaper/reaper'
12
13
 
13
14
  require_relative 'reaper/ast_parser'
15
+ require_relative 'reaper/code_deleter'
14
16
 
15
17
  require_relative 'utils/git_info_provider'
16
18
  require_relative 'utils/git_result'
@@ -20,10 +22,9 @@ require_relative 'utils/logger'
20
22
  require_relative 'utils/network'
21
23
  require_relative 'utils/profiler'
22
24
  require_relative 'utils/project_detector'
25
+ require_relative 'utils/version_check'
23
26
 
24
27
  require 'dry/cli'
25
- require 'pry'
26
- require 'pry-byebug'
27
28
 
28
29
  module EmergeCLI
29
30
  extend Dry::CLI::Registry
@@ -40,6 +41,8 @@ module EmergeCLI
40
41
  prefix.register 'snapshots-ios', Commands::Config::SnapshotsIOS
41
42
  prefix.register 'order-files-ios', Commands::Config::OrderFilesIOS
42
43
  end
44
+
45
+ register 'reaper', Commands::Reaper
43
46
  end
44
47
 
45
48
  # By default the log level is INFO, but can be overridden by the --debug flag
@@ -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/network.rb CHANGED
@@ -11,7 +11,7 @@ module EmergeCLI
11
11
  RETRY_DELAY = 5
12
12
  MAX_RETRIES = 3
13
13
 
14
- def initialize(api_token:, base_url: EMERGE_API_PROD_URL)
14
+ def initialize(api_token: nil, base_url: EMERGE_API_PROD_URL)
15
15
  @base_url = base_url
16
16
  @api_token = api_token
17
17
  @internet = Async::HTTP::Internet.new
@@ -21,8 +21,8 @@ module EmergeCLI
21
21
  request(:get, path, nil, headers)
22
22
  end
23
23
 
24
- def post(path:, body:, headers: {})
25
- request(:post, path, body, headers)
24
+ def post(path:, body:, headers: {}, query: nil)
25
+ request(:post, path, body, headers, query)
26
26
  end
27
27
 
28
28
  def put(path:, body:, headers: {})
@@ -35,18 +35,23 @@ module EmergeCLI
35
35
 
36
36
  private
37
37
 
38
- def request(method, path, body, custom_headers)
38
+ def request(method, path, body, custom_headers, query = nil)
39
39
  uri = if path.start_with?('http')
40
40
  URI.parse(path)
41
41
  else
42
- URI::HTTPS.build(host: @base_url, path:)
42
+ query_string = query ? URI.encode_www_form(query) : nil
43
+ URI::HTTPS.build(
44
+ host: @base_url,
45
+ path: path,
46
+ query: query_string
47
+ )
43
48
  end
44
49
  absolute_uri = uri.to_s
45
50
 
46
51
  headers = {
47
- 'X-API-Token' => @api_token,
48
52
  'User-Agent' => "emerge-cli/#{EmergeCLI::VERSION}"
49
53
  }
54
+ headers['X-API-Token'] = @api_token if @api_token
50
55
  headers['Content-Type'] = 'application/json' if method == :post && body.is_a?(Hash)
51
56
  headers.merge!(custom_headers)
52
57
 
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+
3
+ module EmergeCLI
4
+ module Utils
5
+ class VersionCheck
6
+ def initialize(network: EmergeCLI::Network.new)
7
+ @network = network
8
+ end
9
+
10
+ def check_version
11
+ Sync do
12
+ response = @network.get(
13
+ path: 'https://rubygems.org/api/v1/gems/emerge.json',
14
+ headers: {}
15
+ )
16
+ latest_version = JSON.parse(response.read).fetch('version')
17
+ current_version = EmergeCLI::VERSION
18
+
19
+ if Gem::Version.new(latest_version) > Gem::Version.new(current_version)
20
+ Logger.warn "A new version of emerge-cli is available (#{latest_version})"
21
+ Logger.warn "You are currently using version #{current_version}"
22
+ Logger.warn "To update, run: gem update emerge\n"
23
+ end
24
+ end
25
+ rescue KeyError
26
+ Logger.error 'Failed to parse version from RubyGems API response'
27
+ rescue StandardError => e
28
+ Logger.error "Failed to check for updates: #{e.message}"
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module EmergeCLI
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,29 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emerge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emerge Tools
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-02 00:00:00.000000000 Z
11
+ date: 2024-12-13 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: async
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: 2.21.1
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: 2.21.1
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: async-http
29
15
  requirement: !ruby/object:Gem::Requirement
@@ -80,20 +66,6 @@ dependencies:
80
66
  - - "~>"
81
67
  - !ruby/object:Gem::Version
82
68
  version: 0.2.1
83
- - !ruby/object:Gem::Dependency
84
- name: pry-byebug
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - "~>"
88
- - !ruby/object:Gem::Version
89
- version: '3.8'
90
- type: :runtime
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: '3.8'
97
69
  - !ruby/object:Gem::Dependency
98
70
  name: ruby_tree_sitter
99
71
  requirement: !ruby/object:Gem::Requirement
@@ -165,6 +137,7 @@ files:
165
137
  - lib/commands/config/snapshots/snapshots_ios.rb
166
138
  - lib/commands/global_options.rb
167
139
  - lib/commands/integrate/fastlane.rb
140
+ - lib/commands/reaper/reaper.rb
168
141
  - lib/commands/upload/snapshots/client_libraries/default.rb
169
142
  - lib/commands/upload/snapshots/client_libraries/paparazzi.rb
170
143
  - lib/commands/upload/snapshots/client_libraries/roborazzi.rb
@@ -172,6 +145,7 @@ files:
172
145
  - lib/commands/upload/snapshots/snapshots.rb
173
146
  - lib/emerge_cli.rb
174
147
  - lib/reaper/ast_parser.rb
148
+ - lib/reaper/code_deleter.rb
175
149
  - lib/utils/git.rb
176
150
  - lib/utils/git_info_provider.rb
177
151
  - lib/utils/git_result.rb
@@ -180,7 +154,14 @@ files:
180
154
  - lib/utils/network.rb
181
155
  - lib/utils/profiler.rb
182
156
  - lib/utils/project_detector.rb
157
+ - lib/utils/version_check.rb
183
158
  - lib/version.rb
159
+ - parsers/libtree-sitter-java-darwin-arm64.dylib
160
+ - parsers/libtree-sitter-java-linux-x86_64.so
161
+ - parsers/libtree-sitter-kotlin-darwin-arm64.dylib
162
+ - parsers/libtree-sitter-kotlin-linux-x86_64.so
163
+ - parsers/libtree-sitter-swift-darwin-arm64.dylib
164
+ - parsers/libtree-sitter-swift-linux-x86_64.so
184
165
  homepage: https://github.com/EmergeTools/emerge-cli
185
166
  licenses:
186
167
  - MIT
@@ -204,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
185
  - !ruby/object:Gem::Version
205
186
  version: '0'
206
187
  requirements: []
207
- rubygems_version: 3.4.10
188
+ rubygems_version: 3.5.11
208
189
  signing_key:
209
190
  specification_version: 4
210
191
  summary: Emerge CLI