jitcatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module JitCatch
6
+ # Indexes methods, constants, and calls in one Ruby file.
7
+ # Dynamic definitions (`define_method`, `class_eval`) are not indexed.
8
+ class SourceIndex
9
+ VISIBILITY = %i[public private protected].freeze
10
+ CLASS_VISIBILITY = { private_class_method: :private, public_class_method: :public }.freeze
11
+ # A symbol passed to these names a Hash key, not a method, so
12
+ # `snap[:total]` does not mention a method `total`.
13
+ HASH_KEY_CALLS = %i[[] []= fetch dig key? has_key? values_at fetch_values].freeze
14
+ # These name a method by their first argument, as a symbol or a string.
15
+ REFLECTIVE_CALLS = %i[send __send__ public_send method public_method respond_to?].freeze
16
+ SEND_CALLS = %i[send __send__ public_send].freeze
17
+ # Exception#message and the messages built on it call to_s, the method an
18
+ # error class overrides to say what went wrong.
19
+ MESSAGE_CALLS = %i[message full_message detailed_message].freeze
20
+ # A relative path with a directory part and no spaces: `exe/tool`.
21
+ PATH_LIKE = %r{\A(?:\.\.?/)*[\w.-]+(?:/[\w.-]+)+\z}
22
+ ATTRIBUTE_MACROS = { attr_reader: [""], attr: [""], attr_writer: ["="], attr_accessor: ["", "="] }.freeze
23
+ # The constant a member list is given to, and whether its members get writers.
24
+ MEMBER_BUILDERS = { %i[Struct new] => true, %i[Data define] => false }.freeze
25
+ # Grape's route verbs, and the blocks that add a segment to the path of
26
+ # the endpoints inside them. `route_param :id` adds `:id`.
27
+ ENDPOINT_VERBS = %i[get post put patch delete head options].freeze
28
+ PATH_BLOCKS = %i[resource resources namespace group segment route_param].freeze
29
+ # `desc` and `params` describe the endpoint that follows them.
30
+ ENDPOINT_DESCRIPTIONS = %i[desc params].freeze
31
+ # A request path segment the test computes, as `#{thing.id}`.
32
+ COMPUTED_SEGMENT = "*"
33
+ ROUTE_HELPER = /\A\w+_(?:path|url)\z/
34
+ # A class is an API when its superclass is Grape's, or an app's own base
35
+ # class named like one (`BaseAPI`, `Api::Base` is not).
36
+ API_SUPERCLASS = /(?:\A|::)(?:Grape::API(?:::Instance)?|\w*(?:API|Api))\z/
37
+ # ActiveSupport::Concern blocks that define methods on the including
38
+ # class, and whether those are class methods.
39
+ CONCERN_BLOCKS = { included: false, prepended: false, class_methods: true }.freeze
40
+
41
+ # `macro` is nil for a `def`. A method an `attr_*` macro or a Struct or
42
+ # Data member list defines names that macro, and its source is the
43
+ # one-name form (`attr_reader :seed`), so dropping one name from a shared
44
+ # line does not read as a change to the names left on it.
45
+ Method = Data.define(
46
+ :file, :bare_name, :qualifier, :qualified, :kind, :visibility,
47
+ :start_line, :end_line, :source, :params, :macro
48
+ )
49
+ Constant = Data.define(
50
+ :file, :bare_name, :qualifier, :qualified, :start_line, :end_line, :source
51
+ )
52
+ Call = Data.define(:file, :bare_name, :line, :enclosing, :receiver)
53
+ # What a call is sent to, as far as the source names it: the enclosing
54
+ # object (`self`), a class or module itself (`singleton`), an instance
55
+ # of one (`instance`), one of the constants a method of the same class
56
+ # returns (`one_of`, with `const` the list), or a value only a run would
57
+ # know (`unknown`), or a value the source does not type that Rails would
58
+ # name after its model (`named`, with `const` the name in snake case).
59
+ # `const` is the constant as written, or nil for the enclosing class.
60
+ # An instance variable is an `instance` when every `Const.new` assigned
61
+ # to it in the class names the same constant, and `named` when none
62
+ # does. A local variable is an `instance` when every assignment to it in
63
+ # its method builds the same constant, and `named` when none builds
64
+ # one, as for a parameter. A reader that `belongs_to` or `has_one`
65
+ # declares is an `instance` of the model it names.
66
+ Receiver = Data.define(:kind, :const)
67
+ UNKNOWN_RECEIVER = Receiver.new(kind: "unknown", const: nil)
68
+ # A block given to an RSpec call: a `group` (describe, context), an
69
+ # `example` (it, specify), or a `setup` (let, subject, before). Code in
70
+ # an example or a setup block is referred to under `key`, and so are a
71
+ # group's arguments.
72
+ SpecBlock = Data.define(:kind, :key, :start_line, :end_line) do
73
+ def covers?(line)
74
+ (start_line..end_line).cover?(line)
75
+ end
76
+ end
77
+ SPEC_KINDS = {
78
+ "group" => %i[describe context feature example_group shared_examples shared_examples_for shared_context xdescribe xcontext fdescribe fcontext],
79
+ "example" => %i[it specify example scenario its focus fit fspecify fexample xit xspecify xexample skip pending],
80
+ "setup" => %i[let let! subject subject! before after around prepend_before append_before prepend_after append_after]
81
+ }.flat_map { |kind, names| names.map { |name| [name, kind] } }.to_h.freeze
82
+ # A local bound here can hold any value, so it types no call.
83
+ LOCAL_BINDINGS = [
84
+ Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
85
+ Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode, Prism::KeywordRestParameterNode,
86
+ Prism::BlockParameterNode, Prism::BlockLocalVariableNode, Prism::LocalVariableTargetNode,
87
+ Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
88
+ ].freeze
89
+ SPEC_HELPERS = %i[let let! subject subject!].freeze
90
+ # The Active Record macros that define a reader returning one record.
91
+ ASSOCIATION_READERS = %i[belongs_to has_one].freeze
92
+ DESCRIBED_METHOD = /\A[#.]([a-z_][\w]*[?!=]?)\z/
93
+ # FactoryBot's calls that build an object, so run its class's
94
+ # `initialize`. `attributes_for` builds a Hash.
95
+ FACTORY_CALLS = %i[build build_list build_pair build_stubbed build_stubbed_list build_stubbed_pair create create_list create_pair].freeze
96
+
97
+ # The calls on an Active Record model that run a method its class body
98
+ # names in a callback or a validation, by Rails's own rules: `.create`
99
+ # is sent to the class, `#save` to a record. `validates` takes its
100
+ # positional symbols as attributes, so only its `if:` and `unless:`
101
+ # name methods.
102
+ creating = %w[.create .create! #save #save!]
103
+ updating = %w[#save #save! #update #update!]
104
+ saving = (creating + updating).uniq
105
+ destroying = %w[#destroy #destroy!]
106
+ RECORD_CALLBACKS = {
107
+ saving + %w[#valid? #invalid? #validate #validate!] => %i[validate validates before_validation after_validation],
108
+ saving => %i[before_save around_save after_save after_save_commit],
109
+ creating => %i[before_create around_create after_create after_create_commit],
110
+ updating => %i[before_update around_update after_update after_update_commit],
111
+ destroying => %i[before_destroy around_destroy after_destroy after_destroy_commit],
112
+ saving + destroying => %i[after_commit after_rollback]
113
+ }.flat_map { |calls, macros| macros.map { |macro| [macro, calls.sort.freeze] } }.to_h.freeze
114
+ CALLBACK_CONDITIONS = %w[if unless].freeze
115
+ # Rails reads `on:` only on a validation and on a commit or rollback
116
+ # callback. A context of its own (`on: :publish`) runs only from a
117
+ # call that takes one: `valid?(:publish)` or `save(context: :publish)`.
118
+ CONTEXT_MACROS = %i[validate validates before_validation after_validation after_commit after_rollback].freeze
119
+ validating = %w[#valid? #invalid? #validate #validate!]
120
+ CONTEXT_CALLS = { "create" => creating + validating, "update" => updating + validating, "destroy" => destroying }.freeze
121
+ OWN_CONTEXT_CALLS = (%w[#save #save!] + validating).freeze
122
+ # FactoryBot's calls that save what they build, and so run its save
123
+ # callbacks. The other FACTORY_CALLS only build.
124
+ SAVING_FACTORY_CALLS = %w[create create_list create_pair].freeze
125
+
126
+ # A class header's superclass as written, and the namespace the header
127
+ # is in, which the name is looked up from.
128
+ Superclass = Data.define(:qualified, :written, :scope)
129
+ # One method a callback or validation line in a class body names.
130
+ # `declared` is the line in short (`after_save :count_save`,
131
+ # `validates if: :checked?`). `contexts` holds the `on:` symbols as
132
+ # written, or nil when the line names none.
133
+ Callback = Data.define(:qualifier, :macro, :method, :declared, :contexts) do
134
+ # The record calls, as `.create` or `#save`, that can run it.
135
+ def calls
136
+ all = RECORD_CALLBACKS.fetch(macro.to_sym)
137
+ return all if contexts.nil? || !CONTEXT_MACROS.include?(macro.to_sym)
138
+
139
+ all & contexts.flat_map { |context| CONTEXT_CALLS.fetch(context, OWN_CONTEXT_CALLS) }
140
+ end
141
+ end
142
+
143
+ # One `require_relative` call with one argument. `path` is nil when the
144
+ # source does not settle it: only a string literal, or File.join of
145
+ # string literals, does.
146
+ RelativeRequire = Data.define(:path, :line)
147
+
148
+ attr_reader :methods, :constants, :namespaces, :superclasses, :callbacks, :calls, :private_constants, :parse_error
149
+
150
+ # The classes whose `new` the file made private, so no test builds them
151
+ # with it. `new` is inherited, never defined, so no Method carries it.
152
+ def hidden_new
153
+ @new_visibility.filter_map { |qualifier, visibility| qualifier unless visibility == "public" }
154
+ end
155
+
156
+ def self.parse(source, file:)
157
+ new(source, file).parse
158
+ end
159
+
160
+ # Every require_relative of a source, wherever it is written. Only a
161
+ # run reads these, for its few candidate files, so the parse of every
162
+ # repository file does not pay for the walk.
163
+ def self.relative_requires(source)
164
+ found = []
165
+ pending = [Prism.parse(source.to_s).value]
166
+ while (node = pending.pop)
167
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :require_relative && node.arguments&.arguments&.size == 1
168
+ found << RelativeRequire.new(path: settled_path(node.arguments.arguments.first), line: node.location.start_line)
169
+ end
170
+ pending.concat(node.compact_child_nodes)
171
+ end
172
+ found.sort_by(&:line)
173
+ end
174
+
175
+ def self.settled_path(node)
176
+ return node.unescaped if node.is_a?(Prism::StringNode)
177
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :join && node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :File
178
+
179
+ parts = Array(node.arguments&.arguments)
180
+ parts.all?(Prism::StringNode) && !parts.empty? ? File.join(*parts.map(&:unescaped)) : nil
181
+ end
182
+
183
+ def initialize(source, file)
184
+ @source = source.to_s.gsub("\r\n", "\n")
185
+ @lines = @source.lines
186
+ @file = file.tr("\\", "/")
187
+ @methods = []
188
+ @constants = []
189
+ @namespaces = []
190
+ @superclasses = []
191
+ @callbacks = []
192
+ @module_functions = []
193
+ @private_constants = []
194
+ @new_visibility = {}
195
+ @calls = []
196
+ @references = Hash.new { |hash, key| hash[key] = [] }
197
+ @ivar_classes = Hash.new { |hash, key| hash[key] = [] }
198
+ @local_classes = Hash.new { |hash, key| hash[key] = [] }
199
+ @helper_classes = Hash.new { |hash, key| hash[key] = [] }
200
+ @parse_error = nil
201
+ @spec_blocks = []
202
+ @defined = Hash.new { |hash, key| hash[key] = [] }
203
+ @described = []
204
+ @returned = {}
205
+ @association_classes = {}
206
+ @symbol_lists = {}
207
+ @paths = Hash.new { |hash, key| hash[key] = [] }
208
+ @factory_calls = Hash.new { |hash, key| hash[key] = [] }
209
+ @endpoints = {}
210
+ end
211
+
212
+ def parse
213
+ result = Prism.parse(@source)
214
+ unless result.success?
215
+ @parse_error = result.errors.map { |err| "L#{err.location.start_line}: #{err.message}" }.sort.join("; ")
216
+ return self
217
+ end
218
+
219
+ @string_constants = string_constants(result.value)
220
+ scan_body(result.value, "", :public, false)
221
+ @spec_helpers = spec_helper_names(result.value)
222
+ walk(result.value, "", false, nil)
223
+ type_local_variables
224
+ type_instance_variables
225
+ type_spec_helpers
226
+ type_returned_receivers
227
+ rename_module_function_callers
228
+ @methods = self.class.hide_private_constants(@methods, @private_constants)
229
+ self
230
+ end
231
+
232
+ def self.qualify(qualifier, bare, singleton)
233
+ sep = singleton ? "." : "#"
234
+ qualifier.to_s.empty? ? "#{sep}#{bare}" : "#{qualifier}#{sep}#{bare}"
235
+ end
236
+
237
+ # How a test that builds an object from the factory `name` mentions it:
238
+ # `create(:loan)` when the call saves the record, `build(:loan)` when it
239
+ # does not, and `create(:loan, :cosigned)` with a trait.
240
+ def self.factory_mention(name, call, trait = nil)
241
+ verb = SAVING_FACTORY_CALLS.include?(call.to_s) ? "create" : "build"
242
+ trait ? "#{verb}(:#{name}, :#{trait})" : "#{verb}(:#{name})"
243
+ end
244
+
245
+ def self.unsaved_mention?(name)
246
+ name.start_with?("build(:")
247
+ end
248
+
249
+ # The bare names of the record calls (`#save!`) among `calls`.
250
+ def self.record_calls(calls)
251
+ calls.filter_map do |call|
252
+ owner, sep, bare = split_qualified(call)
253
+ bare if sep == "#" && !owner.empty?
254
+ end
255
+ end
256
+
257
+ # Whether a test that names `names` makes one of `record_calls` on any
258
+ # receiver: the record `build(:loan)` returns sits in a local the source
259
+ # does not type, so the receiver cannot be checked. RSpec's `be_valid`
260
+ # calls `valid?`.
261
+ def self.saves_or_validates?(names, record_calls)
262
+ names.any? do |name|
263
+ bare = split_qualified(name).last
264
+ record_calls.include?(bare) || record_calls.include?("#{bare.delete_prefix("be_")}?")
265
+ end
266
+ end
267
+
268
+ # Whether a call a test writes (`Loan.create!`, `Loan#save`) is the
269
+ # call `call` names. The written owner can be shorter or longer than
270
+ # the call's by whole namespace segments, as `Loan` is written inside
271
+ # `module Shop` for `Shop::Loan`.
272
+ def self.names_call?(written, call)
273
+ return true if written == call
274
+
275
+ owner, sep, bare = split_qualified(written)
276
+ call_owner, call_sep, call_bare = split_qualified(call)
277
+ return false if sep != call_sep || bare != call_bare
278
+
279
+ call_owner.end_with?("::#{owner}") || owner.end_with?("::#{call_owner}")
280
+ end
281
+
282
+ # The model Rails names a record after: `line_item` is a LineItem.
283
+ def self.model_name(snake)
284
+ snake.split("_").map(&:capitalize).join
285
+ end
286
+
287
+ # Whether a request a test sends (`POST /v1/things/7/refresh`) reaches
288
+ # an endpoint (`POST /things/:id/refresh`): the same verb, and the
289
+ # request's path ends in the endpoint's segments, a `:param` matching
290
+ # any one. Leading segments are a mount point the API class does not name.
291
+ # A `*` segment is a part the test computes, which matches any one; a
292
+ # path that starts with one computes its mount point, so it can be
293
+ # shorter than the endpoint's. An endpoint whose path the source
294
+ # computes matches no request.
295
+ def self.endpoint_call?(written, endpoint)
296
+ verb, path = written.split(" ", 2)
297
+ endpoint_verb, endpoint_path = endpoint.split(" ", 2)
298
+ return false unless verb == endpoint_verb && path && endpoint_path
299
+ return false if computed_path?(endpoint)
300
+
301
+ sent = path.split("/").reject(&:empty?)
302
+ open_mount = sent.first == COMPUTED_SEGMENT
303
+ sent = sent.drop(1) if open_mount
304
+ wanted = endpoint_path.split("/").reject(&:empty?)
305
+ return false if sent.size < wanted.size && !open_mount
306
+
307
+ size = [sent.size, wanted.size].min
308
+ sent.last(size).zip(wanted.last(size)).all? { |part, want| part == COMPUTED_SEGMENT || want.start_with?(":") || part == want }
309
+ end
310
+
311
+ def self.endpoint?(name)
312
+ name.match?(%r{\A[A-Z]+ [/*]})
313
+ end
314
+
315
+ # A request sent to a path helper, as `post things_path` is.
316
+ def self.route_helper_request?(name)
317
+ name.match?(/\A[A-Z]+ \w+_path\z/)
318
+ end
319
+
320
+ def self.request?(name)
321
+ endpoint?(name) || route_helper_request?(name)
322
+ end
323
+
324
+ # An endpoint whose path holds a part the source computes, written as
325
+ # that part's source in braces.
326
+ def self.computed_path?(name)
327
+ name.match?(/\A[A-Z]+ \S*\{/)
328
+ end
329
+
330
+ def self.split_qualified(qname)
331
+ if (idx = qname.rindex("#"))
332
+ [qname[0...idx], "#", qname[(idx + 1)..]]
333
+ elsif (idx = qname.rindex("."))
334
+ [qname[0...idx], ".", qname[(idx + 1)..]]
335
+ else
336
+ ["", "#", qname]
337
+ end
338
+ end
339
+
340
+ # The names a method refers to in code, with the same-file helpers it
341
+ # calls: method calls, constant reads, and symbols. A constructor call
342
+ # is also listed as `Owner.new`. Strings and comments are not read, except
343
+ # a plain string that a REFLECTIVE_CALLS call takes first. A symbol that
344
+ # keys a Hash is not listed. When the file defines no method by that
345
+ # name, the whole file is read.
346
+ def referenced_names(qualified)
347
+ start = @methods.find { |method| method.qualified == qualified }
348
+ return (@references.values.flatten + receiver_names(@calls.map(&:enclosing)) + factory_names(@factory_calls.keys)).uniq.sort unless start
349
+
350
+ names_reached_from([start.qualified])
351
+ end
352
+
353
+ # The method names that the test, the example on that line, or the
354
+ # whole file sends only to constants it names (`File.write`), each with
355
+ # those constants, in the same scope as the names it refers to.
356
+ def names_sent_only_to_constants(qualified, line)
357
+ keys = line ? example_keys(line) : []
358
+ keys = [qualified] if keys.empty? && @methods.any? { |method| method.qualified == qualified }
359
+ reached = keys.empty? ? nil : reached_from(keys)
360
+ calls = reached ? @calls.select { |call| reached.include?(call.enclosing) } : @calls
361
+ calls.group_by(&:bare_name).filter_map { |bare, list|
362
+ next unless list.all? { |call| call.receiver.kind == "singleton" && call.receiver.const }
363
+
364
+ [bare, list.map { |call| call.receiver.const }.uniq.sort]
365
+ }.to_h
366
+ end
367
+
368
+ # The names an RSpec example starting on that line refers to: its own
369
+ # block, the `let`, `subject`, and hook blocks of the groups around it,
370
+ # the arguments of those groups, and the same-file helpers all of them
371
+ # call. A group described as `"#total"` or `".build"` names that method.
372
+ # Nil when no example starts on the line.
373
+ def example_references(line)
374
+ keys = example_keys(line)
375
+ keys.empty? ? nil : names_reached_from(keys)
376
+ end
377
+
378
+ def example_keys(line)
379
+ example = @spec_blocks.find { |block| block.kind == "example" && block.start_line == line }
380
+ return [] unless example
381
+
382
+ groups = @spec_blocks.select { |block| block.kind == "group" && block.covers?(line) }
383
+ setups = @spec_blocks.select { |block|
384
+ next false unless block.kind == "setup"
385
+
386
+ parent = @spec_blocks.select { |group| group.kind == "group" && group.covers?(block.start_line) }.max_by(&:start_line)
387
+ parent.nil? || groups.include?(parent)
388
+ }
389
+ [example, *groups, *setups].map(&:key)
390
+ end
391
+
392
+ # The strings shaped like a file path (`"exe/tool"`, `"../bin/x"`) that
393
+ # the test or example, or the same-file helpers it calls, writes: the
394
+ # path of a script it may start in another process. With no such test
395
+ # or example, those of the whole file.
396
+ def paths_named(qualified, line)
397
+ keys = line ? example_keys(line) : [@methods.find { |method| method.qualified == qualified }&.qualified].compact
398
+ return @paths.values.flatten.uniq.sort if keys.empty?
399
+
400
+ reached_from(keys).flat_map { |name| @paths.fetch(name, []) }.uniq.sort
401
+ end
402
+
403
+ # Code outside the namespace cannot name a private constant, so no method
404
+ # under it is an entry point, whatever its own visibility says. A revision
405
+ # passes the names from all its files, since a namespace can be reopened.
406
+ def self.hide_private_constants(methods, names)
407
+ methods.map do |method|
408
+ hidden = names.any? { |name| method.qualifier == name || method.qualifier.start_with?("#{name}::") }
409
+ hidden ? method.with(visibility: "private") : method
410
+ end
411
+ end
412
+
413
+ private
414
+
415
+ def names_reached_from(keys)
416
+ reached = reached_from(keys)
417
+ names = reached.flat_map { |name| @references.fetch(name, []) }
418
+ (names + receiver_names(reached) + factory_names(reached)).uniq.sort
419
+ end
420
+
421
+ def reached_from(keys)
422
+ helpers = @methods.group_by(&:bare_name).transform_values { |list| list.map(&:qualified) }
423
+ @defined.each { |bare, names| helpers[bare] = (helpers.fetch(bare, []) + names).uniq }
424
+ calls = @calls.group_by(&:enclosing)
425
+ reached = keys.dup
426
+ reached.each do |name|
427
+ calls.fetch(name, []).each do |call|
428
+ helpers.fetch(call.bare_name, []).each do |helper|
429
+ reached << helper unless reached.include?(helper)
430
+ end
431
+ end
432
+ end
433
+ reached
434
+ end
435
+
436
+ # `Index.parse` for a call whose receiver the source names as the class
437
+ # `Index` or as an instance of it, so a reader can tell it from another
438
+ # class's `parse`. A constructor is already listed as `Owner.new`.
439
+ def receiver_names(enclosings)
440
+ @calls.filter_map do |call|
441
+ receiver = call.receiver
442
+ next unless enclosings.include?(call.enclosing) && receiver.const && call.bare_name != "new"
443
+
444
+ case receiver.kind
445
+ when "singleton" then "#{receiver.const}.#{call.bare_name}"
446
+ when "instance" then "#{receiver.const}##{call.bare_name}"
447
+ end
448
+ end
449
+ end
450
+
451
+ def scan_body(node, qualifier, visibility, singleton)
452
+ vis = visibility
453
+ statements(node).each do |stmt|
454
+ vis = scan_statement(stmt, qualifier, vis, singleton)
455
+ end
456
+ end
457
+
458
+ def statements(node)
459
+ container = if node.is_a?(Prism::ProgramNode)
460
+ node.statements
461
+ elsif node.respond_to?(:body)
462
+ node.body
463
+ else
464
+ node
465
+ end
466
+ case container
467
+ when nil then []
468
+ when Prism::StatementsNode then container.body
469
+ else [container]
470
+ end
471
+ end
472
+
473
+ def scan_statement(stmt, qualifier, visibility, singleton)
474
+ case stmt
475
+ when Prism::ClassNode, Prism::ModuleNode
476
+ name = const_name(stmt.constant_path)
477
+ nested = join_qual(qualifier, name)
478
+ record_constant(@namespaces, nested, stmt.location.start_line, stmt.location.end_line, @lines[stmt.location.start_line - 1]) if name
479
+ record_members(stmt.superclass, nested) if stmt.is_a?(Prism::ClassNode) && stmt.superclass.is_a?(Prism::CallNode)
480
+ parent = stmt.is_a?(Prism::ClassNode) && name && const_name(stmt.superclass)
481
+ @superclasses << Superclass.new(qualified: nested, written: parent, scope: qualifier.to_s) if parent
482
+ scan_body(stmt, nested, :public, false)
483
+ record_endpoints(stmt, nested, api_prefix(stmt)) if parent && API_SUPERCLASS.match?(parent)
484
+ visibility
485
+ when Prism::SingletonClassNode
486
+ scan_body(stmt, qualifier, :public, true)
487
+ visibility
488
+ when Prism::IfNode, Prism::UnlessNode, Prism::ElseNode
489
+ # A conditional in a class body defines what any branch defines,
490
+ # read as if every branch ran in order, so a bare `private` in a
491
+ # branch carries past the conditional as it does when that branch runs.
492
+ visibility = Array(stmt.statements&.body).reduce(visibility) { |vis, inner| scan_statement(inner, qualifier, vis, singleton) }
493
+ rest = %i[subsequent else_clause consequent].find { |name| stmt.respond_to?(name) }
494
+ rest ? scan_statement(stmt.public_send(rest), qualifier, visibility, singleton) : visibility
495
+ when Prism::DefNode
496
+ record_method(stmt, qualifier, visibility, singleton)
497
+ visibility
498
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
499
+ full = constant_target(stmt, qualifier)
500
+ source, end_line = constant_header(stmt)
501
+ record_constant(@constants, full, stmt.location.start_line, end_line, source)
502
+ list = symbol_list(stmt.value)
503
+ @symbol_lists[full] = list if list
504
+ record_members(stmt.value, full) if stmt.value.is_a?(Prism::CallNode)
505
+ block = value_block(stmt)
506
+ scan_body(block, full, :public, false) if block
507
+ visibility
508
+ when Prism::CallNode
509
+ return handle_visibility(stmt, qualifier, visibility, singleton) if visibility_modifier?(stmt)
510
+
511
+ return handle_module_function(stmt, qualifier, visibility) if stmt.receiver.nil? && stmt.name == :module_function
512
+
513
+ record_private_constants(stmt, qualifier) if stmt.name == :private_constant && (stmt.receiver.nil? || stmt.receiver.is_a?(Prism::SelfNode))
514
+
515
+ if stmt.receiver.nil? && CONCERN_BLOCKS.key?(stmt.name) && stmt.block.is_a?(Prism::BlockNode)
516
+ scan_body(stmt.block, qualifier, :public, CONCERN_BLOCKS.fetch(stmt.name))
517
+ return visibility
518
+ end
519
+
520
+ handle_class_visibility(stmt, qualifier) if stmt.receiver.nil? && CLASS_VISIBILITY.key?(stmt.name)
521
+ record_callbacks(stmt, qualifier) if (stmt.receiver.nil? || stmt.receiver.is_a?(Prism::SelfNode)) && !singleton && RECORD_CALLBACKS.key?(stmt.name)
522
+ record_association(stmt, qualifier) if stmt.receiver.nil? && !singleton && ASSOCIATION_READERS.include?(stmt.name)
523
+ if stmt.receiver.nil? && ATTRIBUTE_MACROS.key?(stmt.name)
524
+ record_attributes_as_methods(stmt, qualifier, visibility == :module_function ? :public : visibility, singleton)
525
+ end
526
+ record_scope(stmt, qualifier) if scope?(stmt) && !singleton
527
+ record_delegates(stmt, qualifier, singleton) if stmt.receiver.nil? && stmt.name == :delegate
528
+ record_enum(stmt, qualifier) if stmt.receiver.nil? && stmt.name == :enum && !singleton
529
+ visibility
530
+ else
531
+ visibility
532
+ end
533
+ end
534
+
535
+ def visibility_modifier?(node)
536
+ node.receiver.nil? && VISIBILITY.include?(node.name)
537
+ end
538
+
539
+ def handle_visibility(stmt, qualifier, visibility, singleton)
540
+ args = stmt.arguments&.arguments || []
541
+ return stmt.name if args.empty?
542
+
543
+ args.each do |arg|
544
+ case arg
545
+ when Prism::DefNode
546
+ record_method(arg, qualifier, stmt.name, singleton)
547
+ when Prism::SymbolNode
548
+ mark_visibility(qualifier, arg.unescaped.to_s, stmt.name, singleton)
549
+ when Prism::CallNode
550
+ record_attributes_as_methods(arg, qualifier, stmt.name, singleton) if arg.receiver.nil? && ATTRIBUTE_MACROS.key?(arg.name)
551
+ end
552
+ end
553
+ visibility
554
+ end
555
+
556
+ # `module_function` makes the public method `Mod.name` that callers use;
557
+ # the private instance copy it also makes is not listed.
558
+ def handle_module_function(stmt, qualifier, visibility)
559
+ args = stmt.arguments&.arguments || []
560
+ return :module_function if args.empty?
561
+
562
+ args.each do |arg|
563
+ case arg
564
+ when Prism::DefNode
565
+ record_method(arg, qualifier, :module_function, false)
566
+ when Prism::SymbolNode, Prism::StringNode
567
+ to_module_function(qualifier, arg.unescaped.to_s)
568
+ end
569
+ end
570
+ visibility
571
+ end
572
+
573
+ def to_module_function(qualifier, bare)
574
+ @methods.map! do |method|
575
+ next method unless method.qualifier == qualifier.to_s && method.bare_name == bare && method.kind == "instance"
576
+
577
+ @module_functions << method.qualified
578
+ method.with(kind: "singleton", visibility: "public", qualified: self.class.qualify(qualifier, bare, true))
579
+ end
580
+ end
581
+
582
+ def record_private_constants(stmt, qualifier)
583
+ (stmt.arguments&.arguments || []).each do |arg|
584
+ @private_constants << join_qual(qualifier, arg.unescaped.to_s) if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
585
+ end
586
+ end
587
+
588
+ def handle_class_visibility(stmt, qualifier)
589
+ visibility = CLASS_VISIBILITY.fetch(stmt.name)
590
+ (stmt.arguments&.arguments || []).each do |arg|
591
+ case arg
592
+ when Prism::DefNode
593
+ record_method(arg, qualifier, visibility, true, explicit: true)
594
+ when Prism::SymbolNode, Prism::StringNode
595
+ mark_visibility(qualifier, arg.unescaped.to_s, visibility, true)
596
+ end
597
+ end
598
+ end
599
+
600
+ def record_method(node, qualifier, visibility, singleton_context, explicit: false)
601
+ # `def self.foo` stays public; bare `private` does not apply to it.
602
+ # Visibility inside `class << self` still applies to receiver-less defs.
603
+ explicit_receiver = !node.receiver.nil?
604
+ module_function = visibility == :module_function && !explicit_receiver && !singleton_context
605
+ singleton = singleton_context || explicit_receiver || module_function
606
+ visibility = :public if (explicit_receiver && !explicit) || visibility == :module_function
607
+ @module_functions << self.class.qualify(qualifier, node.name.to_s, false) if module_function
608
+ bare = node.name.to_s
609
+ params = node.parameters&.location&.slice.to_s.gsub(/\s+/, "")
610
+ @methods << self.class::Method.new(
611
+ file: @file,
612
+ bare_name: bare,
613
+ qualifier: qualifier.to_s,
614
+ qualified: self.class.qualify(qualifier, bare, singleton),
615
+ kind: singleton ? "singleton" : "instance",
616
+ visibility: visibility.to_s,
617
+ start_line: node.location.start_line,
618
+ end_line: node.location.end_line,
619
+ source: extract_lines(node.location.start_line, node.location.end_line),
620
+ params: params,
621
+ macro: nil
622
+ )
623
+ end
624
+
625
+ # A string, a lambda, or a block runs code the line does not name, so
626
+ # only symbols count.
627
+ def record_callbacks(stmt, qualifier)
628
+ arguments = Array(stmt.arguments&.arguments)
629
+ options = arguments.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode)
630
+ .select { |pair| pair.key.is_a?(Prism::SymbolNode) && CALLBACK_CONDITIONS.include?(pair.key.unescaped) }
631
+ named = stmt.name == :validates ? [] : arguments.grep(Prism::SymbolNode).map { |arg| [arg.unescaped, ":#{arg.unescaped}"] }
632
+ named += options.flat_map do |pair|
633
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
634
+ values.grep(Prism::SymbolNode).map { |arg| [arg.unescaped, "#{pair.key.unescaped}: :#{arg.unescaped}"] }
635
+ end
636
+ context = arguments.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode)
637
+ .find { |pair| pair.key.is_a?(Prism::SymbolNode) && pair.key.unescaped == "on" }&.value
638
+ contexts = callback_contexts(context)
639
+ on = contexts && CONTEXT_MACROS.include?(stmt.name) ? ", on: #{slice_of(context)}" : ""
640
+ named.each do |method, written|
641
+ @callbacks << Callback.new(qualifier: qualifier.to_s, macro: stmt.name.to_s, method: method, declared: "#{stmt.name} #{written}#{on}", contexts: contexts)
642
+ end
643
+ end
644
+
645
+ # `belongs_to :cosigner, class_name: "Borrower"` reads a Borrower. A
646
+ # polymorphic one reads whatever class the row names.
647
+ def record_association(stmt, qualifier)
648
+ name, *rest = Array(stmt.arguments&.arguments)
649
+ return unless name.is_a?(Prism::SymbolNode)
650
+
651
+ options = rest.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode)
652
+ .select { |pair| pair.key.is_a?(Prism::SymbolNode) }.to_h { |pair| [pair.key.unescaped, pair.value] }
653
+ return if options.key?("polymorphic")
654
+
655
+ written = options["class_name"]
656
+ model = if written.nil? then self.class.model_name(name.unescaped)
657
+ elsif written.is_a?(Prism::StringNode) then written.unescaped.delete_prefix("::")
658
+ end
659
+ @association_classes[self.class.qualify(qualifier, name.unescaped, false)] = model if model
660
+ end
661
+
662
+ # An `on:` the source does not spell as symbols could be any context,
663
+ # so it narrows nothing.
664
+ def callback_contexts(value)
665
+ values = value.is_a?(Prism::ArrayNode) ? value.elements : [value]
666
+ return nil if values.empty? || !values.all?(Prism::SymbolNode)
667
+
668
+ values.map(&:unescaped)
669
+ end
670
+
671
+ def record_attributes_as_methods(stmt, qualifier, visibility, singleton)
672
+ suffixes = ATTRIBUTE_MACROS.fetch(stmt.name)
673
+ member_symbols(stmt, qualifier).each do |arg|
674
+ suffixes.each do |suffix|
675
+ macro = suffix.empty? ? "attr_reader" : "attr_writer"
676
+ record_generated(arg, qualifier, "#{arg.unescaped}#{suffix}", visibility, singleton, macro, "#{macro} :#{arg.unescaped}")
677
+ end
678
+ end
679
+ end
680
+
681
+ # `scope :recent, -> { ... }` and `scope(:old) { ... }` define the class
682
+ # method `Post.recent`, and its body is the whole statement.
683
+ def scope?(stmt)
684
+ stmt.receiver.nil? && stmt.name == :scope && stmt.arguments&.arguments&.first.is_a?(Prism::SymbolNode) && !scope_body(stmt).nil?
685
+ end
686
+
687
+ def scope_body(stmt)
688
+ body = stmt.arguments.arguments[1]
689
+ return body if body.is_a?(Prism::LambdaNode)
690
+
691
+ stmt.block if stmt.block.is_a?(Prism::BlockNode)
692
+ end
693
+
694
+ def record_scope(stmt, qualifier)
695
+ bare = stmt.arguments.arguments.first.unescaped
696
+ params = scope_body(stmt).parameters
697
+ params = params.parameters if params.is_a?(Prism::BlockParametersNode)
698
+ @methods << self.class::Method.new(
699
+ file: @file,
700
+ bare_name: bare,
701
+ qualifier: qualifier.to_s,
702
+ qualified: self.class.qualify(qualifier, bare, true),
703
+ kind: "singleton",
704
+ visibility: "public",
705
+ start_line: stmt.location.start_line,
706
+ end_line: stmt.location.end_line,
707
+ source: extract_lines(stmt.location.start_line, stmt.location.end_line),
708
+ params: params&.location&.slice.to_s.gsub(/\s+/, ""),
709
+ macro: "scope"
710
+ )
711
+ end
712
+
713
+ # `prefix :api` puts its segment before every path of the class.
714
+ def api_prefix(body)
715
+ call = statements(body).find { |stmt| stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :prefix }
716
+ call ? [path_segment(call.arguments&.arguments&.first)].compact : []
717
+ end
718
+
719
+ # `described` is the line of a `desc` or `params` before the block that
720
+ # holds these statements, which describes the first endpoint in it.
721
+ def record_endpoints(body, qualifier, path, described = nil)
722
+ statements(body).each do |stmt|
723
+ call = stmt if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
724
+ if call && ENDPOINT_DESCRIPTIONS.include?(call.name)
725
+ described ||= call.location.start_line
726
+ next
727
+ end
728
+ block = call&.block.is_a?(Prism::BlockNode)
729
+ segment = path_segment(call.arguments&.arguments&.first) if block
730
+ if block && PATH_BLOCKS.include?(call.name)
731
+ described = record_endpoints(call.block, qualifier, [*path, *(call.name == :route_param && segment ? ":#{segment}" : segment)], described)
732
+ next
733
+ elsif block && ENDPOINT_VERBS.include?(call.name)
734
+ record_endpoint(call, qualifier, [*path, *segment], described || call.location.start_line)
735
+ end
736
+ described = nil
737
+ end
738
+ described
739
+ end
740
+
741
+ def record_endpoint(stmt, qualifier, path, start_line)
742
+ bare = "#{stmt.name.upcase} /#{path.join("/")}"
743
+ qualified = self.class.qualify(qualifier, bare, false)
744
+ @endpoints[stmt.location.start_offset] = qualified
745
+ @methods << self.class::Method.new(
746
+ file: @file, bare_name: bare, qualifier: qualifier.to_s, qualified: qualified, kind: "endpoint", visibility: "public",
747
+ start_line: start_line, end_line: stmt.location.end_line, source: extract_lines(start_line, stmt.location.end_line), params: "", macro: nil
748
+ )
749
+ end
750
+
751
+ # A path argument's segments with no slash at either end, or nil for
752
+ # none. A part the source computes is kept as its source in braces, so
753
+ # the endpoint's name says that its path is not known.
754
+ def path_segment(node)
755
+ return nil if node.nil? || node.is_a?(Prism::KeywordHashNode)
756
+
757
+ text = path_parts(node).map { |part| part.is_a?(String) ? part : "{#{part.location.slice}}" }.join
758
+ trimmed = text.gsub(%r{\A/+|/+\z}, "")
759
+ trimmed.empty? ? nil : trimmed
760
+ end
761
+
762
+ # The parts of a path the source writes: the text of each literal part,
763
+ # and the node of each part it computes. A constant the file assigns a
764
+ # string or a symbol is literal.
765
+ def path_parts(node)
766
+ case node
767
+ when Prism::StringNode, Prism::SymbolNode
768
+ [node.unescaped]
769
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode
770
+ node.parts.flat_map do |part|
771
+ inner = part.statements&.body if part.is_a?(Prism::EmbeddedStatementsNode)
772
+ if part.is_a?(Prism::StringNode) then [part.unescaped]
773
+ elsif inner&.size == 1 then path_parts(inner.first)
774
+ else [part]
775
+ end
776
+ end
777
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
778
+ value = @string_constants[name_of(node).to_s]
779
+ value ? [value] : [node]
780
+ else
781
+ [node]
782
+ end
783
+ end
784
+
785
+ # The string and symbol constants the file assigns, by name. A name
786
+ # assigned two values is left out, since the scope that reads it is not
787
+ # followed.
788
+ def string_constants(root)
789
+ values = Hash.new { |hash, key| hash[key] = [] }
790
+ nodes = [root]
791
+ until nodes.empty?
792
+ node = nodes.pop
793
+ if node.is_a?(Prism::ConstantWriteNode) && (node.value.is_a?(Prism::StringNode) || node.value.is_a?(Prism::SymbolNode))
794
+ values[node.name.to_s] |= [node.value.unescaped]
795
+ end
796
+ nodes.concat(node.compact_child_nodes)
797
+ end
798
+ values.filter_map { |name, found| [name, found.first] if found.one? }.to_h
799
+ end
800
+
801
+ # A request a test sends, as `post "/things/#{thing.id}/refresh"`, by
802
+ # verb and path, with no query string and a `*` for each segment the
803
+ # test computes; or by verb and path helper, as `post things_path` is.
804
+ def request_name(node)
805
+ return nil unless node.receiver.nil? && ENDPOINT_VERBS.include?(node.name)
806
+
807
+ path = node.arguments&.arguments&.first
808
+ verb = node.name.upcase
809
+ if path.is_a?(Prism::CallNode) && path.receiver.nil? && path.name.to_s.match?(ROUTE_HELPER)
810
+ return "#{verb} #{path.name.to_s.sub(/_url\z/, "_path")}"
811
+ end
812
+ return nil unless path.is_a?(Prism::StringNode) || path.is_a?(Prism::InterpolatedStringNode)
813
+
814
+ text = path_parts(path).map { |part| part.is_a?(String) ? part : COMPUTED_SEGMENT }.join.sub(/[?#].*/m, "")
815
+ return nil unless text.start_with?("/", COMPUTED_SEGMENT)
816
+
817
+ segments = text.split("/").map { |segment| segment.include?(COMPUTED_SEGMENT) ? COMPUTED_SEGMENT : segment }
818
+ return nil if segments.reject(&:empty?).all?(COMPUTED_SEGMENT)
819
+
820
+ "#{verb} #{segments.join("/")}"
821
+ end
822
+
823
+ # `delegate :name, to: :author, prefix: true` defines `author_name`, and
824
+ # a change to the options changes every name the line delegates.
825
+ def record_delegates(stmt, qualifier, singleton)
826
+ names = Array(stmt.arguments&.arguments).grep(Prism::SymbolNode)
827
+ options = Array(stmt.arguments&.arguments).grep(Prism::KeywordHashNode).first
828
+ return if names.empty? || options.nil?
829
+
830
+ pairs = options.elements.grep(Prism::AssocNode).select { |pair| pair.key.is_a?(Prism::SymbolNode) }.to_h { |pair| [pair.key.unescaped, pair.value] }
831
+ return unless pairs.key?("to")
832
+
833
+ prefix = case (value = pairs["prefix"])
834
+ when Prism::TrueNode then pairs["to"].is_a?(Prism::SymbolNode) ? "#{pairs["to"].unescaped}_" : ""
835
+ when Prism::SymbolNode, Prism::StringNode then "#{value.unescaped}_"
836
+ else ""
837
+ end
838
+ visibility = pairs["private"].is_a?(Prism::TrueNode) ? :private : :public
839
+ names.each do |arg|
840
+ record_generated(arg, qualifier, "#{prefix}#{arg.unescaped}", visibility, singleton, "delegate", "delegate :#{arg.unescaped}, #{slice_of(options)}")
841
+ end
842
+ end
843
+
844
+ ENUM_OPTIONS = %w[prefix suffix scopes default instance_methods validate].freeze
845
+
846
+ # `enum :status, { draft: 0, live: 1 }` defines `draft?`, `draft!`, and
847
+ # the scopes `Post.draft` and `Post.not_draft` for each value. Each value
848
+ # keeps its own line, so changing one value changes that value's methods.
849
+ # Rails 8 removed the `enum status: {...}` form, so it is not read.
850
+ def record_enum(stmt, qualifier)
851
+ name, *rest = Array(stmt.arguments&.arguments)
852
+ return unless name.is_a?(Prism::SymbolNode)
853
+
854
+ pairs = rest.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode).select { |pair| pair.key.is_a?(Prism::SymbolNode) }
855
+ options = pairs.select { |pair| ENUM_OPTIONS.include?(pair.key.unescaped) }
856
+ listed = rest.find { |arg| arg.is_a?(Prism::HashNode) || arg.is_a?(Prism::ArrayNode) }
857
+ values = case listed
858
+ when Prism::HashNode then listed.elements.grep(Prism::AssocNode).select { |pair| pair.key.is_a?(Prism::SymbolNode) }.map { |pair| [pair.key.unescaped, pair] }
859
+ when Prism::ArrayNode then listed.elements.grep(Prism::SymbolNode).map { |value| [value.unescaped, value] }
860
+ else (pairs - options).map { |pair| [pair.key.unescaped, pair] }
861
+ end
862
+ option = options.to_h { |pair| [pair.key.unescaped, pair.value] }
863
+ affix = lambda do |key, joined|
864
+ case (value = option[key])
865
+ when Prism::TrueNode then joined.call(name.unescaped)
866
+ when Prism::SymbolNode, Prism::StringNode then joined.call(value.unescaped)
867
+ else ""
868
+ end
869
+ end
870
+ prefix = affix.call("prefix", ->(text) { "#{text}_" })
871
+ suffix = affix.call("suffix", ->(text) { "_#{text}" })
872
+ written = options.map { |pair| ", #{slice_of(pair)}" }.join
873
+ values.each do |value, node|
874
+ method = "#{prefix}#{value}#{suffix}"
875
+ source = "enum :#{name.unescaped}, #{node.is_a?(Prism::SymbolNode) ? ":#{value}" : slice_of(node)}#{written}"
876
+ unless option["instance_methods"].is_a?(Prism::FalseNode)
877
+ ["#{method}?", "#{method}!"].each { |bare| record_generated(node, qualifier, bare, :public, false, "enum", source) }
878
+ end
879
+ unless option["scopes"].is_a?(Prism::FalseNode)
880
+ [method, "not_#{method}"].each { |bare| record_generated(node, qualifier, bare, :public, true, "enum", source) }
881
+ end
882
+ end
883
+ end
884
+
885
+ def record_members(call, qualifier)
886
+ builder = const_name(call.receiver)
887
+ writers = MEMBER_BUILDERS[[builder&.to_sym, call.name]]
888
+ return if writers.nil?
889
+
890
+ macro = "#{builder}.#{call.name}"
891
+ member_symbols(call, qualifier).each do |arg|
892
+ source = "#{macro}(:#{arg.unescaped})"
893
+ record_generated(arg, qualifier, arg.unescaped, :public, false, macro, source)
894
+ record_generated(arg, qualifier, "#{arg.unescaped}=", :public, false, macro, source) if writers
895
+ end
896
+ end
897
+
898
+ # The symbols a member list names, with `*FIELDS` read from a constant
899
+ # this file set to a list of symbols before it, looked up from the
900
+ # innermost namespace out. Each member keeps the line of its own symbol,
901
+ # so a change to the list is a change to that member.
902
+ def member_symbols(call, qualifier)
903
+ Array(call.arguments&.arguments).flat_map do |arg|
904
+ case arg
905
+ when Prism::SymbolNode
906
+ [arg]
907
+ when Prism::SplatNode
908
+ name = const_name(arg.expression)
909
+ parts = qualifier.to_s.split("::")
910
+ found = name && parts.size.downto(0).map { |n| [*parts.first(n), name].join("::") }.find { |full| @symbol_lists.key?(full) }
911
+ found ? @symbol_lists.fetch(found) : []
912
+ else
913
+ []
914
+ end
915
+ end
916
+ end
917
+
918
+ # `[:a, :b]`, `%i[a b]`, and either with `.freeze`.
919
+ def symbol_list(node)
920
+ node = node.receiver if node.is_a?(Prism::CallNode) && node.name == :freeze && node.arguments.nil?
921
+ return nil unless node.is_a?(Prism::ArrayNode) && node.elements.all?(Prism::SymbolNode)
922
+
923
+ node.elements
924
+ end
925
+
926
+ def record_generated(arg, qualifier, bare, visibility, singleton, macro, source)
927
+ @methods << self.class::Method.new(
928
+ file: @file,
929
+ bare_name: bare,
930
+ qualifier: qualifier.to_s,
931
+ qualified: self.class.qualify(qualifier, bare, singleton),
932
+ kind: singleton ? "singleton" : "instance",
933
+ visibility: visibility.to_s,
934
+ start_line: arg.location.start_line,
935
+ end_line: arg.location.start_line,
936
+ source: source,
937
+ params: bare.end_with?("=") ? "value" : "",
938
+ macro: macro
939
+ )
940
+ end
941
+
942
+ def mark_visibility(qualifier, bare, visibility, singleton)
943
+ kind = singleton ? "singleton" : "instance"
944
+ @new_visibility[qualifier.to_s] = visibility.to_s if singleton && bare == "new"
945
+ @methods.map! do |method|
946
+ if method.qualifier == qualifier.to_s && method.bare_name == bare && method.kind == kind
947
+ method.with(visibility: visibility.to_s)
948
+ else
949
+ method
950
+ end
951
+ end
952
+ end
953
+
954
+ # A class or module header goes to the namespaces; it stands for the
955
+ # body, which belongs to the methods and constants inside it.
956
+ def record_constant(list, full, start_line, end_line, source)
957
+ bare = full.split("::").last
958
+ list << self.class::Constant.new(
959
+ file: @file,
960
+ bare_name: bare,
961
+ qualifier: full.delete_suffix(bare).delete_suffix("::"),
962
+ qualified: full,
963
+ start_line: start_line,
964
+ end_line: end_line,
965
+ source: source.strip
966
+ )
967
+ end
968
+
969
+ # The walk names an enclosing method by its `def`, which reads as an
970
+ # instance method; a module function is listed as `Mod.name`.
971
+ def type_instance_variables
972
+ @calls.map! do |call|
973
+ next call unless call.receiver.kind == "ivar"
974
+
975
+ qualifier, _sep, _bare = self.class.split_qualified(call.enclosing.to_s)
976
+ named = @ivar_classes[[qualifier, call.receiver.const]].uniq
977
+ call.with(receiver: typed_or_named(named, named.size == 1, call.receiver.const.delete_prefix("@")))
978
+ end
979
+ end
980
+
981
+ # A local read starts `named`; most are parameters and stay so. This
982
+ # runs before the instance variables become `named` too.
983
+ def type_local_variables
984
+ @calls.map! do |call|
985
+ next call unless call.receiver.kind == "named"
986
+
987
+ assigned = @local_classes.fetch([call.enclosing, call.receiver.const], [])
988
+ next call if assigned.all?(&:empty?)
989
+
990
+ built = assigned.flatten.uniq
991
+ call.with(receiver: typed_or_named(built, assigned.all? { |consts| consts.size == 1 }, call.receiver.const))
992
+ end
993
+ end
994
+
995
+ def typed_or_named(built, every_assignment_builds, name)
996
+ if built.empty?
997
+ Receiver.new(kind: "named", const: name)
998
+ elsif built.size == 1 && every_assignment_builds
999
+ Receiver.new(kind: "instance", const: built.first)
1000
+ else
1001
+ UNKNOWN_RECEIVER
1002
+ end
1003
+ end
1004
+
1005
+ # `let(:cart) { Cart.new }` makes `cart.total` a call on a Cart, as a
1006
+ # local variable would. A name defined twice, in two groups, types none.
1007
+ def type_spec_helpers
1008
+ @calls.map! do |call|
1009
+ next call unless call.receiver.kind == "helper"
1010
+
1011
+ built = @helper_classes[call.receiver.const].uniq
1012
+ typed = built.size == 1 && built.first
1013
+ call.with(receiver: typed ? Receiver.new(kind: "instance", const: typed) : UNKNOWN_RECEIVER)
1014
+ end
1015
+ end
1016
+
1017
+ # `emitter_for(format).render` sends `render` to whatever `emitter_for`
1018
+ # returns. When that method of the same class returns only constants (and
1019
+ # nil), the call goes to one of them.
1020
+ def type_returned_receivers
1021
+ @calls.map! do |call|
1022
+ next call unless call.receiver.kind == "returned"
1023
+
1024
+ qualifier, sep, _bare = self.class.split_qualified(call.enclosing.to_s)
1025
+ names = [sep == ".", sep != "."].map { |singleton| self.class.qualify(qualifier, call.receiver.const, singleton) }
1026
+ consts = @returned.values_at(*names).compact.first.to_a.uniq
1027
+ reader = names.first
1028
+ # A singleton reader names `Loan.borrower`, which no association line records.
1029
+ association = @association_classes[reader] unless @defined.fetch(call.receiver.const, []).include?(reader)
1030
+ next call.with(receiver: Receiver.new(kind: "instance", const: association)) if association
1031
+
1032
+ call.with(receiver: consts.empty? ? UNKNOWN_RECEIVER : Receiver.new(kind: "one_of", const: consts))
1033
+ end
1034
+ end
1035
+
1036
+ # The constants a method body can return, or nil when it can return
1037
+ # anything else. A `nil` branch adds none.
1038
+ def returned_constants(node)
1039
+ case node
1040
+ when nil, Prism::NilNode
1041
+ []
1042
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1043
+ [const_name(node)]
1044
+ when Prism::StatementsNode
1045
+ returned_constants(node.body.last)
1046
+ when Prism::ParenthesesNode
1047
+ returned_constants(node.body)
1048
+ when Prism::ElseNode, Prism::WhenNode
1049
+ returned_constants(node.statements)
1050
+ when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode
1051
+ branches = (node.compact_child_nodes - [node.predicate]).map { |branch| returned_constants(branch) }
1052
+ branches.all? ? branches.flatten : nil
1053
+ end
1054
+ end
1055
+
1056
+ def returns?(node)
1057
+ return false if node.nil? || node.is_a?(Prism::DefNode)
1058
+
1059
+ node.is_a?(Prism::ReturnNode) || node.compact_child_nodes.any? { |child| returns?(child) }
1060
+ end
1061
+
1062
+ # The constants a value builds with `Const.new`, through `a || b`,
1063
+ # `c ? a : b`, and parentheses.
1064
+ def built_constants(node)
1065
+ case node
1066
+ when Prism::CallNode
1067
+ owner = constructor_owner(node) || described_owner(node)
1068
+ owner ? [owner] : []
1069
+ when Prism::OrNode, Prism::AndNode
1070
+ built_constants(node.left) + built_constants(node.right)
1071
+ when Prism::IfNode, Prism::UnlessNode
1072
+ (node.compact_child_nodes - [node.predicate]).flat_map { |branch| built_constants(branch) }
1073
+ when Prism::ElseNode
1074
+ built_constants(node.statements)
1075
+ when Prism::ParenthesesNode
1076
+ built_constants(node.body)
1077
+ when Prism::StatementsNode
1078
+ built_constants(node.body.last)
1079
+ else
1080
+ []
1081
+ end
1082
+ end
1083
+
1084
+ def rename_module_function_callers
1085
+ renamed = @module_functions.to_h { |name| [name, name.sub(/#(?=[^#]*\z)/, ".")] }
1086
+ @calls.map! { |call| renamed.key?(call.enclosing) ? call.with(enclosing: renamed[call.enclosing]) : call }
1087
+ renamed.each { |from, to| @references[to] = @references.delete(from) if @references.key?(from) }
1088
+ end
1089
+
1090
+ def constant_target(node, qualifier)
1091
+ node.is_a?(Prism::ConstantWriteNode) ? join_qual(qualifier, node.name.to_s) : const_name(node.target).to_s
1092
+ end
1093
+
1094
+ # `Point = Struct.new(:x) do ... end` and `Class.new do ... end` define
1095
+ # methods for that constant. Those methods are indexed on their own, so
1096
+ # the constant keeps only the text up to the block's `do` or `{`, which
1097
+ # still shows a block that came or went.
1098
+ def value_block(node)
1099
+ value = node.value
1100
+ value.block if value.is_a?(Prism::CallNode) && value.block.is_a?(Prism::BlockNode)
1101
+ end
1102
+
1103
+ def constant_header(node)
1104
+ block = value_block(node)
1105
+ return [slice_of(node), node.location.end_line] unless block
1106
+
1107
+ length = block.opening_loc.end_offset - node.location.start_offset
1108
+ [@source.byteslice(node.location.start_offset, length).force_encoding(@source.encoding).rstrip, block.location.start_line]
1109
+ end
1110
+
1111
+ def walk(node, qualifier, singleton_context, enclosing)
1112
+ case node
1113
+ when Prism::ClassNode
1114
+ q = join_qual(qualifier, const_name(node.constant_path))
1115
+ node.compact_child_nodes.each { |child| walk(child, q, false, enclosing) }
1116
+ when Prism::ModuleNode
1117
+ q = join_qual(qualifier, const_name(node.constant_path))
1118
+ node.compact_child_nodes.each { |child| walk(child, q, false, enclosing) }
1119
+ when Prism::SingletonClassNode
1120
+ node.compact_child_nodes.each { |child| walk(child, qualifier, true, enclosing) }
1121
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
1122
+ owner = value_block(node) ? constant_target(node, qualifier) : qualifier
1123
+ node.compact_child_nodes.each { |child| walk(child, owner, singleton_context, enclosing) }
1124
+ when Prism::DefNode
1125
+ singleton = singleton_context || !node.receiver.nil?
1126
+ qname = self.class.qualify(qualifier, node.name.to_s, singleton)
1127
+ @defined[node.name.to_s] << qname unless @defined[node.name.to_s].include?(qname)
1128
+ @returned[qname] = returned_constants(node.body) unless returns?(node.body)
1129
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, qname) }
1130
+ when Prism::CallNode
1131
+ return walk_spec_block(node, qualifier, singleton_context, enclosing) if spec_block?(node)
1132
+
1133
+ endpoint = @endpoints[node.location.start_offset]
1134
+ return walk(node.block, qualifier, singleton_context, endpoint) if endpoint
1135
+
1136
+ if enclosing.nil? && !singleton_context && scope?(node)
1137
+ scope = self.class.qualify(qualifier, node.arguments.arguments.first.unescaped, true)
1138
+ return walk(scope_body(node), qualifier, true, scope)
1139
+ end
1140
+
1141
+ if node.name
1142
+ @calls << self.class::Call.new(
1143
+ file: @file,
1144
+ bare_name: node.name.to_s,
1145
+ line: node.location.start_line,
1146
+ enclosing: enclosing,
1147
+ receiver: receiver_of(node.receiver)
1148
+ )
1149
+ # `lines.sum(&:total_cents)` sends total_cents to each element,
1150
+ # whose class the source does not name.
1151
+ if node.block.is_a?(Prism::BlockArgumentNode) && node.block.expression.is_a?(Prism::SymbolNode)
1152
+ @calls << self.class::Call.new(file: @file, bare_name: node.block.expression.unescaped, line: node.location.start_line,
1153
+ enclosing: enclosing, receiver: UNKNOWN_RECEIVER)
1154
+ end
1155
+ sent = sent_name(node)
1156
+ if sent
1157
+ @calls << self.class::Call.new(file: @file, bare_name: sent, line: node.location.start_line,
1158
+ enclosing: enclosing, receiver: receiver_of(node.receiver))
1159
+ end
1160
+ refer(enclosing, node.name) unless spec_helper_call?(node)
1161
+ owner = constructor_owner(node) || described_owner(node)
1162
+ refer(enclosing, "#{owner}.new") if owner
1163
+ named = reflective_name(node)
1164
+ refer(enclosing, named) if named
1165
+ refer(enclosing, "to_s") if MESSAGE_CALLS.include?(node.name)
1166
+ request = request_name(node)
1167
+ refer(enclosing, request) if request
1168
+ factory = factory_built(node)
1169
+ @factory_calls[enclosing] << [node.name.to_s, factory, factory_traits(node)] if factory
1170
+ end
1171
+ node.compact_child_nodes.each do |child|
1172
+ children = child.is_a?(Prism::ArgumentsNode) && HASH_KEY_CALLS.include?(node.name) ? child.arguments.grep_v(Prism::SymbolNode) : [child]
1173
+ children.each { |arg| walk(arg, qualifier, singleton_context, enclosing) }
1174
+ end
1175
+ when Prism::AssocNode
1176
+ walk(node.key, qualifier, singleton_context, enclosing) unless node.key.is_a?(Prism::SymbolNode)
1177
+ walk(node.value, qualifier, singleton_context, enclosing) if node.value
1178
+ when Prism::CallOperatorWriteNode, Prism::CallAndWriteNode, Prism::CallOrWriteNode
1179
+ refer(enclosing, node.read_name)
1180
+ refer(enclosing, node.write_name)
1181
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1182
+ when Prism::CallTargetNode, Prism::ConstantReadNode, Prism::ConstantPathNode
1183
+ refer(enclosing, name_of(node))
1184
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1185
+ when Prism::SymbolNode
1186
+ refer(enclosing, node.unescaped)
1187
+ when Prism::StringNode
1188
+ text = node.unescaped
1189
+ @paths[enclosing] << text if text.valid_encoding? && text.match?(PATH_LIKE)
1190
+ when Prism::LocalVariableWriteNode
1191
+ @local_classes[[enclosing, node.name.to_s]] << built_constants(node.value).uniq
1192
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1193
+ when *LOCAL_BINDINGS
1194
+ @local_classes[[enclosing, node.name.to_s]] << [] if node.name
1195
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1196
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode
1197
+ @ivar_classes[[qualifier.to_s, node.name.to_s]].concat(built_constants(node.value))
1198
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1199
+ else
1200
+ node.compact_child_nodes.each { |child| walk(child, qualifier, singleton_context, enclosing) }
1201
+ end
1202
+ end
1203
+
1204
+ def spec_block?(node)
1205
+ return false unless node.block.is_a?(Prism::BlockNode) && SPEC_KINDS.key?(node.name)
1206
+
1207
+ node.receiver.nil? || (node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :RSpec)
1208
+ end
1209
+
1210
+ def walk_spec_block(node, qualifier, singleton_context, enclosing)
1211
+ kind = SPEC_KINDS.fetch(node.name)
1212
+ block = SpecBlock.new(kind: kind, key: "#{kind}:#{node.location.start_line}", start_line: node.location.start_line, end_line: node.location.end_line)
1213
+ @spec_blocks << block
1214
+ record_spec_helper(node) if SPEC_HELPERS.include?(node.name)
1215
+ arguments = Array(node.arguments&.arguments)
1216
+ arguments.each { |arg| walk(arg, qualifier, singleton_context, block.key) unless spec_helper_name?(node, arg) }
1217
+ described = kind == "group" ? arguments.first : nil
1218
+ if described.is_a?(Prism::StringNode) && (match = described.unescaped.match(DESCRIBED_METHOD))
1219
+ refer(block.key, match[1])
1220
+ end
1221
+ return walk(node.block, qualifier, singleton_context, block.key) unless kind == "group"
1222
+
1223
+ @described.push(const_name(described) || @described.last)
1224
+ walk(node.block, qualifier, singleton_context, enclosing)
1225
+ @described.pop
1226
+ end
1227
+
1228
+ # `let(:result)` and `subject(:result)` name a helper of the spec, so the
1229
+ # symbol and a bare `result` call refer to the spec's own helper, not to
1230
+ # a method `result` of the code under test.
1231
+ def spec_helper_names(node)
1232
+ own = node.is_a?(Prism::CallNode) && spec_helper_name?(node, node.arguments&.arguments&.first) ? [node.arguments.arguments.first.unescaped] : []
1233
+ own << "subject" if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.block && %i[subject subject!].include?(node.name)
1234
+ own + node.compact_child_nodes.flat_map { |child| spec_helper_names(child) }
1235
+ end
1236
+
1237
+ # An unnamed `subject` is called as `subject`, and a named one answers
1238
+ # to both names.
1239
+ def record_spec_helper(node)
1240
+ first = node.arguments&.arguments&.first
1241
+ names = first.is_a?(Prism::SymbolNode) ? [first.unescaped] : []
1242
+ names << "subject" if node.name.start_with?("subject")
1243
+ built = built_constants(node.block.body).uniq
1244
+ names.each { |name| @helper_classes[name] << (built.size == 1 ? built.first : nil) }
1245
+ end
1246
+
1247
+ def spec_helper_name?(node, arg)
1248
+ SPEC_HELPERS.include?(node.name) && node.receiver.nil? && arg.is_a?(Prism::SymbolNode) && node.arguments.arguments.first.equal?(arg)
1249
+ end
1250
+
1251
+ def spec_helper_call?(node)
1252
+ node.receiver.nil? && node.arguments.nil? && @spec_helpers.include?(node.name.to_s)
1253
+ end
1254
+
1255
+ # `described_class.new` builds the constant the enclosing group describes.
1256
+ def described_owner(node)
1257
+ return nil unless node.name == :new && node.receiver.is_a?(Prism::CallNode)
1258
+ return nil unless node.receiver.name == :described_class && node.receiver.receiver.nil?
1259
+
1260
+ @described.last
1261
+ end
1262
+
1263
+ # A file that defines a method of the call's name calls its own
1264
+ # helper, not FactoryBot's.
1265
+ def factory_names(enclosings)
1266
+ enclosings.flat_map { |name| @factory_calls.fetch(name, []) }
1267
+ .flat_map { |call, factory, traits|
1268
+ @defined.key?(call) ? [] : [nil, *traits].map { |trait| self.class.factory_mention(factory, call, trait) }
1269
+ }
1270
+ end
1271
+
1272
+ # `create(:loan, :cosigned)` names the trait :cosigned after the factory.
1273
+ def factory_traits(node)
1274
+ Array(node.arguments&.arguments).drop(1).grep(Prism::SymbolNode).map(&:unescaped)
1275
+ end
1276
+
1277
+ # `create(:loan)` or `FactoryBot.build("loan")` builds from the factory
1278
+ # `loan`. A name a run computes cannot be read.
1279
+ def factory_built(node)
1280
+ return nil unless FACTORY_CALLS.include?(node.name)
1281
+ return nil unless node.receiver.nil? || (node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :FactoryBot)
1282
+
1283
+ first = node.arguments&.arguments&.first
1284
+ first.unescaped if first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
1285
+ end
1286
+
1287
+ def refer(enclosing, name)
1288
+ @references[enclosing] << name.to_s
1289
+ end
1290
+
1291
+
1292
+ # `emitter.send(:write)` calls `write` on the receiver of `send`.
1293
+ def sent_name(node)
1294
+ return nil unless SEND_CALLS.include?(node.name)
1295
+
1296
+ first = node.arguments&.arguments&.first
1297
+ first.unescaped if first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
1298
+ end
1299
+
1300
+ def reflective_name(node)
1301
+ return nil unless REFLECTIVE_CALLS.include?(node.name)
1302
+
1303
+ first = node.arguments&.arguments&.first
1304
+ first.unescaped if first.is_a?(Prism::StringNode)
1305
+ end
1306
+
1307
+ def receiver_of(node)
1308
+ case node
1309
+ when nil, Prism::SelfNode
1310
+ Receiver.new(kind: "self", const: nil)
1311
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1312
+ Receiver.new(kind: "singleton", const: const_name(node))
1313
+ when Prism::InstanceVariableReadNode
1314
+ Receiver.new(kind: "ivar", const: node.name.to_s)
1315
+ when Prism::LocalVariableReadNode
1316
+ Receiver.new(kind: "named", const: node.name.to_s)
1317
+ when Prism::CallNode
1318
+ if node.name == :described_class && node.receiver.nil?
1319
+ return @described.last ? Receiver.new(kind: "singleton", const: @described.last) : UNKNOWN_RECEIVER
1320
+ end
1321
+
1322
+ inner = receiver_of(node.receiver)
1323
+ if node.name == :class && inner.kind == "self" && !node.receiver.nil?
1324
+ Receiver.new(kind: "singleton", const: nil)
1325
+ elsif node.name == :new && inner.kind == "singleton"
1326
+ Receiver.new(kind: "instance", const: inner.const)
1327
+ elsif node.name == :new && inner.kind == "self"
1328
+ Receiver.new(kind: "instance", const: nil)
1329
+ elsif node.receiver.nil? && @spec_helpers.include?(node.name.to_s)
1330
+ Receiver.new(kind: "helper", const: node.name.to_s)
1331
+ elsif inner.kind == "self"
1332
+ Receiver.new(kind: "returned", const: node.name.to_s)
1333
+ else
1334
+ UNKNOWN_RECEIVER
1335
+ end
1336
+ else
1337
+ UNKNOWN_RECEIVER
1338
+ end
1339
+ end
1340
+
1341
+ def constructor_owner(node)
1342
+ return nil unless node.name == :new
1343
+
1344
+ receiver = node.receiver
1345
+ const_name(receiver) if receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
1346
+ end
1347
+
1348
+ # Prism 0.19, the default on Ruby 3.3, has no ConstantPathNode#name: the
1349
+ # last segment is a ConstantReadNode in `child`, which Prism 1.x deprecates.
1350
+ # `full_name` and the source text after `::` are not the same on both.
1351
+ def name_of(node)
1352
+ node.respond_to?(:name) ? node.name : node.child.name
1353
+ end
1354
+
1355
+ def const_name(node)
1356
+ case node
1357
+ when Prism::ConstantReadNode, Prism::ConstantWriteNode
1358
+ node.name.to_s
1359
+ when Prism::ConstantPathNode
1360
+ parent = node.parent ? const_name(node.parent) : nil
1361
+ [parent, name_of(node).to_s].compact.reject(&:empty?).join("::")
1362
+ else
1363
+ nil
1364
+ end
1365
+ end
1366
+
1367
+ def join_qual(qualifier, name)
1368
+ return qualifier.to_s if name.nil? || name.empty?
1369
+ return name if qualifier.to_s.empty?
1370
+
1371
+ "#{qualifier}::#{name}"
1372
+ end
1373
+
1374
+ def extract_lines(start_line, end_line)
1375
+ @lines[(start_line - 1)...end_line].map(&:rstrip).join("\n")
1376
+ end
1377
+
1378
+ def slice_of(node)
1379
+ node.location.slice.to_s
1380
+ end
1381
+ end
1382
+ end