pink_spoon 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/bin/install-addon +34 -0
- data/bin/pink-spoon +8 -0
- data/lib/pink_spoon/constant_resolver.rb +469 -0
- data/lib/pink_spoon/definition_finder.rb +144 -0
- data/lib/pink_spoon/doc_extractor.rb +265 -0
- data/lib/pink_spoon/rbi_index.rb +334 -0
- data/lib/pink_spoon/server.rb +173 -0
- data/lib/pink_spoon/version.rb +5 -0
- data/lib/pink_spoon.rb +7 -0
- data/lib/ruby_lsp/pink_spoon/addon.rb +52 -0
- data/lib/ruby_lsp/pink_spoon/code_lens_listener.rb +57 -0
- data/lib/ruby_lsp/pink_spoon/completion_listener.rb +200 -0
- data/lib/ruby_lsp/pink_spoon/definition_listener.rb +425 -0
- data/lib/ruby_lsp/pink_spoon/hover_listener.rb +94 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ce69a26c0202add732139120d580e082afe4e783e33053e29652f0ab113778bd
|
|
4
|
+
data.tar.gz: 468b2feec436762cb468751b58cda34264088ece69e246a0422e067cad213b2f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2b716b7f37adb643ccaaca86c4058f32bc3903d7097f9a82ca3cdfddd2c397b1628792fb40141dc111d92d97f764c7a383018c2b0c24b37c8af1e06791203b49
|
|
7
|
+
data.tar.gz: '078a9df02c0ed4160180b8a9a9efd7ad4ca0161af3723edffd443a019f410cdb55c10b86a0320e19a32e001f5c355c1cac17cd43eaf8e4a9cd362c772f495267'
|
data/bin/install-addon
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: ruby bin/install-addon /path/to/target/project
|
|
5
|
+
#
|
|
6
|
+
# Drops a ruby_lsp/pink_spoon/addon.rb stub into the target project that
|
|
7
|
+
# loads pink-spoon from this gem's source tree.
|
|
8
|
+
# ruby_lsp/pink_spoon/ is covered by ~/.gitignore_global so the stub
|
|
9
|
+
# never shows up in git status.
|
|
10
|
+
|
|
11
|
+
require "fileutils"
|
|
12
|
+
|
|
13
|
+
target = ARGV[0]&.then { File.expand_path(_1) }
|
|
14
|
+
|
|
15
|
+
if target.nil? || !File.directory?(target)
|
|
16
|
+
abort "Usage: #{$0} /path/to/project"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
gem_root = File.expand_path("..", __dir__)
|
|
20
|
+
stub_dir = File.join(target, "ruby_lsp", "pink_spoon")
|
|
21
|
+
stub_file = File.join(stub_dir, "addon.rb")
|
|
22
|
+
|
|
23
|
+
FileUtils.mkdir_p(stub_dir)
|
|
24
|
+
File.write(stub_file, <<~RUBY)
|
|
25
|
+
# frozen_string_literal: true
|
|
26
|
+
# Auto-generated by pink-spoon/bin/install-addon — not committed.
|
|
27
|
+
# Re-run install-addon to update after changes to pink-spoon.
|
|
28
|
+
$LOAD_PATH.unshift "#{gem_root}/lib" unless $LOAD_PATH.include?("#{gem_root}/lib")
|
|
29
|
+
require "ruby_lsp/pink_spoon/addon"
|
|
30
|
+
RUBY
|
|
31
|
+
|
|
32
|
+
puts "wrote #{stub_file}"
|
|
33
|
+
puts "covered by ~/.gitignore_global — won't appear in git status"
|
|
34
|
+
puts "\nDone. Restart ruby-lsp in Zed (cmd+shift+p → Restart Language Server)."
|
data/bin/pink-spoon
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module PinkSpoon
|
|
6
|
+
# Given a file + cursor position, figures out:
|
|
7
|
+
# 1. What constant the receiver resolves to (e.g. EnabledFeatureGauge → Prometheus::Client::Gauge)
|
|
8
|
+
# 2. What method name is under the cursor
|
|
9
|
+
#
|
|
10
|
+
# Returns { type: "Prometheus::Client::Gauge", method: "init_label_set" } or nil.
|
|
11
|
+
#
|
|
12
|
+
# Resolution strategy (in order):
|
|
13
|
+
# a. Walk up the AST to find the call node under the cursor.
|
|
14
|
+
# b. If the receiver is a constant, look it up as an assignment in the same file.
|
|
15
|
+
# c. If the receiver is a local variable, resolve its type from assignments or block params.
|
|
16
|
+
# d. Follow the RHS chain through the RBI index (register_gauge → Gauge, .freeze → passthrough).
|
|
17
|
+
# e. For Foo.new(...), the type is Foo directly (Sorbet rarely types .new explicitly).
|
|
18
|
+
class ConstantResolver
|
|
19
|
+
PASSTHROUGH_METHODS = %w[freeze dup clone tap then yield_self].freeze
|
|
20
|
+
|
|
21
|
+
RSPEC_EXAMPLE_GROUP_METHODS = %w[
|
|
22
|
+
it example specify
|
|
23
|
+
context describe
|
|
24
|
+
let let!
|
|
25
|
+
subject subject!
|
|
26
|
+
before after around
|
|
27
|
+
pending skip
|
|
28
|
+
include_context include_examples include_shared_examples_for
|
|
29
|
+
shared_examples shared_context shared_examples_for
|
|
30
|
+
aggregate_failures
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
RSPEC_MODULE_METHODS = %w[describe shared_examples shared_context shared_examples_for].freeze
|
|
34
|
+
|
|
35
|
+
# Methods that yield each element of their receiver collection.
|
|
36
|
+
ENUMERATION_METHODS = %w[
|
|
37
|
+
each map select reject flat_map filter_map find detect
|
|
38
|
+
each_with_index each_with_object collect sum min_by max_by
|
|
39
|
+
sort_by group_by
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
def initialize(root_path, rbi_index)
|
|
43
|
+
@root_path = root_path
|
|
44
|
+
@rbi_index = rbi_index
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Entry point for the ruby-lsp addon.
|
|
48
|
+
# Returns { type:, method: } or nil.
|
|
49
|
+
def resolve_from_call(call_node, program_node, nesting = nil)
|
|
50
|
+
return nil unless call_node
|
|
51
|
+
|
|
52
|
+
method_name = call_node.name.to_s
|
|
53
|
+
receiver = call_node.receiver
|
|
54
|
+
|
|
55
|
+
unless receiver
|
|
56
|
+
type = infer_rspec_receiver(method_name)
|
|
57
|
+
return type ? { type: type, method: method_name } : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@_call_line = call_node.location.start_line
|
|
61
|
+
@_nesting = nesting
|
|
62
|
+
|
|
63
|
+
type = resolve_receiver(receiver, program_node, nil)
|
|
64
|
+
return nil unless type
|
|
65
|
+
|
|
66
|
+
{ type: type, method: method_name }
|
|
67
|
+
ensure
|
|
68
|
+
@_call_line = nil
|
|
69
|
+
@_nesting = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Public entry point for completion: returns the type string of a receiver node.
|
|
73
|
+
def resolve_receiver_type(receiver_node, program_node, nesting = nil)
|
|
74
|
+
@_nesting = nesting
|
|
75
|
+
resolve_receiver(receiver_node, program_node, nil)
|
|
76
|
+
ensure
|
|
77
|
+
@_nesting = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Standalone server entry point. Returns { type:, method: } or nil.
|
|
81
|
+
def resolve_at(file, line, col)
|
|
82
|
+
return nil unless file && File.exist?(file)
|
|
83
|
+
|
|
84
|
+
source = File.read(file)
|
|
85
|
+
result = Prism.parse(source)
|
|
86
|
+
|
|
87
|
+
call = innermost_call_at(result.value, line, col)
|
|
88
|
+
return nil unless call
|
|
89
|
+
|
|
90
|
+
method_name = call.name.to_s
|
|
91
|
+
receiver = call.receiver
|
|
92
|
+
|
|
93
|
+
unless receiver
|
|
94
|
+
type = infer_rspec_receiver(method_name)
|
|
95
|
+
return type ? { type: type, method: method_name } : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
type = resolve_receiver(receiver, result.value, source)
|
|
99
|
+
return nil unless type
|
|
100
|
+
|
|
101
|
+
{ type: type, method: method_name }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# AST walking helpers
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def innermost_call_at(root, lsp_line, lsp_col)
|
|
111
|
+
target_line = lsp_line + 1
|
|
112
|
+
|
|
113
|
+
best = nil
|
|
114
|
+
walk(root) do |node|
|
|
115
|
+
next unless node.is_a?(Prism::CallNode)
|
|
116
|
+
loc = node.location
|
|
117
|
+
next unless covers?(loc, target_line, lsp_col)
|
|
118
|
+
best = node if best.nil? || span(node) < span(best)
|
|
119
|
+
end
|
|
120
|
+
best
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def covers?(loc, line, col)
|
|
124
|
+
return false if line < loc.start_line || line > loc.end_line
|
|
125
|
+
return false if line == loc.start_line && col < loc.start_column
|
|
126
|
+
return false if line == loc.end_line && col > loc.end_column
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def span(node)
|
|
131
|
+
loc = node.location
|
|
132
|
+
(loc.end_line - loc.start_line) * 100_000 + (loc.end_column - loc.start_column)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def walk(node, &block)
|
|
136
|
+
return unless node.is_a?(Prism::Node)
|
|
137
|
+
block.call(node)
|
|
138
|
+
node.child_nodes.compact.each { |child| walk(child, &block) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Receiver resolution
|
|
143
|
+
# _seen guards against infinite recursion when local variables
|
|
144
|
+
# reference themselves (e.g. `entries = entries.first`).
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def resolve_receiver(receiver, ast, source, _seen = [])
|
|
148
|
+
case receiver
|
|
149
|
+
when Prism::ConstantReadNode
|
|
150
|
+
const_name = receiver.name.to_s
|
|
151
|
+
resolve_constant(const_name, ast, source, _seen) || const_name
|
|
152
|
+
when Prism::ConstantPathNode
|
|
153
|
+
receiver.slice.delete_prefix("::")
|
|
154
|
+
when Prism::LocalVariableReadNode
|
|
155
|
+
name = receiver.name.to_s
|
|
156
|
+
guard = "lv:#{name}"
|
|
157
|
+
return nil if _seen.include?(guard)
|
|
158
|
+
find_local_var_type(name, ast, source, _seen + [guard])
|
|
159
|
+
when Prism::InstanceVariableReadNode
|
|
160
|
+
ivar = receiver.name.to_s
|
|
161
|
+
guard = "iv:#{ivar}"
|
|
162
|
+
return nil if _seen.include?(guard)
|
|
163
|
+
find_ivar_type(ivar, ast, source, _seen + [guard])
|
|
164
|
+
when Prism::CallNode
|
|
165
|
+
# T.must(x) → resolve x
|
|
166
|
+
if receiver.name == :must && t_module?(receiver.receiver)
|
|
167
|
+
arg = receiver.arguments&.arguments&.first
|
|
168
|
+
return arg ? resolve_receiver(arg, ast, source, _seen) : nil
|
|
169
|
+
end
|
|
170
|
+
# T.cast(x, Type) / T.let(x, Type) → return Type
|
|
171
|
+
if (receiver.name == :cast || receiver.name == :let) && t_module?(receiver.receiver)
|
|
172
|
+
type_arg = receiver.arguments&.arguments&.[](1)
|
|
173
|
+
return type_arg ? type_from_node(type_arg) : nil
|
|
174
|
+
end
|
|
175
|
+
# Constructor: Foo.new(...) → instance type is Foo.
|
|
176
|
+
if receiver.name == :new && receiver.receiver
|
|
177
|
+
case receiver.receiver
|
|
178
|
+
when Prism::ConstantReadNode then return receiver.receiver.name.to_s
|
|
179
|
+
when Prism::ConstantPathNode then return receiver.receiver.slice.delete_prefix("::")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
inner_type = resolve_receiver(receiver.receiver, ast, source, _seen)
|
|
183
|
+
method_name = receiver.name.to_s
|
|
184
|
+
follow_chain(inner_type, method_name)
|
|
185
|
+
else
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resolve_constant(const_name, ast, source, _seen = [])
|
|
191
|
+
AssignmentFinder.new(const_name).find(ast)&.then do |rhs|
|
|
192
|
+
resolve_rhs(rhs, ast, source, _seen)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def resolve_rhs(rhs, ast, source, _seen = [])
|
|
197
|
+
case rhs
|
|
198
|
+
when Prism::ConstantReadNode
|
|
199
|
+
rhs.name.to_s
|
|
200
|
+
when Prism::ConstantPathNode
|
|
201
|
+
rhs.slice.delete_prefix("::")
|
|
202
|
+
when Prism::CallNode
|
|
203
|
+
# Constructor shortcut — same reasoning as resolve_receiver.
|
|
204
|
+
if rhs.name == :new && rhs.receiver
|
|
205
|
+
case rhs.receiver
|
|
206
|
+
when Prism::ConstantReadNode then return rhs.receiver.name.to_s
|
|
207
|
+
when Prism::ConstantPathNode then return rhs.receiver.slice.delete_prefix("::")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
inner_type = resolve_receiver(rhs.receiver, ast, source, _seen)
|
|
211
|
+
method_name = rhs.name.to_s
|
|
212
|
+
follow_chain(inner_type, method_name)
|
|
213
|
+
else
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def follow_chain(type, method_name)
|
|
219
|
+
return type if PASSTHROUGH_METHODS.include?(method_name)
|
|
220
|
+
return nil unless type
|
|
221
|
+
|
|
222
|
+
clean = unwrap_sorbet_wrapper(type)
|
|
223
|
+
ret = @rbi_index.return_type_for(clean, method_name)&.delete_prefix("::")
|
|
224
|
+
ret ? unwrap_sorbet_wrapper(ret) : nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def unwrap_sorbet_wrapper(type)
|
|
228
|
+
return nil unless type
|
|
229
|
+
if (m = type.match(/\AT\.nilable\((.+)\)\z/))
|
|
230
|
+
return m[1].strip.delete_prefix("::")
|
|
231
|
+
end
|
|
232
|
+
if (m = type.match(/\AT\.any\((.+)\)\z/))
|
|
233
|
+
non_nil = m[1].split(",").map(&:strip).reject { |t| t =~ /\ANilClass\z/i }
|
|
234
|
+
return non_nil.first&.delete_prefix("::") unless non_nil.empty?
|
|
235
|
+
end
|
|
236
|
+
type
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def t_module?(node)
|
|
240
|
+
node.is_a?(Prism::ConstantReadNode) && node.name == :T
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def type_from_node(node)
|
|
244
|
+
case node
|
|
245
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
246
|
+
when Prism::ConstantPathNode then node.slice.delete_prefix("::")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def infer_rspec_receiver(method_name)
|
|
251
|
+
return "RSpec::Core::ExampleGroup" if RSPEC_EXAMPLE_GROUP_METHODS.include?(method_name)
|
|
252
|
+
return "RSpec" if RSPEC_MODULE_METHODS.include?(method_name)
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Local variable type resolution
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
# Resolves the type of a local variable by:
|
|
261
|
+
# 1. Finding its most recent assignment and resolving the RHS.
|
|
262
|
+
# 2. Falling back to block parameter inference (entries.each { |e| }).
|
|
263
|
+
def find_local_var_type(var_name, ast, source, _seen = [])
|
|
264
|
+
# Direct assignments: var = <rhs>
|
|
265
|
+
LocalAssignmentFinder.new(var_name).find_all(ast).each do |rhs|
|
|
266
|
+
type = resolve_rhs(rhs, ast, source, _seen)
|
|
267
|
+
return type if type
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Strategy 2b: method parameter type from RBI sig
|
|
271
|
+
param_type = find_method_param_type(var_name)
|
|
272
|
+
return param_type if param_type
|
|
273
|
+
|
|
274
|
+
# Block parameter: method { |var| } — infer element type from receiver.
|
|
275
|
+
finder = BlockParamFinder.new(var_name)
|
|
276
|
+
finder.visit(ast)
|
|
277
|
+
return nil unless finder.enclosing_call&.receiver
|
|
278
|
+
|
|
279
|
+
call = finder.enclosing_call
|
|
280
|
+
receiver_type = resolve_receiver(call.receiver, ast, source, _seen)
|
|
281
|
+
return nil unless receiver_type
|
|
282
|
+
|
|
283
|
+
method_name = call.name.to_s
|
|
284
|
+
if ENUMERATION_METHODS.include?(method_name)
|
|
285
|
+
extract_element_type(receiver_type)
|
|
286
|
+
else
|
|
287
|
+
ret = @rbi_index.return_type_for(receiver_type, method_name)
|
|
288
|
+
ret ? extract_element_type(ret) : nil
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Extracts X from generic types like T::Array[X], T::Enumerator[X].
|
|
293
|
+
# Resolves the type of an instance variable by scanning its assignments.
|
|
294
|
+
def find_ivar_type(ivar_name, ast, source, _seen = [])
|
|
295
|
+
IvarTypeFinder.new(ivar_name).find_all(ast).each do |rhs|
|
|
296
|
+
type = resolve_rhs(rhs, ast, source, _seen)
|
|
297
|
+
return type if type
|
|
298
|
+
end
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Looks up the Sorbet-declared type of a method parameter from the RBI index.
|
|
303
|
+
# Requires @_call_line and @_nesting to be set (done in resolve_from_call).
|
|
304
|
+
def find_method_param_type(param_name)
|
|
305
|
+
return nil unless @_call_line && @_nesting
|
|
306
|
+
|
|
307
|
+
class_node = @_nesting.reverse.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
308
|
+
return nil unless class_node
|
|
309
|
+
|
|
310
|
+
current_type = class_node.constant_path.slice.delete_prefix("::")
|
|
311
|
+
method_name = EnclosingDefFinder.new(@_call_line).find(
|
|
312
|
+
@_nesting.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
313
|
+
)
|
|
314
|
+
return nil unless method_name
|
|
315
|
+
|
|
316
|
+
params = @rbi_index.params_for(current_type, method_name)
|
|
317
|
+
raw = params&.[](param_name)
|
|
318
|
+
raw ? unwrap_sorbet_wrapper(raw) : nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def extract_element_type(type_str)
|
|
322
|
+
return nil unless type_str
|
|
323
|
+
m = type_str.match(/\[(\w+(?:::\w+)*)\]$/)
|
|
324
|
+
m&.[](1)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
# Inner classes
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
# Collects all RHS nodes for `@ivar = rhs` and `@ivar ||= rhs` writes.
|
|
332
|
+
class IvarTypeFinder < Prism::Visitor
|
|
333
|
+
def initialize(ivar_name)
|
|
334
|
+
@ivar_name = ivar_name
|
|
335
|
+
@results = []
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def find_all(ast)
|
|
339
|
+
visit(ast)
|
|
340
|
+
@results
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def visit_instance_variable_write_node(node)
|
|
344
|
+
@results << node.value if node.name.to_s == @ivar_name
|
|
345
|
+
super
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def visit_instance_variable_or_write_node(node)
|
|
349
|
+
@results << node.value if node.name.to_s == @ivar_name
|
|
350
|
+
super
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Finds the name of the innermost def containing a target line.
|
|
355
|
+
class EnclosingDefFinder < Prism::Visitor
|
|
356
|
+
def initialize(target_line)
|
|
357
|
+
@target_line = target_line
|
|
358
|
+
@best_name = nil
|
|
359
|
+
@best_span = nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def find(ast)
|
|
363
|
+
visit(ast)
|
|
364
|
+
@best_name
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def visit_def_node(node)
|
|
368
|
+
loc = node.location
|
|
369
|
+
return super unless @target_line >= loc.start_line && @target_line <= loc.end_line
|
|
370
|
+
|
|
371
|
+
span = loc.end_line - loc.start_line
|
|
372
|
+
if @best_span.nil? || span < @best_span
|
|
373
|
+
@best_span = span
|
|
374
|
+
@best_name = node.name.to_s
|
|
375
|
+
end
|
|
376
|
+
super
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
class AssignmentFinder < Prism::Visitor
|
|
381
|
+
def initialize(const_name)
|
|
382
|
+
@const_name = const_name
|
|
383
|
+
@result = nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def find(ast)
|
|
387
|
+
visit(ast)
|
|
388
|
+
@result
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def visit_constant_write_node(node)
|
|
392
|
+
@result = node.value if node.name.to_s == @const_name
|
|
393
|
+
super
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def visit_constant_path_write_node(node)
|
|
397
|
+
@result = node.value if node.target.slice == @const_name
|
|
398
|
+
super
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Collects all RHS nodes for `var_name = <rhs>` local variable writes.
|
|
403
|
+
class LocalAssignmentFinder < Prism::Visitor
|
|
404
|
+
def initialize(var_name)
|
|
405
|
+
@var_name = var_name
|
|
406
|
+
@results = []
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def find_all(ast)
|
|
410
|
+
visit(ast)
|
|
411
|
+
@results
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def visit_local_variable_write_node(node)
|
|
415
|
+
@results << node.value if node.name.to_s == @var_name
|
|
416
|
+
super
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def visit_local_variable_operator_write_node(node)
|
|
420
|
+
@results << node.value if node.name.to_s == @var_name
|
|
421
|
+
super
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def visit_local_variable_or_write_node(node)
|
|
425
|
+
@results << node.value if node.name.to_s == @var_name
|
|
426
|
+
super
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Finds the CallNode whose block introduces `var_name` as a parameter.
|
|
431
|
+
# e.g. entries.each { |entry| } → enclosing_call = entries.each(...)
|
|
432
|
+
class BlockParamFinder < Prism::Visitor
|
|
433
|
+
attr_reader :enclosing_call
|
|
434
|
+
|
|
435
|
+
def initialize(var_name)
|
|
436
|
+
@var_name = var_name
|
|
437
|
+
@enclosing_call = nil
|
|
438
|
+
@call_stack = []
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def visit_call_node(node)
|
|
442
|
+
@call_stack.push(node)
|
|
443
|
+
super
|
|
444
|
+
@call_stack.pop
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def visit_block_node(node)
|
|
448
|
+
return super if @enclosing_call
|
|
449
|
+
|
|
450
|
+
# BlockParametersNode wraps a ParametersNode which holds .requireds
|
|
451
|
+
block_params = node.parameters
|
|
452
|
+
return super unless block_params
|
|
453
|
+
|
|
454
|
+
inner = block_params.is_a?(Prism::BlockParametersNode) ? block_params.parameters : block_params
|
|
455
|
+
return super unless inner
|
|
456
|
+
|
|
457
|
+
found = inner.requireds&.any? do |p|
|
|
458
|
+
p.is_a?(Prism::RequiredParameterNode) && p.name.to_s == @var_name
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
if found
|
|
462
|
+
@enclosing_call = @call_stack.last
|
|
463
|
+
else
|
|
464
|
+
super
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module PinkSpoon
|
|
6
|
+
# Maps a fully-qualified type + method name to a source file and line number
|
|
7
|
+
# inside the installed gem.
|
|
8
|
+
#
|
|
9
|
+
# Strategy:
|
|
10
|
+
# 1. Convert the type name to a likely gem name (heuristic + Bundler lookup).
|
|
11
|
+
# 2. Find the gem's source directory via Gem::Specification.
|
|
12
|
+
# 3. Convert the type to a file path (Prometheus::Client::Gauge → prometheus/client/gauge.rb).
|
|
13
|
+
# 4. Parse that file with Prism and find the def line.
|
|
14
|
+
# 5. If not found in the direct file, walk parent classes via Prism.
|
|
15
|
+
class DefinitionFinder
|
|
16
|
+
def initialize(root_path)
|
|
17
|
+
@root_path = root_path
|
|
18
|
+
setup_bundler
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns { file: absolute_path, line: integer } or nil.
|
|
22
|
+
def find(type, method_name)
|
|
23
|
+
return nil unless type && method_name
|
|
24
|
+
|
|
25
|
+
candidates = candidate_files(type)
|
|
26
|
+
candidates.each do |file|
|
|
27
|
+
next unless File.exist?(file)
|
|
28
|
+
line = method_line(file, method_name.to_s)
|
|
29
|
+
return { file: file, line: line } if line
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def setup_bundler
|
|
38
|
+
gemfile = File.join(@root_path, "Gemfile")
|
|
39
|
+
return unless File.exist?(gemfile)
|
|
40
|
+
|
|
41
|
+
# Load Bundler's gem paths without clobbering the current environment.
|
|
42
|
+
require "bundler"
|
|
43
|
+
Bundler.load.specs # warm the spec cache
|
|
44
|
+
rescue => e
|
|
45
|
+
$stderr.puts "[pink-spoon] Bundler setup skipped: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Build a list of candidate source files for the given type.
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
def candidate_files(type)
|
|
52
|
+
parts = type.delete_prefix("::").split("::")
|
|
53
|
+
|
|
54
|
+
files = []
|
|
55
|
+
|
|
56
|
+
parts.length.downto(1) do |depth|
|
|
57
|
+
tail = parts.last(depth)
|
|
58
|
+
|
|
59
|
+
# Generate two suffix variants: standard underscore and a form where the
|
|
60
|
+
# first component is simply lowercased (handles acronyms like RSpec → rspec
|
|
61
|
+
# vs the incorrect underscore form r_spec).
|
|
62
|
+
suffixes = [
|
|
63
|
+
tail.map { |p| underscore(p) }.join("/") + ".rb",
|
|
64
|
+
([tail.first.downcase] + tail.drop(1).map { |p| underscore(p) }).join("/") + ".rb",
|
|
65
|
+
].uniq
|
|
66
|
+
|
|
67
|
+
# Use both underscore and plain-downcase as gem hints for the same reason.
|
|
68
|
+
gem_hints = [underscore(parts.first), parts.first.downcase].uniq
|
|
69
|
+
|
|
70
|
+
gem_hints.each do |hint|
|
|
71
|
+
gem_dirs_for(hint).each do |gem_dir|
|
|
72
|
+
suffixes.each do |suffix|
|
|
73
|
+
files << File.join(gem_dir, "lib", suffix)
|
|
74
|
+
files << File.join(gem_dir, suffix)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Broader fallback: try all gem dirs.
|
|
80
|
+
all_gem_dirs.each do |gem_dir|
|
|
81
|
+
suffixes.each do |suffix|
|
|
82
|
+
files << File.join(gem_dir, "lib", suffix)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
files.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Gem dirs whose name starts with the hint (e.g. "prometheus" → prometheus-client-*).
|
|
91
|
+
def gem_dirs_for(hint)
|
|
92
|
+
Gem::Specification.each.filter_map do |spec|
|
|
93
|
+
spec.gem_dir if spec.name.start_with?(hint) || spec.name.include?(hint)
|
|
94
|
+
end
|
|
95
|
+
rescue
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def all_gem_dirs
|
|
100
|
+
@all_gem_dirs ||= Gem::Specification.map(&:gem_dir)
|
|
101
|
+
rescue
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Find the line number of `def method_name` inside a Ruby file.
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
def method_line(file, method_name)
|
|
109
|
+
result = Prism.parse_file(file)
|
|
110
|
+
finder = DefFinder.new(method_name)
|
|
111
|
+
finder.visit(result.value)
|
|
112
|
+
finder.line
|
|
113
|
+
rescue
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def underscore(str)
|
|
118
|
+
str
|
|
119
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
120
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
121
|
+
.downcase
|
|
122
|
+
.tr("-", "_")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Finds the line of `def <name>` in a parsed AST.
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
class DefFinder < Prism::Visitor
|
|
129
|
+
attr_reader :line
|
|
130
|
+
|
|
131
|
+
def initialize(method_name)
|
|
132
|
+
@method_name = method_name
|
|
133
|
+
@line = nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def visit_def_node(node)
|
|
137
|
+
if node.name.to_s == @method_name
|
|
138
|
+
@line ||= node.location.start_line
|
|
139
|
+
end
|
|
140
|
+
super
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|