spoom 1.2.3 → 1.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +64 -55
  3. data/lib/spoom/backtrace_filter/minitest.rb +21 -0
  4. data/lib/spoom/cli/deadcode.rb +172 -0
  5. data/lib/spoom/cli/helper.rb +20 -0
  6. data/lib/spoom/cli/srb/bump.rb +200 -0
  7. data/lib/spoom/cli/srb/coverage.rb +224 -0
  8. data/lib/spoom/cli/srb/lsp.rb +159 -0
  9. data/lib/spoom/cli/srb/tc.rb +150 -0
  10. data/lib/spoom/cli/srb.rb +27 -0
  11. data/lib/spoom/cli.rb +72 -32
  12. data/lib/spoom/context/git.rb +2 -2
  13. data/lib/spoom/context/sorbet.rb +2 -2
  14. data/lib/spoom/deadcode/definition.rb +11 -0
  15. data/lib/spoom/deadcode/erb.rb +4 -4
  16. data/lib/spoom/deadcode/indexer.rb +266 -200
  17. data/lib/spoom/deadcode/location.rb +30 -2
  18. data/lib/spoom/deadcode/plugins/action_mailer.rb +21 -0
  19. data/lib/spoom/deadcode/plugins/action_mailer_preview.rb +19 -0
  20. data/lib/spoom/deadcode/plugins/actionpack.rb +59 -0
  21. data/lib/spoom/deadcode/plugins/active_job.rb +13 -0
  22. data/lib/spoom/deadcode/plugins/active_model.rb +46 -0
  23. data/lib/spoom/deadcode/plugins/active_record.rb +108 -0
  24. data/lib/spoom/deadcode/plugins/active_support.rb +32 -0
  25. data/lib/spoom/deadcode/plugins/base.rb +165 -12
  26. data/lib/spoom/deadcode/plugins/graphql.rb +47 -0
  27. data/lib/spoom/deadcode/plugins/minitest.rb +28 -0
  28. data/lib/spoom/deadcode/plugins/namespaces.rb +32 -0
  29. data/lib/spoom/deadcode/plugins/rails.rb +31 -0
  30. data/lib/spoom/deadcode/plugins/rake.rb +12 -0
  31. data/lib/spoom/deadcode/plugins/rspec.rb +19 -0
  32. data/lib/spoom/deadcode/plugins/rubocop.rb +41 -0
  33. data/lib/spoom/deadcode/plugins/ruby.rb +10 -18
  34. data/lib/spoom/deadcode/plugins/sorbet.rb +40 -0
  35. data/lib/spoom/deadcode/plugins/thor.rb +21 -0
  36. data/lib/spoom/deadcode/plugins.rb +91 -0
  37. data/lib/spoom/deadcode/remover.rb +651 -0
  38. data/lib/spoom/deadcode/send.rb +27 -6
  39. data/lib/spoom/deadcode/visitor.rb +755 -0
  40. data/lib/spoom/deadcode.rb +41 -10
  41. data/lib/spoom/file_tree.rb +0 -16
  42. data/lib/spoom/sorbet/errors.rb +1 -1
  43. data/lib/spoom/sorbet/lsp/structures.rb +2 -2
  44. data/lib/spoom/version.rb +1 -1
  45. metadata +36 -15
  46. data/lib/spoom/cli/bump.rb +0 -198
  47. data/lib/spoom/cli/coverage.rb +0 -222
  48. data/lib/spoom/cli/lsp.rb +0 -168
  49. data/lib/spoom/cli/run.rb +0 -148
