pink_spoon 0.1.2 → 0.1.3
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 +4 -4
- data/lib/pink_spoon/constant_resolver.rb +89 -9
- data/lib/pink_spoon/doc_extractor.rb +35 -7
- data/lib/pink_spoon/rbi_index.rb +45 -0
- data/lib/pink_spoon/version.rb +1 -1
- data/lib/ruby_lsp/pink_spoon/definition_listener.rb +101 -29
- data/lib/ruby_lsp/pink_spoon/hover_listener.rb +86 -5
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a1dfe02635dfec7fd176f0727ddbf7feff1a23e17ef63fdcee94c283a4e008c
|
|
4
|
+
data.tar.gz: 355c66c3af0e1cb8169d2b3b2887f161558376578a9b009bca577943d536e460
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9682658394a75acd7987bbe67df72a15041a81fe47cb8d560df327410d05b62dc421808341c8f7337d84b084b331d197dab0588d86ffdc6d4b5184cf44fe869
|
|
7
|
+
data.tar.gz: a5738766fbfe3171f40148117075fa5379a5aa25893387ad43db668558f0e6b2e1786164d4b7fd6077f6352df4242da1abb447191b7672ee926bd51327afe8dd
|
|
@@ -28,6 +28,7 @@ module PinkSpoon
|
|
|
28
28
|
include_context include_examples include_shared_examples_for
|
|
29
29
|
shared_examples shared_context shared_examples_for
|
|
30
30
|
aggregate_failures
|
|
31
|
+
described_class
|
|
31
32
|
].freeze
|
|
32
33
|
|
|
33
34
|
RSPEC_MODULE_METHODS = %w[describe shared_examples shared_context shared_examples_for].freeze
|
|
@@ -69,6 +70,22 @@ module PinkSpoon
|
|
|
69
70
|
@_nesting = nil
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
# Returns the inferred type of a local variable (including block params), or nil.
|
|
74
|
+
# Used by hover to show type info alongside the "block parameter" label.
|
|
75
|
+
def infer_variable_type(var_name, program_node, nesting = nil)
|
|
76
|
+
@_nesting = nesting
|
|
77
|
+
find_local_var_type(var_name, program_node, nil)
|
|
78
|
+
rescue
|
|
79
|
+
nil
|
|
80
|
+
ensure
|
|
81
|
+
@_nesting = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the class name passed to the outermost describe(MyClass) block, or nil.
|
|
85
|
+
def described_class_in(program_node)
|
|
86
|
+
find_described_class(program_node)
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
# Public entry point for completion: returns the type string of a receiver node.
|
|
73
90
|
def resolve_receiver_type(receiver_node, program_node, nesting = nil)
|
|
74
91
|
@_nesting = nesting
|
|
@@ -162,6 +179,22 @@ module PinkSpoon
|
|
|
162
179
|
return nil if _seen.include?(guard)
|
|
163
180
|
find_ivar_type(ivar, ast, source, _seen + [guard])
|
|
164
181
|
when Prism::CallNode
|
|
182
|
+
# described_class → the class passed to the outermost describe(MyClass)
|
|
183
|
+
if receiver.name == :described_class && receiver.receiver.nil?
|
|
184
|
+
return find_described_class(ast)
|
|
185
|
+
end
|
|
186
|
+
# Bare call used as a receiver (e.g. have_been_made.once).
|
|
187
|
+
# Try ExampleGroup mixin chain, then a global RBI search so that
|
|
188
|
+
# WebMock/custom methods work even without an explicit mixin link.
|
|
189
|
+
if receiver.receiver.nil?
|
|
190
|
+
rspec_return = follow_chain("RSpec::Core::ExampleGroup", receiver.name.to_s)
|
|
191
|
+
return rspec_return if rspec_return
|
|
192
|
+
found_type = @rbi_index.find_type_for_method(receiver.name.to_s)
|
|
193
|
+
if found_type
|
|
194
|
+
ret = @rbi_index.return_type_for(found_type, receiver.name.to_s)
|
|
195
|
+
return ret if ret
|
|
196
|
+
end
|
|
197
|
+
end
|
|
165
198
|
# T.must(x) → resolve x
|
|
166
199
|
if receiver.name == :must && t_module?(receiver.receiver)
|
|
167
200
|
arg = receiver.arguments&.arguments&.first
|
|
@@ -253,6 +286,12 @@ module PinkSpoon
|
|
|
253
286
|
nil
|
|
254
287
|
end
|
|
255
288
|
|
|
289
|
+
def find_described_class(ast)
|
|
290
|
+
finder = DescribedClassFinder.new
|
|
291
|
+
finder.visit(ast)
|
|
292
|
+
finder.result
|
|
293
|
+
end
|
|
294
|
+
|
|
256
295
|
# ------------------------------------------------------------------
|
|
257
296
|
# Local variable type resolution
|
|
258
297
|
# ------------------------------------------------------------------
|
|
@@ -271,16 +310,35 @@ module PinkSpoon
|
|
|
271
310
|
param_type = find_method_param_type(var_name)
|
|
272
311
|
return param_type if param_type
|
|
273
312
|
|
|
274
|
-
# Block parameter: method { |var| } — infer
|
|
313
|
+
# Block parameter: method { |var| } — infer type from the enclosing call.
|
|
275
314
|
finder = BlockParamFinder.new(var_name)
|
|
276
315
|
finder.visit(ast)
|
|
277
|
-
return nil unless finder.enclosing_call
|
|
316
|
+
return nil unless finder.enclosing_call
|
|
317
|
+
|
|
318
|
+
call = finder.enclosing_call
|
|
319
|
+
method_name = call.name.to_s
|
|
278
320
|
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
receiver_type = if call.receiver
|
|
322
|
+
resolve_receiver(call.receiver, ast, source, _seen)
|
|
323
|
+
else
|
|
324
|
+
# Bare call (e.g. stub_request { |req| }): try ExampleGroup then global.
|
|
325
|
+
@rbi_index.return_type_for("RSpec::Core::ExampleGroup", method_name) ||
|
|
326
|
+
begin
|
|
327
|
+
ft = @rbi_index.find_type_for_method(method_name)
|
|
328
|
+
ft ? "#{ft}" : nil
|
|
329
|
+
end
|
|
330
|
+
end
|
|
281
331
|
return nil unless receiver_type
|
|
282
332
|
|
|
283
|
-
|
|
333
|
+
# Prefer the explicit yield type declared in the sig (yields(SomeType)).
|
|
334
|
+
yt = @rbi_index.yield_type_for(receiver_type, method_name)
|
|
335
|
+
# For bare calls that resolved via find_type_for_method, also try that type.
|
|
336
|
+
if yt.nil? && call.receiver.nil?
|
|
337
|
+
ft = @rbi_index.find_type_for_method(method_name)
|
|
338
|
+
yt = @rbi_index.yield_type_for(ft, method_name) if ft
|
|
339
|
+
end
|
|
340
|
+
return yt if yt
|
|
341
|
+
|
|
284
342
|
if ENUMERATION_METHODS.include?(method_name)
|
|
285
343
|
extract_element_type(receiver_type)
|
|
286
344
|
else
|
|
@@ -304,10 +362,7 @@ module PinkSpoon
|
|
|
304
362
|
def find_method_param_type(param_name)
|
|
305
363
|
return nil unless @_call_line && @_nesting
|
|
306
364
|
|
|
307
|
-
|
|
308
|
-
return nil unless class_node
|
|
309
|
-
|
|
310
|
-
current_type = class_node.constant_path.slice.delete_prefix("::")
|
|
365
|
+
current_type = qualified_type_from_nesting(@_nesting)
|
|
311
366
|
method_name = EnclosingDefFinder.new(@_call_line).find(
|
|
312
367
|
@_nesting.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
313
368
|
)
|
|
@@ -318,6 +373,14 @@ module PinkSpoon
|
|
|
318
373
|
raw ? unwrap_sorbet_wrapper(raw) : nil
|
|
319
374
|
end
|
|
320
375
|
|
|
376
|
+
def qualified_type_from_nesting(nesting)
|
|
377
|
+
return nil unless nesting
|
|
378
|
+
parts = nesting
|
|
379
|
+
.select { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
380
|
+
.map { |n| n.constant_path.slice.delete_prefix("::") }
|
|
381
|
+
parts.empty? ? nil : parts.join("::")
|
|
382
|
+
end
|
|
383
|
+
|
|
321
384
|
def extract_element_type(type_str)
|
|
322
385
|
return nil unless type_str
|
|
323
386
|
m = type_str.match(/\[(\w+(?:::\w+)*)\]$/)
|
|
@@ -427,6 +490,23 @@ module PinkSpoon
|
|
|
427
490
|
end
|
|
428
491
|
end
|
|
429
492
|
|
|
493
|
+
# Finds the class constant passed as the first argument to the outermost
|
|
494
|
+
# describe(MyClass) or RSpec.describe(MyClass) call in the file.
|
|
495
|
+
class DescribedClassFinder < Prism::Visitor
|
|
496
|
+
attr_reader :result
|
|
497
|
+
|
|
498
|
+
def visit_call_node(node)
|
|
499
|
+
if node.name == :describe && @result.nil?
|
|
500
|
+
first = node.arguments&.arguments&.first
|
|
501
|
+
@result = case first
|
|
502
|
+
when Prism::ConstantReadNode then first.name.to_s
|
|
503
|
+
when Prism::ConstantPathNode then first.slice.delete_prefix("::")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
super
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
430
510
|
# Finds the CallNode whose block introduces `var_name` as a parameter.
|
|
431
511
|
# e.g. entries.each { |entry| } → enclosing_call = entries.each(...)
|
|
432
512
|
class BlockParamFinder < Prism::Visitor
|
|
@@ -32,7 +32,7 @@ module PinkSpoon
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
%w[lib app].each do |subdir|
|
|
35
|
+
%w[lib app spec test].each do |subdir|
|
|
36
36
|
dir = File.join(@root_path, subdir)
|
|
37
37
|
next unless File.directory?(dir)
|
|
38
38
|
Dir.glob("#{dir}/**/*.rb").sort.each do |file|
|
|
@@ -126,19 +126,47 @@ module PinkSpoon
|
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
#
|
|
130
|
-
|
|
129
|
+
# For project files: try the conventional Zeitwerk path first so that a
|
|
130
|
+
# fully-qualified type like Acme::Billing::Worker resolves directly to
|
|
131
|
+
# lib/acme/billing/worker.rb rather than returning the first alphabetical
|
|
132
|
+
# match from a glob (which may be an unrelated class with the same name).
|
|
133
|
+
%w[lib app spec test].each do |subdir|
|
|
131
134
|
dir = File.join(@root_path, subdir)
|
|
132
135
|
next unless File.directory?(dir)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
|
|
137
|
+
if parts.size > 1
|
|
138
|
+
candidate = File.join(dir, *parts.map { |p| underscore(p) }) + ".rb"
|
|
139
|
+
if File.exist?(candidate)
|
|
140
|
+
line = first_line_matching(candidate, pattern)
|
|
141
|
+
return { file: candidate, line: line || 1 } if line
|
|
142
|
+
end
|
|
143
|
+
# Glob fallback: require the file path to contain the full
|
|
144
|
+
# intermediate namespace so that lib/nexus/au/.../download.rb is
|
|
145
|
+
# not returned when looking for Nexus::Gb::Bacs::Form3::Retrievals::Download.
|
|
146
|
+
# Also accepts a single-file namespace (lib/my_gem.rb for MyGem::Foo).
|
|
147
|
+
intermediate = parts[0..-2].map { |p| underscore(p) }.join("/")
|
|
148
|
+
Dir.glob("#{dir}/**/*.rb").sort.each do |file|
|
|
149
|
+
next unless file.include?("/#{intermediate}/") || file.end_with?("/#{intermediate}.rb")
|
|
150
|
+
line = first_line_matching(file, pattern)
|
|
151
|
+
return { file: file, line: line } if line
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
Dir.glob("#{dir}/**/*.rb").sort.each do |file|
|
|
155
|
+
line = first_line_matching(file, pattern)
|
|
156
|
+
return { file: file, line: line } if line
|
|
157
|
+
end
|
|
136
158
|
end
|
|
137
159
|
end
|
|
138
160
|
|
|
139
161
|
nil
|
|
140
162
|
end
|
|
141
163
|
|
|
164
|
+
def underscore(str)
|
|
165
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
166
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
167
|
+
.downcase
|
|
168
|
+
end
|
|
169
|
+
|
|
142
170
|
def first_line_matching(file, pattern)
|
|
143
171
|
File.foreach(file).with_index(1) { |l, i| return i if l.match?(pattern) }
|
|
144
172
|
nil
|
|
@@ -165,7 +193,7 @@ module PinkSpoon
|
|
|
165
193
|
end
|
|
166
194
|
|
|
167
195
|
# Fall back to project source (lib/, app/) for project-defined methods.
|
|
168
|
-
%w[lib app].each do |subdir|
|
|
196
|
+
%w[lib app spec test].each do |subdir|
|
|
169
197
|
dir = File.join(@root_path, subdir)
|
|
170
198
|
next unless File.directory?(dir)
|
|
171
199
|
Dir.glob("#{dir}/**/*.rb").sort.each do |file|
|
data/lib/pink_spoon/rbi_index.rb
CHANGED
|
@@ -22,8 +22,10 @@ module PinkSpoon
|
|
|
22
22
|
@locations = Hash.new { |h, k| h[k] = {} }
|
|
23
23
|
@mixins = Hash.new { |h, k| h[k] = [] }
|
|
24
24
|
@params = Hash.new { |h, k| h[k] = {} }
|
|
25
|
+
@yields = Hash.new { |h, k| h[k] = {} }
|
|
25
26
|
@const_locations = {}
|
|
26
27
|
@const_resolve_cache = {}
|
|
28
|
+
@method_type_cache = {}
|
|
27
29
|
build
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -160,6 +162,42 @@ module PinkSpoon
|
|
|
160
162
|
result
|
|
161
163
|
end
|
|
162
164
|
|
|
165
|
+
# Returns the yield type declared in the sig (yields(Type)), or nil.
|
|
166
|
+
# e.g. for `sig { yields(WebMock::RequestSignature).returns(...) }`
|
|
167
|
+
# returns "WebMock::RequestSignature".
|
|
168
|
+
def yield_type_for(type, method_name, _seen = [])
|
|
169
|
+
t = normalise(type)
|
|
170
|
+
return nil if _seen.include?(t)
|
|
171
|
+
|
|
172
|
+
direct = @yields[t][method_name.to_s]
|
|
173
|
+
return direct if direct
|
|
174
|
+
|
|
175
|
+
@mixins[t].each do |mixin|
|
|
176
|
+
result = yield_type_for(mixin, method_name, _seen + [t])
|
|
177
|
+
return result if result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Returns the first RBI type that defines +method_name+, scanning all
|
|
184
|
+
# indexed types. Used as a last resort when mixin-chain lookups fail
|
|
185
|
+
# (e.g. WebMock methods not wired into RSpec::Core::ExampleGroup).
|
|
186
|
+
def find_type_for_method(method_name)
|
|
187
|
+
name = method_name.to_s
|
|
188
|
+
return @method_type_cache[name] if @method_type_cache.key?(name)
|
|
189
|
+
|
|
190
|
+
result = nil
|
|
191
|
+
@sigs.each { |type, methods| result = type and break if methods.key?(name) }
|
|
192
|
+
result ||= begin
|
|
193
|
+
found = nil
|
|
194
|
+
@defs.each { |type, methods| found = type and break if methods.key?(name) }
|
|
195
|
+
found
|
|
196
|
+
end
|
|
197
|
+
@method_type_cache[name] = result
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
163
201
|
# All known types (for debugging / testing).
|
|
164
202
|
def types = @sigs.keys
|
|
165
203
|
|
|
@@ -196,6 +234,7 @@ module PinkSpoon
|
|
|
196
234
|
@defs[type][entry[:method]] = entry[:def_line]
|
|
197
235
|
@params[type][entry[:method]] = entry[:params] if entry[:params]
|
|
198
236
|
@sources[type][entry[:method]] = entry[:source] if entry[:source]
|
|
237
|
+
@yields[type][entry[:method]] = entry[:yield_type] if entry[:yield_type]
|
|
199
238
|
@locations[type][entry[:method]] = { file: path, line: entry[:line] }
|
|
200
239
|
end
|
|
201
240
|
|
|
@@ -313,6 +352,7 @@ module PinkSpoon
|
|
|
313
352
|
sig: sig,
|
|
314
353
|
def_line: def_line,
|
|
315
354
|
return_type: sig ? extract_return_type(sig) : nil,
|
|
355
|
+
yield_type: sig ? extract_yield_type(sig) : nil,
|
|
316
356
|
params: sig ? extract_params(sig) : {},
|
|
317
357
|
source: source,
|
|
318
358
|
line: node.location.start_line,
|
|
@@ -361,6 +401,11 @@ module PinkSpoon
|
|
|
361
401
|
match&.[](1)
|
|
362
402
|
end
|
|
363
403
|
|
|
404
|
+
def extract_yield_type(sig)
|
|
405
|
+
match = sig.match(/yields\(\s*([\w:]+)\s*\)/)
|
|
406
|
+
match&.[](1)&.delete_prefix("::")
|
|
407
|
+
end
|
|
408
|
+
|
|
364
409
|
def extract_params(sig)
|
|
365
410
|
m = sig.match(/params\((.+?)\)\s*\./m)
|
|
366
411
|
return {} unless m
|
data/lib/pink_spoon/version.rb
CHANGED
|
@@ -41,6 +41,22 @@ module RubyLsp
|
|
|
41
41
|
source_location(resolved) || rbi_location(resolved)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Bare call with no location yet: try RSpec::Core::ExampleGroup.
|
|
45
|
+
# Handles expect, have_been_made, a_request, stub_request, etc.
|
|
46
|
+
if location.nil? && node.receiver.nil? && resolved.nil?
|
|
47
|
+
eg_resolved = { type: "RSpec::Core::ExampleGroup", method: node.name.to_s }
|
|
48
|
+
location = source_location(eg_resolved) || rbi_location(eg_resolved)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# described_class → go to the class passed to describe(MyClass)
|
|
52
|
+
if location.nil? && node.receiver.nil? && node.name == :described_class
|
|
53
|
+
described = @resolver.described_class_in(program_node)
|
|
54
|
+
if described
|
|
55
|
+
goto_constant(described)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
# Bare call (no explicit receiver): current file first, then nesting.
|
|
45
61
|
# Checking the current file first avoids the project-wide scan in
|
|
46
62
|
# nesting_location picking up a same-named method in a different file
|
|
@@ -48,7 +64,8 @@ module RubyLsp
|
|
|
48
64
|
if location.nil? && node.receiver.nil?
|
|
49
65
|
method_name = node.name.to_s
|
|
50
66
|
location = current_file_location(method_name) ||
|
|
51
|
-
nesting_location(method_name, nesting)
|
|
67
|
+
nesting_location(method_name, nesting) ||
|
|
68
|
+
included_module_location(method_name, program_node)
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
# Fallback: ruby-lsp resolves LocalVariableReadNode receivers up to the
|
|
@@ -97,12 +114,11 @@ module RubyLsp
|
|
|
97
114
|
def on_def_node_enter(node)
|
|
98
115
|
return unless node.equal?(@node_context.node)
|
|
99
116
|
|
|
100
|
-
nesting
|
|
101
|
-
|
|
102
|
-
return unless
|
|
117
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
118
|
+
type = qualified_type_from_nesting(nesting)
|
|
119
|
+
return unless type
|
|
103
120
|
|
|
104
|
-
|
|
105
|
-
loc = source_location({ type: type, method: node.name.to_s })
|
|
121
|
+
loc = source_location({ type: type, method: node.name.to_s })
|
|
106
122
|
push_location(loc[:file], loc[:line]) if loc
|
|
107
123
|
rescue
|
|
108
124
|
nil
|
|
@@ -137,11 +153,10 @@ module RubyLsp
|
|
|
137
153
|
end
|
|
138
154
|
|
|
139
155
|
# Not found in current file — walk the parent class chain.
|
|
140
|
-
nesting
|
|
141
|
-
|
|
142
|
-
return unless
|
|
156
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
157
|
+
current_type = qualified_type_from_nesting(nesting)
|
|
158
|
+
return unless current_type
|
|
143
159
|
|
|
144
|
-
current_type = class_node.constant_path.slice.delete_prefix("::")
|
|
145
160
|
@rbi_index.mixins_for(current_type).each do |parent_type|
|
|
146
161
|
loc = @doc_extractor&.find_ivar_in_type(parent_type, ivar_name)
|
|
147
162
|
if loc
|
|
@@ -181,11 +196,10 @@ module RubyLsp
|
|
|
181
196
|
program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
182
197
|
return unless program_node
|
|
183
198
|
|
|
184
|
-
|
|
185
|
-
return unless
|
|
199
|
+
current_type = qualified_type_from_nesting(nesting)
|
|
200
|
+
return unless current_type
|
|
186
201
|
|
|
187
|
-
|
|
188
|
-
method_name = EnclosingMethodFinder.new(node.location.start_line).find(program_node)
|
|
202
|
+
method_name = EnclosingMethodFinder.new(node.location.start_line).find(program_node)
|
|
189
203
|
return unless method_name
|
|
190
204
|
|
|
191
205
|
@rbi_index.mixins_for(current_type).each do |parent_type|
|
|
@@ -227,16 +241,80 @@ module RubyLsp
|
|
|
227
241
|
nil
|
|
228
242
|
end
|
|
229
243
|
|
|
244
|
+
# Scans the file AST for include/extend/prepend calls and tries each
|
|
245
|
+
# module as a source for the method. Covers the case where a method comes
|
|
246
|
+
# from an included module that isn't in the RBI mixin chain.
|
|
247
|
+
def included_module_location(method_name, program_node)
|
|
248
|
+
included_module_names(program_node).each do |mod_name|
|
|
249
|
+
resolved = { type: mod_name, method: method_name }
|
|
250
|
+
loc = rbi_location(resolved) || source_location(resolved)
|
|
251
|
+
return loc if loc
|
|
252
|
+
end
|
|
253
|
+
nil
|
|
254
|
+
rescue
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def included_module_names(program_node)
|
|
259
|
+
names = []
|
|
260
|
+
collect_includes(program_node, names)
|
|
261
|
+
names
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def collect_includes(node, names)
|
|
265
|
+
return unless node.is_a?(Prism::Node)
|
|
266
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
267
|
+
%i[include extend prepend].include?(node.name)
|
|
268
|
+
node.arguments&.arguments&.each do |arg|
|
|
269
|
+
name = case arg
|
|
270
|
+
when Prism::ConstantReadNode then arg.name.to_s
|
|
271
|
+
when Prism::ConstantPathNode then arg.slice.delete_prefix("::")
|
|
272
|
+
end
|
|
273
|
+
names << name if name
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
node.child_nodes.compact.each { |child| collect_includes(child, names) }
|
|
277
|
+
end
|
|
278
|
+
|
|
230
279
|
def guess_type_from_nesting(nesting)
|
|
231
|
-
|
|
232
|
-
class_node&.constant_path&.slice&.delete_prefix("::")
|
|
280
|
+
qualified_type_from_nesting(nesting)
|
|
233
281
|
end
|
|
234
282
|
|
|
235
|
-
def
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
283
|
+
def qualified_type_from_nesting(nesting)
|
|
284
|
+
return nil unless nesting
|
|
285
|
+
parts = nesting
|
|
286
|
+
.select { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
287
|
+
.map { |n| n.constant_path.slice.delete_prefix("::") }
|
|
288
|
+
parts.empty? ? nil : parts.join("::")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def goto_constant(bare_name)
|
|
292
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
293
|
+
|
|
294
|
+
lexical_candidates(bare_name, nesting).each do |candidate|
|
|
295
|
+
location = @rbi_index.const_source_for(candidate)
|
|
296
|
+
location ||= @doc_extractor&.find_constant_source(candidate)
|
|
297
|
+
next unless location
|
|
298
|
+
|
|
299
|
+
push_location(location[:file], location[:line])
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def lexical_candidates(bare_name, nesting)
|
|
305
|
+
parts = nesting
|
|
306
|
+
&.select { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
307
|
+
&.map { |n| n.constant_path.slice.delete_prefix("::") }
|
|
308
|
+
|
|
309
|
+
return [bare_name] if parts.nil? || parts.empty?
|
|
310
|
+
|
|
311
|
+
candidates = []
|
|
312
|
+
while parts.any?
|
|
313
|
+
candidates << "#{parts.join("::")}::#{bare_name}"
|
|
314
|
+
parts.pop
|
|
315
|
+
end
|
|
316
|
+
candidates << bare_name
|
|
317
|
+
candidates
|
|
240
318
|
end
|
|
241
319
|
|
|
242
320
|
def rbi_location(resolved)
|
|
@@ -250,16 +328,10 @@ module RubyLsp
|
|
|
250
328
|
# Infer the type from the innermost enclosing class/module in the nesting
|
|
251
329
|
# and look the method up in the RBI index or gem source.
|
|
252
330
|
def nesting_location(method_name, nesting)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
class_node = nesting.reverse.find do |n|
|
|
256
|
-
n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)
|
|
257
|
-
end
|
|
258
|
-
return nil unless class_node
|
|
331
|
+
type = qualified_type_from_nesting(nesting)
|
|
332
|
+
return nil unless type
|
|
259
333
|
|
|
260
|
-
type = class_node.constant_path.slice.delete_prefix("::")
|
|
261
334
|
resolved = { type: type, method: method_name }
|
|
262
|
-
|
|
263
335
|
rbi_location(resolved) || source_location(resolved)
|
|
264
336
|
end
|
|
265
337
|
|
|
@@ -40,11 +40,10 @@ module RubyLsp
|
|
|
40
40
|
def on_instance_variable_read_node_enter(node)
|
|
41
41
|
return unless node.equal?(@node_context.node)
|
|
42
42
|
|
|
43
|
-
nesting
|
|
44
|
-
|
|
45
|
-
return unless
|
|
43
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
44
|
+
type = qualified_type_from_nesting(nesting)
|
|
45
|
+
return unless type
|
|
46
46
|
|
|
47
|
-
type = class_node.constant_path.slice.delete_prefix("::")
|
|
48
47
|
accessor = node.name.to_s.delete_prefix("@")
|
|
49
48
|
|
|
50
49
|
content = @rbi_index.hover_content_for(type, accessor)
|
|
@@ -60,12 +59,50 @@ module RubyLsp
|
|
|
60
59
|
program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
61
60
|
return unless program_node
|
|
62
61
|
|
|
62
|
+
if node.name == :described_class && node.receiver.nil?
|
|
63
|
+
described = @resolver.described_class_in(program_node)
|
|
64
|
+
if described
|
|
65
|
+
@response_builder.push("**`described_class`** → `#{described}`", category: :documentation)
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
63
70
|
resolved = @resolver.resolve_from_call(node, program_node)
|
|
71
|
+
|
|
72
|
+
if resolved.nil? && node.receiver.nil?
|
|
73
|
+
type = qualified_type_from_nesting(nesting)
|
|
74
|
+
resolved = { type: type, method: node.name.to_s } if type
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Bare call with still-unresolved type: try ExampleGroup.
|
|
78
|
+
# Catches RSpec/WebMock methods (expect, have_been_made, a_request, etc.)
|
|
79
|
+
# that arrive via globally-included modules not visible in the file AST.
|
|
80
|
+
if resolved.nil? && node.receiver.nil?
|
|
81
|
+
resolved = { type: "RSpec::Core::ExampleGroup", method: node.name.to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
64
84
|
return unless resolved
|
|
65
85
|
|
|
66
86
|
rbi_content = @rbi_index.hover_content_for(resolved[:type], resolved[:method])
|
|
67
87
|
doc_content = @doc_extractor&.extract(resolved[:type], resolved[:method])
|
|
68
88
|
|
|
89
|
+
# If the main type chain yielded nothing and this is a bare call, scan the
|
|
90
|
+
# file AST for include/extend declarations and try each module directly.
|
|
91
|
+
# This covers methods that come from included modules not in the RBI chain.
|
|
92
|
+
if rbi_content.nil? && doc_content.nil? && node.receiver.nil?
|
|
93
|
+
rbi_content, doc_content = hover_from_included_modules(resolved[:method], program_node)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Global fallback: find any RBI type that defines this method.
|
|
97
|
+
# Handles WebMock/custom methods not wired into the ExampleGroup chain.
|
|
98
|
+
if rbi_content.nil? && doc_content.nil? && node.receiver.nil?
|
|
99
|
+
found_type = @rbi_index.find_type_for_method(resolved[:method])
|
|
100
|
+
if found_type
|
|
101
|
+
rbi_content = @rbi_index.hover_content_for(found_type, resolved[:method])
|
|
102
|
+
doc_content = @doc_extractor&.extract(found_type, resolved[:method])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
69
106
|
content = combine(rbi_content, doc_content)
|
|
70
107
|
return unless content
|
|
71
108
|
|
|
@@ -88,13 +125,57 @@ module RubyLsp
|
|
|
88
125
|
when :block_param then "block parameter"
|
|
89
126
|
else "local variable"
|
|
90
127
|
end
|
|
91
|
-
|
|
128
|
+
|
|
129
|
+
type = @resolver.infer_variable_type(node.name.to_s, program, nesting)
|
|
130
|
+
text = type ? "**`#{node.name}`** — *#{label}* `#{type}`" \
|
|
131
|
+
: "**`#{node.name}`** — *#{label}*"
|
|
132
|
+
@response_builder.push(text, category: :documentation)
|
|
92
133
|
rescue
|
|
93
134
|
nil
|
|
94
135
|
end
|
|
95
136
|
|
|
96
137
|
private
|
|
97
138
|
|
|
139
|
+
def hover_from_included_modules(method_name, program_node)
|
|
140
|
+
included_module_names(program_node).each do |mod_name|
|
|
141
|
+
rbi = @rbi_index.hover_content_for(mod_name, method_name)
|
|
142
|
+
doc = @doc_extractor&.extract(mod_name, method_name)
|
|
143
|
+
return [rbi, doc] if rbi || doc
|
|
144
|
+
end
|
|
145
|
+
[nil, nil]
|
|
146
|
+
rescue
|
|
147
|
+
[nil, nil]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def included_module_names(program_node)
|
|
151
|
+
names = []
|
|
152
|
+
collect_includes(program_node, names)
|
|
153
|
+
names
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def collect_includes(node, names)
|
|
157
|
+
return unless node.is_a?(Prism::Node)
|
|
158
|
+
if node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
159
|
+
%i[include extend prepend].include?(node.name)
|
|
160
|
+
node.arguments&.arguments&.each do |arg|
|
|
161
|
+
name = case arg
|
|
162
|
+
when Prism::ConstantReadNode then arg.name.to_s
|
|
163
|
+
when Prism::ConstantPathNode then arg.slice.delete_prefix("::")
|
|
164
|
+
end
|
|
165
|
+
names << name if name
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
node.child_nodes.compact.each { |child| collect_includes(child, names) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def qualified_type_from_nesting(nesting)
|
|
172
|
+
return nil unless nesting
|
|
173
|
+
parts = nesting
|
|
174
|
+
.select { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
175
|
+
.map { |n| n.constant_path.slice.delete_prefix("::") }
|
|
176
|
+
parts.empty? ? nil : parts.join("::")
|
|
177
|
+
end
|
|
178
|
+
|
|
98
179
|
def serve_constant_hover(type)
|
|
99
180
|
return unless @doc_extractor
|
|
100
181
|
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pink_spoon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jānis Harbs
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-13 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: prism
|
|
@@ -39,6 +40,7 @@ dependencies:
|
|
|
39
40
|
version: '0.22'
|
|
40
41
|
description: Augments ruby-lsp hover and go-to-definition with type information from
|
|
41
42
|
sorbet/rbi/**/*.rbi. Drop it in your Gemfile and it just works.
|
|
43
|
+
email:
|
|
42
44
|
executables:
|
|
43
45
|
- pink-spoon
|
|
44
46
|
extensions: []
|
|
@@ -58,8 +60,10 @@ files:
|
|
|
58
60
|
- lib/ruby_lsp/pink_spoon/completion_listener.rb
|
|
59
61
|
- lib/ruby_lsp/pink_spoon/definition_listener.rb
|
|
60
62
|
- lib/ruby_lsp/pink_spoon/hover_listener.rb
|
|
63
|
+
homepage:
|
|
61
64
|
licenses: []
|
|
62
65
|
metadata: {}
|
|
66
|
+
post_install_message:
|
|
63
67
|
rdoc_options: []
|
|
64
68
|
require_paths:
|
|
65
69
|
- lib
|
|
@@ -74,7 +78,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
78
|
- !ruby/object:Gem::Version
|
|
75
79
|
version: '0'
|
|
76
80
|
requirements: []
|
|
77
|
-
rubygems_version: 3.
|
|
81
|
+
rubygems_version: 3.4.19
|
|
82
|
+
signing_key:
|
|
78
83
|
specification_version: 4
|
|
79
84
|
summary: 'ruby-lsp addon: resolves types from Sorbet RBI files'
|
|
80
85
|
test_files: []
|