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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -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
|