@@ -0,0 +1,651 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ class Remover
7
+ extend T::Sig
8
+
9
+ class Error < Spoom::Error; end
10
+
11
+ sig { params(context: Context).void }
12
+ def initialize(context)
13
+ @context = context
14
+ end
15
+
16
+ sig { params(kind: T.nilable(Definition::Kind), location: Location).returns(String) }
17
+ def remove_location(kind, location)
18
+ file = location.file
19
+
20
+ unless @context.file?(file)
21
+ raise Error, "Can't find file at #{file}"
22
+ end
23
+
24
+ node_remover = NodeRemover.new(@context.read(file), kind, location)
25
+ node_remover.apply_edit
26
+ node_remover.new_source
27
+ end
28
+
29
+ class NodeRemover
30
+ extend T::Sig
31
+
32
+ sig { returns(String) }
33
+ attr_reader :new_source
34
+
35
+ sig { params(source: String, kind: T.nilable(Definition::Kind), location: Location).void }
36
+ def initialize(source, kind, location)
37
+ @old_source = source
38
+ @new_source = T.let(source.dup, String)
39
+ @kind = kind
40
+ @location = location
41
+
42
+ @node_context = T.let(NodeFinder.find(source, location, kind), NodeContext)
43
+ end
44
+
45
+ sig { void }
46
+ def apply_edit
47
+ sclass_context = @node_context.sclass_context
48
+ if sclass_context
49
+ delete_node_and_comments_and_sigs(sclass_context)
50
+ return
51
+ end
52
+
53
+ node = @node_context.node
54
+ case node
55
+ when Prism::ClassNode, Prism::ModuleNode, Prism::DefNode
56
+ delete_node_and_comments_and_sigs(@node_context)
57
+ when Prism::ConstantWriteNode, Prism::ConstantOperatorWriteNode,
58
+ Prism::ConstantAndWriteNode, Prism::ConstantOrWriteNode,
59
+ Prism::ConstantPathWriteNode, Prism::ConstantPathOperatorWriteNode,
60
+ Prism::ConstantPathAndWriteNode, Prism::ConstantPathOrWriteNode,
61
+ Prism::ConstantTargetNode
62
+ delete_constant_assignment(@node_context)
63
+ when Prism::SymbolNode # for attr accessors
64
+ delete_attr_accessor(@node_context)
65
+ else
66
+ raise Error, "Unsupported node type: #{node.class}"
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ sig { params(context: NodeContext).void }
73
+ def delete_constant_assignment(context)
74
+ case context.node
75
+ when Prism::ConstantWriteNode, Prism::ConstantOperatorWriteNode,
76
+ Prism::ConstantAndWriteNode, Prism::ConstantOrWriteNode,
77
+ Prism::ConstantPathWriteNode, Prism::ConstantPathOperatorWriteNode,
78
+ Prism::ConstantPathAndWriteNode, Prism::ConstantPathOrWriteNode
79
+ # Nesting node is an assign, it means only one constant is assigned on the line
80
+ # so we can remove the whole assign
81
+ delete_node_and_comments_and_sigs(context)
82
+ return
83
+ end
84
+
85
+ # We're assigning multiple constants, we need to remove only the useless node
86
+ parent_context = context.parent_context
87
+ parent_node = parent_context.node
88
+ if parent_node.is_a?(Prism::ConstantWriteNode)
89
+ # Nesting node is an assign, it means only one constant is assigned on the line
90
+ # so we can remove the whole assign
91
+ delete_node_and_comments_and_sigs(parent_context)
92
+ return
93
+ elsif parent_node.is_a?(Prism::MultiWriteNode) && parent_node.lefts.size == 1
94
+ # Nesting node is a single left hand side, it means only one constant is assigned
95
+ # so we can remove the whole line
96
+ delete_node_and_comments_and_sigs(parent_context.parent_context)
97
+ return
98
+ end
99
+
100
+ # Nesting node is a multiple left hand side, it means multiple constants are assigned
101
+ # so we need to remove only the right node from the left hand side
102
+ node = context.node
103
+ prev_node = context.previous_node
104
+ next_node = context.next_node
105
+
106
+ if (prev_node && prev_node.location.end_line != node.location.start_line) &&
107
+ (next_node && next_node.location.start_line != node.location.end_line)
108
+ # We have a node before and after, but on different lines, we need to remove the whole line
109
+ #
110
+ # ~~~
111
+ # FOO,
112
+ # BAR, # we need to remove BAR
113
+ # BAZ = 42
114
+ # ~~~
115
+ delete_lines(node.location.start_line, node.location.end_line)
116
+ elsif prev_node && next_node.is_a?(Prism::ConstantTargetNode)
117
+ # We have a node before and after one the same line, just remove the part of the line
118
+ #
119
+ # ~~~
120
+ # FOO, BAR, BAZ = 42 # we need to remove BAR
121
+ # ~~~
122
+ replace_chars(prev_node.location.end_offset, next_node.location.start_offset, ", ")
123
+ elsif prev_node
124
+ # We have a node before, on the same line, but no node after, just remove the part of the line
125
+ #
126
+ # ~~~
127
+ # FOO, BAR = 42 # we need to remove BAR
128
+ # ~~~
129
+ nesting_assign = T.cast(parent_context.node, Prism::MultiWriteNode)
130
+
131
+ rparen_loc = nesting_assign.rparen_loc
132
+ if rparen_loc
133
+ # We have an assign with parenthesis, we need to remove the part of the line until the closing parenthesis
134
+ delete_chars(prev_node.location.end_offset, rparen_loc.start_offset)
135
+ else
136
+ # We don't have a parenthesis, we need to remove the part of the line until the operator
137
+ replace_chars(prev_node.location.end_offset, nesting_assign.operator_loc.start_offset, " ")
138
+ end
139
+ elsif next_node.is_a?(Prism::ConstantTargetNode)
140
+ # We don't have a node before but a node after on the same line, just remove the part of the line
141
+ #
142
+ # ~~~
143
+ # FOO, BAR = 42 # we need to remove FOO
144
+ # ~~~
145
+ delete_chars(node.location.start_offset, next_node.location.start_offset)
146
+ else
147
+ # Should have been removed as a single MLHS node
148
+ raise "Unexpected case while removing constant assignment"
149
+ end
150
+ end
151
+
152
+ sig { params(context: NodeContext).void }
153
+ def delete_attr_accessor(context)
154
+ args_context = context.parent_context
155
+ send_context = args_context.parent_context
156
+ send_context = send_context.parent_context if send_context.node.is_a?(Prism::ArgumentsNode)
157
+
158
+ send_node = T.cast(send_context.node, Prism::CallNode)
159
+ need_accessor = send_node.name == :attr_accessor
160
+
161
+ if args_context.node.child_nodes.size == 1
162
+ # Only one accessor is defined, we can remove the whole node
163
+ delete_node_and_comments_and_sigs(send_context)
164
+ insert_accessor(context.node, send_context, was_removed: true) if need_accessor
165
+ return
166
+ end
167
+
168
+ prev_node = context.previous_node
169
+ next_node = context.next_node
170
+
171
+ if (prev_node && prev_node.location.end_line != context.node.location.start_line) &&
172
+ (next_node && next_node.location.start_line != context.node.location.end_line)
173
+ # We have a node before and after, but on different lines, we need to remove the whole line
174
+ #
175
+ # ~~~
176
+ # attr_reader(
177
+ # :foo,
178
+ # :bar, # attr to remove
179
+ # :baz,
180
+ # )
181
+ # ~~~
182
+ delete_lines(context.node.location.start_line, context.node.location.end_line)
183
+ elsif prev_node && next_node
184
+ # We have a node before and after one the same line, just remove the part of the line
185
+ #
186
+ # ~~~
187
+ # attr_reader :foo, :bar, :baz # we need to remove bar
188
+ # ~~~
189
+ replace_chars(prev_node.location.end_offset, next_node.location.start_offset, ", ")
190
+ elsif prev_node
191
+ # We have a node before, on the same line, but no node after, just remove the part of the line
192
+ #
193
+ # ~~~
194
+ # attr_reader :foo, :bar, :baz # we need to remove baz
195
+ # ~~~
196
+ delete_chars(prev_node.location.end_offset, context.node.location.end_offset)
197
+ elsif next_node
198
+ # We don't have a node before but a node after on the same line, just remove the part of the line
199
+ #
200
+ # ~~~
201
+ # attr_reader :foo, :bar, :baz # we need to remove foo
202
+ # ~~~
203
+ delete_chars(context.node.location.start_offset, next_node.location.start_offset)
204
+ else
205
+ raise "Unexpected case while removing attr_accessor"
206
+ end
207
+
208
+ insert_accessor(context.node, send_context, was_removed: false) if need_accessor
209
+ end
210
+
211
+ sig do
212
+ params(
213
+ node: Prism::Node,
214
+ send_context: NodeContext,
215
+ was_removed: T::Boolean,
216
+ ).void
217
+ end
218
+ def insert_accessor(node, send_context, was_removed:)
219
+ name = node.slice
220
+ code = case @kind
221
+ when Definition::Kind::AttrReader
222
+ "attr_writer #{name}"
223
+ when Definition::Kind::AttrWriter
224
+ "attr_reader #{name}"
225
+ end
226
+
227
+ indent = " " * send_context.node.location.start_column
228
+
229
+ sig = send_context.attached_sig
230
+ sig_string = transform_sig(sig, name: name, kind: @kind) if sig
231
+
232
+ node_after = send_context.next_node
233
+
234
+ if was_removed
235
+ first_node = send_context.attached_sigs.first || send_context.node
236
+ at_line = first_node.location.start_line - 1
237
+
238
+ prev_context = NodeContext.new(@old_source, @node_context.comments, first_node, send_context.nesting)
239
+ node_before = prev_context.previous_node
240
+
241
+ new_line_before = node_before && send_context.node.location.start_line - node_before.location.end_line < 2
242
+ new_line_after = node_after && node_after.location.start_line - send_context.node.location.end_line <= 2
243
+ else
244
+ at_line = send_context.node.location.end_line
245
+ new_line_before = true
246
+ new_line_after = node_after && node_after.location.start_line - send_context.node.location.end_line < 2
247
+ end
248
+
249
+ lines_to_insert = String.new
250
+ lines_to_insert << "\n" if new_line_before
251
+ lines_to_insert << "#{indent}#{sig_string}\n" if sig_string
252
+ lines_to_insert << "#{indent}#{code}\n"
253
+ lines_to_insert << "\n" if new_line_after
254
+
255
+ lines = @new_source.lines
256
+ lines.insert(at_line, lines_to_insert)
257
+ @new_source = lines.join
258
+ end
259
+
260
+ sig { params(context: NodeContext).void }
261
+ def delete_node_and_comments_and_sigs(context)
262
+ start_line = context.node.location.start_line
263
+ end_line = context.node.location.end_line
264
+
265
+ # TODO: remove once Prism location are fixed
266
+ node = context.node
267
+ case node
268
+ when Prism::ConstantWriteNode, Prism::ConstantOperatorWriteNode,
269
+ Prism::ConstantAndWriteNode, Prism::ConstantOrWriteNode,
270
+ Prism::ConstantPathWriteNode, Prism::ConstantPathOperatorWriteNode,
271
+ Prism::ConstantPathAndWriteNode, Prism::ConstantPathOrWriteNode
272
+ value = node.value
273
+ if value.is_a?(Prism::StringNode)
274
+ end_line = value.closing_loc&.start_line || value.location.end_line
275
+ end
276
+ end
277
+
278
+ # Adjust the lines to remove to include sigs attached to the node
279
+ first_node = context.attached_sigs.first || context.node
280
+ start_line = first_node.location.start_line if first_node
281
+
282
+ # Adjust the lines to remove to include comments attached to the node
283
+ first_comment = context.attached_comments(first_node).first
284
+ start_line = first_comment.location.start_line if first_comment
285
+
286
+ # Adjust the lines to remove to include previous blank lines
287
+ prev_context = NodeContext.new(@old_source, @node_context.comments, first_node, context.nesting)
288
+ before = T.let(prev_context.previous_node, T.nilable(T.any(Prism::Node, Prism::Comment)))
289
+
290
+ # There may be an unrelated comment between the current node and the one before
291
+ # if there is, we only want to delete lines up to the last comment found
292
+ if before
293
+ to_node = first_comment || node
294
+ comment = @node_context.comments_between_lines(before.location.end_line, to_node.location.start_line).last
295
+ before = comment if comment
296
+ end
297
+
298
+ if before && before.location.end_line < start_line - 1
299
+ # There is a node before and a blank line
300
+ start_line = before.location.end_line + 1
301
+ elsif before.nil?
302
+ # There is no node before, check if there is a blank line
303
+ parent_context = context.parent_context
304
+ # With Prism the StatementsNode location starts at the first line of the first node
305
+ parent_context = parent_context.parent_context if parent_context.node.is_a?(Prism::StatementsNode)
306
+ if parent_context.node.location.start_line < start_line - 1
307
+ # There is a blank line before the node
308
+ start_line = parent_context.node.location.start_line + 1
309
+ end
310
+ end
311
+
312
+ # Adjust the lines to remove to include following blank lines
313
+ after = context.next_node
314
+ if before.nil? && after && after.location.start_line > end_line + 1
315
+ end_line = after.location.end_line - 1
316
+ elsif after.nil? && context.parent_node.location.end_line > end_line + 1
317
+ end_line = context.parent_node.location.end_line - 1
318
+ end
319
+
320
+ delete_lines(start_line, end_line)
321
+ end
322
+
323
+ sig { params(start_line: Integer, end_line: Integer).void }
324
+ def delete_lines(start_line, end_line)
325
+ lines = @new_source.lines
326
+ lines[start_line - 1...end_line] = []
327
+ @new_source = lines.join
328
+ end
329
+
330
+ sig { params(start_char: Integer, end_char: Integer).void }
331
+ def delete_chars(start_char, end_char)
332
+ @new_source[start_char...end_char] = ""
333
+ end
334
+
335
+ sig { params(start_char: Integer, end_char: Integer, replacement: String).void }
336
+ def replace_chars(start_char, end_char, replacement)
337
+ @new_source[start_char...end_char] = replacement
338
+ end
339
+
340
+ sig { params(node: Prism::CallNode, name: String, kind: T.nilable(Definition::Kind)).returns(String) }
341
+ def transform_sig(node, name:, kind:)
342
+ type = T.let(nil, T.nilable(String))
343
+
344
+ block = T.cast(node.block, Prism::BlockNode)
345
+ statements = T.cast(block.body, Prism::StatementsNode)
346
+
347
+ statements.body.each do |call|
348
+ next unless call.is_a?(Prism::CallNode)
349
+ next unless call.name == :returns
350
+
351
+ args = call.arguments
352
+ next unless args
353
+
354
+ first = args.arguments.first
355
+ next unless first
356
+
357
+ type = first.slice
358
+ end
359
+
360
+ name = name.delete_prefix(":")
361
+ type = T.must(type)
362
+
363
+ case kind
364
+ when Definition::Kind::AttrReader
365
+ "sig { params(#{name}: #{type}).returns(#{type}) }"
366
+ else
367
+ "sig { returns(#{type}) }"
368
+ end
369
+ end
370
+ end
371
+
372
+ class NodeContext
373
+ extend T::Sig
374
+
375
+ sig { returns(T::Hash[Integer, Prism::Comment]) }
376
+ attr_reader :comments
377
+
378
+ sig { returns(Prism::Node) }
379
+ attr_reader :node
380
+
381
+ sig { returns(T::Array[Prism::Node]) }
382
+ attr_accessor :nesting
383
+
384
+ sig do
385
+ params(
386
+ source: String,
387
+ comments: T::Hash[Integer, Prism::Comment],
388
+ node: Prism::Node,
389
+ nesting: T::Array[Prism::Node],
390
+ ).void
391
+ end
392
+ def initialize(source, comments, node, nesting)
393
+ @source = source
394
+ @comments = comments
395
+ @node = node
396
+ @nesting = nesting
397
+ end
398
+
399
+ sig { returns(Prism::Node) }
400
+ def parent_node
401
+ parent = @nesting.last
402
+ raise "No parent for node #{node}" unless parent
403
+
404
+ parent
405
+ end
406
+
407
+ sig { returns(NodeContext) }
408
+ def parent_context
409
+ nesting = @nesting.dup
410
+ parent = nesting.pop
411
+ raise "No parent context for node #{@node}" unless parent
412
+
413
+ NodeContext.new(@source, @comments, parent, nesting)
414
+ end
415
+
416
+ sig { returns(T::Array[Prism::Node]) }
417
+ def previous_nodes
418
+ parent = parent_node
419
+ child_nodes = parent.child_nodes.compact
420
+
421
+ index = child_nodes.index(@node)
422
+ raise "Node #{@node} not found in parent #{parent}" unless index
423
+
424
+ T.must(child_nodes[0...index])
425
+ end
426
+
427
+ sig { returns(T.nilable(Prism::Node)) }
428
+ def previous_node
429
+ previous_nodes.last
430
+ end
431
+
432
+ sig { returns(T::Array[Prism::Node]) }
433
+ def next_nodes
434
+ parent = parent_node
435
+ child_nodes = parent.child_nodes.compact
436
+
437
+ index = child_nodes.index(node)
438
+ raise "Node #{@node} not found in nesting node #{parent}" unless index
439
+
440
+ T.must(child_nodes.compact[(index + 1)..-1])
441
+ end
442
+
443
+ sig { returns(T.nilable(Prism::Node)) }
444
+ def next_node
445
+ next_nodes.first
446
+ end
447
+
448
+ sig { returns(T.nilable(NodeContext)) }
449
+ def sclass_context
450
+ sclass = T.let(nil, T.nilable(Prism::SingletonClassNode))
451
+
452
+ nesting = @nesting.dup
453
+ until nesting.empty? || sclass
454
+ node = nesting.pop
455
+ next unless node.is_a?(Prism::SingletonClassNode)
456
+
457
+ sclass = node
458
+ end
459
+
460
+ return unless sclass.is_a?(Prism::SingletonClassNode)
461
+
462
+ body = sclass.body
463
+ return NodeContext.new(@source, @comments, sclass, nesting) unless body.is_a?(Prism::StatementsNode)
464
+
465
+ nodes = body.child_nodes.reject do |node|
466
+ sorbet_signature?(node) || sorbet_extend_sig?(node)
467
+ end
468
+
469
+ if nodes.size <= 1
470
+ return NodeContext.new(@source, @comments, sclass, nesting)
471
+ end
472
+
473
+ nil
474
+ end
475
+
476
+ sig { params(node: T.nilable(Prism::Node)).returns(T::Boolean) }
477
+ def sorbet_signature?(node)
478
+ node.is_a?(Prism::CallNode) && node.name == :sig
479
+ end
480
+
481
+ sig { params(node: T.nilable(Prism::Node)).returns(T::Boolean) }
482
+ def sorbet_extend_sig?(node)
483
+ return false unless node.is_a?(Prism::CallNode)
484
+ return false unless node.name == :extend
485
+
486
+ args = node.arguments
487
+ return false unless args
488
+ return false unless args.arguments.size == 1
489
+
490
+ args.arguments.first&.slice == "T::Sig"
491
+ end
492
+
493
+ sig { params(start_line: Integer, end_line: Integer).returns(T::Array[Prism::Comment]) }
494
+ def comments_between_lines(start_line, end_line)
495
+ comments = T.let([], T::Array[Prism::Comment])
496
+
497
+ (start_line + 1).upto(end_line - 1) do |line|
498
+ comment = @comments[line]
499
+ comments << comment if comment
500
+ end
501
+
502
+ comments
503
+ end
504
+
505
+ sig { params(node: Prism::Node).returns(T::Array[Prism::Comment]) }
506
+ def attached_comments(node)
507
+ comments = T.let([], T::Array[Prism::Comment])
508
+
509
+ start_line = node.location.start_line - 1
510
+ start_line.downto(1) do |line|
511
+ comment = @comments[line]
512
+ break unless comment
513
+
514
+ comments << comment
515
+ end
516
+
517
+ comments.reverse
518
+ end
519
+
520
+ sig { returns(T::Array[Prism::Node]) }
521
+ def attached_sigs
522
+ nodes = T.let([], T::Array[Prism::Node])
523
+
524
+ previous_nodes.reverse_each do |prev_node|
525
+ break unless sorbet_signature?(prev_node)
526
+
527
+ nodes << prev_node
528
+ end
529
+
530
+ nodes.reverse
531
+ end
532
+
533
+ sig { returns(T.nilable(Prism::CallNode)) }
534
+ def attached_sig
535
+ previous_nodes.reverse_each do |node|
536
+ if node.is_a?(Prism::Comment)
537
+ next
538
+ elsif sorbet_signature?(node)
539
+ return T.cast(node, Prism::CallNode)
540
+ else
541
+ break
542
+ end
543
+ end
544
+
545
+ nil
546
+ end
547
+ end
548
+
549
+ class NodeFinder < Visitor
550
+ extend T::Sig
551
+
552
+ class << self
553
+ extend T::Sig
554
+
555
+ sig { params(source: String, location: Location, kind: T.nilable(Definition::Kind)).returns(NodeContext) }
556
+ def find(source, location, kind)
557
+ result = Prism.parse(source)
558
+
559
+ unless result.success?
560
+ message = result.errors.map do |e|
561
+ "#{e.message} (at #{e.location.start_line}:#{e.location.start_column})."
562
+ end.join(" ")
563
+
564
+ raise ParserError, "Error while parsing #{location.file}: #{message}"
565
+ end
566
+
567
+ visitor = new(location)
568
+ visitor.visit(result.value)
569
+
570
+ node = visitor.node
571
+ unless node
572
+ raise Error, "Can't find node at #{location}"
573
+ end
574
+
575
+ if kind && !node_match_kind?(node, kind)
576
+ raise Error, "Can't find node at #{location}, expected #{kind} but got #{node.class}"
577
+ end
578
+
579
+ comments_by_line = T.let(
580
+ result.comments.to_h do |comment|
581
+ [comment.location.start_line, comment]
582
+ end,
583
+ T::Hash[Integer, Prism::Comment],
584
+ )
585
+
586
+ NodeContext.new(source, comments_by_line, node, visitor.nodes_nesting)
587
+ end
588
+
589
+ sig { params(node: Prism::Node, kind: Definition::Kind).returns(T::Boolean) }
590
+ def node_match_kind?(node, kind)
591
+ case kind
592
+ when Definition::Kind::AttrReader, Definition::Kind::AttrWriter
593
+ node.is_a?(Prism::SymbolNode)
594
+ when Definition::Kind::Class
595
+ node.is_a?(Prism::ClassNode)
596
+ when Definition::Kind::Constant
597
+ node.is_a?(Prism::ConstantWriteNode) ||
598
+ node.is_a?(Prism::ConstantAndWriteNode) ||
599
+ node.is_a?(Prism::ConstantOrWriteNode) ||
600
+ node.is_a?(Prism::ConstantOperatorWriteNode) ||
601
+ node.is_a?(Prism::ConstantPathWriteNode) ||
602
+ node.is_a?(Prism::ConstantPathAndWriteNode) ||
603
+ node.is_a?(Prism::ConstantPathOrWriteNode) ||
604
+ node.is_a?(Prism::ConstantPathOperatorWriteNode) ||
605
+ node.is_a?(Prism::ConstantTargetNode)
606
+ when Definition::Kind::Method
607
+ node.is_a?(Prism::DefNode)
608
+ when Definition::Kind::Module
609
+ node.is_a?(Prism::ModuleNode)
610
+ end
611
+ end
612
+ end
613
+
614
+ sig { returns(T.nilable(Prism::Node)) }
615
+ attr_reader :node
616
+
617
+ sig { returns(T::Array[Prism::Node]) }
618
+ attr_reader :nodes_nesting
619
+
620
+ sig { params(location: Location).void }
621
+ def initialize(location)
622
+ super()
623
+ @location = location
624
+ @node = T.let(nil, T.nilable(Prism::Node))
625
+ @nodes_nesting = T.let([], T::Array[Prism::Node])
626
+ end
627
+
628
+ sig { override.params(node: T.nilable(Prism::Node)).void }
629
+ def visit(node)
630
+ return unless node
631
+
632
+ location = Location.from_prism(@location.file, node.location)
633
+
634
+ if location == @location
635
+ # We found the node we're looking for at `@location`
636
+ @node = node
637
+
638
+ # There may be a more precise child inside the node that also matches `@location`, let's visit them
639
+ @nodes_nesting << node
640
+ super(node)
641
+ @nodes_nesting.pop if @nodes_nesting.last == @node
642
+ elsif location.include?(@location)
643
+ # The node we're looking for is inside `node`, let's visit it
644
+ @nodes_nesting << node
645
+ super(node)
646
+ end
647
+ end
648
+ end
649
+ end
650
+ end
651
+ end
@@ -3,16 +3,37 @@
3
3
 
4
4
  module Spoom
5
5
  module Deadcode
6
- # An abstraction to simplify handling of SyntaxTree::CallNode, SyntaxTree::Command, SyntaxTree::CommandCall and
7
- # SyntaxTree::VCall nodes.
6
+ # An abstraction to simplify handling of Prism::CallNode nodes.
8
7
  class Send < T::Struct
9
8
  extend T::Sig
10
9
 
11
- const :node, SyntaxTree::Node
10
+ const :node, Prism::CallNode
12
11
  const :name, String
13
- const :recv, T.nilable(SyntaxTree::Node), default: nil
14
- const :args, T::Array[SyntaxTree::Node], default: []
15
- const :block, T.nilable(SyntaxTree::Node), default: nil
12
+ const :recv, T.nilable(Prism::Node), default: nil
13
+ const :args, T::Array[Prism::Node], default: []
14
+ const :block, T.nilable(Prism::Node), default: nil
15
+
16
+ sig do
17
+ type_parameters(:T)
18
+ .params(arg_type: T::Class[T.type_parameter(:T)], block: T.proc.params(arg: T.type_parameter(:T)).void)
19
+ .void
20
+ end
21
+ def each_arg(arg_type, &block)
22
+ args.each do |arg|
23
+ yield(T.unsafe(arg)) if arg.is_a?(arg_type)
24
+ end
25
+ end
26
+
27
+ sig { params(block: T.proc.params(key: Prism::Node, value: T.nilable(Prism::Node)).void).void }
28
+ def each_arg_assoc(&block)
29
+ args.each do |arg|
30
+ next unless arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode)
31
+
32
+ arg.elements.each do |assoc|
33
+ yield(assoc.key, assoc.value) if assoc.is_a?(Prism::AssocNode)
34
+ end
35
+ end
36
+ end
16
37
  end
17
38
  end
18
39
  end