pink_spoon 0.1.1 → 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 +127 -17
- data/lib/pink_spoon/server.rb +95 -14
- data/lib/pink_spoon/version.rb +1 -1
- data/lib/ruby_lsp/pink_spoon/addon.rb +108 -1
- data/lib/ruby_lsp/pink_spoon/definition_listener.rb +156 -57
- data/lib/ruby_lsp/pink_spoon/hover_listener.rb +166 -4
- 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
|
@@ -14,14 +14,18 @@ module PinkSpoon
|
|
|
14
14
|
# though the method is defined on Hesiod::Gauge (which Hesiod extends).
|
|
15
15
|
class RbiIndex
|
|
16
16
|
def initialize(root_path)
|
|
17
|
-
@root_path
|
|
18
|
-
@sigs
|
|
19
|
-
@types
|
|
20
|
-
@defs
|
|
21
|
-
@sources
|
|
22
|
-
@locations
|
|
23
|
-
@mixins
|
|
24
|
-
@params
|
|
17
|
+
@root_path = root_path
|
|
18
|
+
@sigs = Hash.new { |h, k| h[k] = {} }
|
|
19
|
+
@types = Hash.new { |h, k| h[k] = {} }
|
|
20
|
+
@defs = Hash.new { |h, k| h[k] = {} }
|
|
21
|
+
@sources = Hash.new { |h, k| h[k] = {} }
|
|
22
|
+
@locations = Hash.new { |h, k| h[k] = {} }
|
|
23
|
+
@mixins = Hash.new { |h, k| h[k] = [] }
|
|
24
|
+
@params = Hash.new { |h, k| h[k] = {} }
|
|
25
|
+
@yields = Hash.new { |h, k| h[k] = {} }
|
|
26
|
+
@const_locations = {}
|
|
27
|
+
@const_resolve_cache = {}
|
|
28
|
+
@method_type_cache = {}
|
|
25
29
|
build
|
|
26
30
|
end
|
|
27
31
|
|
|
@@ -109,6 +113,20 @@ module PinkSpoon
|
|
|
109
113
|
@mixins[normalise(type)].dup
|
|
110
114
|
end
|
|
111
115
|
|
|
116
|
+
# Returns { file:, line: } pointing at the real gem source for a constant,
|
|
117
|
+
# resolved from the "# source://" comment in the RBI file, or nil.
|
|
118
|
+
# Resolution is deferred to call time and nil results are not cached so
|
|
119
|
+
# transient Bundler state changes don't permanently break lookups.
|
|
120
|
+
def const_source_for(type)
|
|
121
|
+
raw = @const_locations[normalise(type)]
|
|
122
|
+
return nil unless raw
|
|
123
|
+
cached = @const_resolve_cache[raw]
|
|
124
|
+
return cached if cached
|
|
125
|
+
loc = resolve_source_uri(raw)
|
|
126
|
+
@const_resolve_cache[raw] = loc if loc
|
|
127
|
+
loc
|
|
128
|
+
end
|
|
129
|
+
|
|
112
130
|
# Returns { param_name => type_string } for type#method, or nil.
|
|
113
131
|
# Follows the mixin chain.
|
|
114
132
|
def params_for(type, method_name, _seen = [])
|
|
@@ -144,6 +162,42 @@ module PinkSpoon
|
|
|
144
162
|
result
|
|
145
163
|
end
|
|
146
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
|
+
|
|
147
201
|
# All known types (for debugging / testing).
|
|
148
202
|
def types = @sigs.keys
|
|
149
203
|
|
|
@@ -180,12 +234,51 @@ module PinkSpoon
|
|
|
180
234
|
@defs[type][entry[:method]] = entry[:def_line]
|
|
181
235
|
@params[type][entry[:method]] = entry[:params] if entry[:params]
|
|
182
236
|
@sources[type][entry[:method]] = entry[:source] if entry[:source]
|
|
237
|
+
@yields[type][entry[:method]] = entry[:yield_type] if entry[:yield_type]
|
|
183
238
|
@locations[type][entry[:method]] = { file: path, line: entry[:line] }
|
|
184
239
|
end
|
|
185
240
|
|
|
186
241
|
visitor.mixins.each do |type, mixin_list|
|
|
187
242
|
@mixins[normalise(type)].concat(mixin_list.map { normalise(_1) }).uniq!
|
|
188
243
|
end
|
|
244
|
+
|
|
245
|
+
visitor.const_sources.each do |type, raw_uri|
|
|
246
|
+
@const_locations[normalise(type)] = raw_uri
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Parses "# source://gem-name//lib/path/to/file.rb#42" and resolves
|
|
251
|
+
# the gem name to a real file path.
|
|
252
|
+
#
|
|
253
|
+
# Primary: scan $LOAD_PATH for entries containing /gems/<gem-name>-<version>/.
|
|
254
|
+
# $LOAD_PATH is fixed at process start and unaffected by Bundler state changes
|
|
255
|
+
# that ruby-lsp triggers during its indexing phase.
|
|
256
|
+
#
|
|
257
|
+
# Fallback: scan Gem.path directories (covers cases where the gem's lib dir
|
|
258
|
+
# is not explicitly on the load path).
|
|
259
|
+
def resolve_source_uri(comment)
|
|
260
|
+
m = comment.match(%r{source://([^/]+)//(.+?)#(\d+)})
|
|
261
|
+
return nil unless m
|
|
262
|
+
gem_name, rel_path, line = m[1], m[2], m[3].to_i
|
|
263
|
+
lp_pattern = %r{/gems/(#{Regexp.escape(gem_name)}-[^/]+)/}
|
|
264
|
+
|
|
265
|
+
$LOAD_PATH.each do |lp|
|
|
266
|
+
next unless (lm = lp.match(lp_pattern))
|
|
267
|
+
gem_dir = lp[0, lp.index(lm[0]) + lm[0].length - 1]
|
|
268
|
+
file = File.join(gem_dir, rel_path)
|
|
269
|
+
return { file: file, line: line } if File.exist?(file)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
Gem.path.each do |gem_base|
|
|
273
|
+
Dir.glob("#{gem_base}/gems/#{gem_name}-*/").sort.reverse.each do |gem_dir|
|
|
274
|
+
file = File.join(gem_dir, rel_path)
|
|
275
|
+
return { file: file, line: line } if File.exist?(file)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
nil
|
|
280
|
+
rescue
|
|
281
|
+
nil
|
|
189
282
|
end
|
|
190
283
|
|
|
191
284
|
def normalise(type)
|
|
@@ -198,25 +291,31 @@ module PinkSpoon
|
|
|
198
291
|
# - extend/include calls (mixins)
|
|
199
292
|
# ------------------------------------------------------------------
|
|
200
293
|
class RbiVisitor < Prism::Visitor
|
|
201
|
-
attr_reader :entries, :mixins
|
|
294
|
+
attr_reader :entries, :mixins, :const_sources
|
|
202
295
|
|
|
203
296
|
def initialize(comment_map = {})
|
|
204
|
-
@entries
|
|
205
|
-
@mixins
|
|
206
|
-
@
|
|
207
|
-
@
|
|
208
|
-
@
|
|
297
|
+
@entries = []
|
|
298
|
+
@mixins = Hash.new { |h, k| h[k] = [] }
|
|
299
|
+
@const_sources = {}
|
|
300
|
+
@scope = []
|
|
301
|
+
@pending_sig = nil
|
|
302
|
+
@comment_map = comment_map
|
|
209
303
|
end
|
|
210
304
|
|
|
211
305
|
def visit_module_node(node)
|
|
212
|
-
|
|
306
|
+
source_comment = source_comment_at(node.location.start_line)
|
|
307
|
+
push_scope(node.constant_path) do
|
|
308
|
+
@const_sources[@scope.join("::")] = source_comment if source_comment
|
|
309
|
+
super
|
|
310
|
+
end
|
|
213
311
|
end
|
|
214
312
|
|
|
215
313
|
def visit_class_node(node)
|
|
314
|
+
source_comment = source_comment_at(node.location.start_line)
|
|
216
315
|
push_scope(node.constant_path) do
|
|
217
|
-
|
|
316
|
+
type = @scope.join("::")
|
|
317
|
+
@const_sources[type] = source_comment if source_comment
|
|
218
318
|
if node.superclass
|
|
219
|
-
type = @scope.join("::")
|
|
220
319
|
parent = const_path_to_string(node.superclass).delete_prefix("::")
|
|
221
320
|
@mixins[type] << parent unless @mixins[type].include?(parent)
|
|
222
321
|
end
|
|
@@ -253,6 +352,7 @@ module PinkSpoon
|
|
|
253
352
|
sig: sig,
|
|
254
353
|
def_line: def_line,
|
|
255
354
|
return_type: sig ? extract_return_type(sig) : nil,
|
|
355
|
+
yield_type: sig ? extract_yield_type(sig) : nil,
|
|
256
356
|
params: sig ? extract_params(sig) : {},
|
|
257
357
|
source: source,
|
|
258
358
|
line: node.location.start_line,
|
|
@@ -263,6 +363,11 @@ module PinkSpoon
|
|
|
263
363
|
|
|
264
364
|
private
|
|
265
365
|
|
|
366
|
+
def source_comment_at(start_line)
|
|
367
|
+
c = @comment_map[start_line - 1]
|
|
368
|
+
c if c&.include?("source://")
|
|
369
|
+
end
|
|
370
|
+
|
|
266
371
|
def record_mixins(call_node)
|
|
267
372
|
type = @scope.join("::")
|
|
268
373
|
return if type.empty?
|
|
@@ -296,6 +401,11 @@ module PinkSpoon
|
|
|
296
401
|
match&.[](1)
|
|
297
402
|
end
|
|
298
403
|
|
|
404
|
+
def extract_yield_type(sig)
|
|
405
|
+
match = sig.match(/yields\(\s*([\w:]+)\s*\)/)
|
|
406
|
+
match&.[](1)&.delete_prefix("::")
|
|
407
|
+
end
|
|
408
|
+
|
|
299
409
|
def extract_params(sig)
|
|
300
410
|
m = sig.match(/params\((.+?)\)\s*\./m)
|
|
301
411
|
return {} unless m
|
data/lib/pink_spoon/server.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "prism"
|
|
4
5
|
require_relative "rbi_index"
|
|
5
6
|
require_relative "constant_resolver"
|
|
6
7
|
require_relative "definition_finder"
|
|
@@ -56,6 +57,8 @@ module PinkSpoon
|
|
|
56
57
|
exit(0)
|
|
57
58
|
when "textDocument/definition"
|
|
58
59
|
write_response(id, on_definition(req[:params]))
|
|
60
|
+
when "textDocument/diagnostic"
|
|
61
|
+
write_response(id, { kind: "full", items: [] })
|
|
59
62
|
when "textDocument/hover"
|
|
60
63
|
write_response(id, on_hover(req[:params]))
|
|
61
64
|
else
|
|
@@ -92,23 +95,19 @@ module PinkSpoon
|
|
|
92
95
|
def on_definition(params)
|
|
93
96
|
return nil unless @rbi_index
|
|
94
97
|
|
|
95
|
-
file
|
|
96
|
-
line
|
|
97
|
-
col
|
|
98
|
+
file = uri_to_path(params.dig(:textDocument, :uri))
|
|
99
|
+
line = params.dig(:position, :line)
|
|
100
|
+
col = params.dig(:position, :character)
|
|
98
101
|
|
|
99
102
|
resolved = @constant_resolver.resolve_at(file, line, col)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
if resolved
|
|
104
|
+
location = @definition_finder.find(resolved[:type], resolved[:method])
|
|
105
|
+
return location_response(location[:file], location[:line]) if location
|
|
106
|
+
end
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
start: { line: location[:line] - 1, character: 0 },
|
|
109
|
-
end: { line: location[:line] - 1, character: 0 },
|
|
110
|
-
},
|
|
111
|
-
}
|
|
108
|
+
local_variable_definition(file, line, col)
|
|
109
|
+
rescue
|
|
110
|
+
nil
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
def on_hover(params)
|
|
@@ -132,6 +131,34 @@ module PinkSpoon
|
|
|
132
131
|
}
|
|
133
132
|
end
|
|
134
133
|
|
|
134
|
+
def local_variable_definition(file, lsp_line, col)
|
|
135
|
+
return nil unless file && File.exist?(file)
|
|
136
|
+
|
|
137
|
+
result = Prism.parse_file(file)
|
|
138
|
+
return nil unless result.success?
|
|
139
|
+
|
|
140
|
+
prism_line = lsp_line + 1
|
|
141
|
+
var_node = CursorLocalVarFinder.new(prism_line, col).tap { |f| f.visit(result.value) }.result
|
|
142
|
+
return nil unless var_node
|
|
143
|
+
|
|
144
|
+
decl_line = LocalVarDeclarationFinder.new(var_node.name.to_s, prism_line).tap { |f| f.visit(result.value) }.line
|
|
145
|
+
return nil unless decl_line
|
|
146
|
+
|
|
147
|
+
location_response(file, decl_line)
|
|
148
|
+
rescue
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def location_response(file, line)
|
|
153
|
+
{
|
|
154
|
+
uri: path_to_uri(file),
|
|
155
|
+
range: {
|
|
156
|
+
start: { line: line - 1, character: 0 },
|
|
157
|
+
end: { line: line - 1, character: 0 },
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
135
162
|
# ------------------------------------------------------------------
|
|
136
163
|
# Protocol helpers
|
|
137
164
|
# ------------------------------------------------------------------
|
|
@@ -170,4 +197,58 @@ module PinkSpoon
|
|
|
170
197
|
"file://#{path}"
|
|
171
198
|
end
|
|
172
199
|
end
|
|
200
|
+
|
|
201
|
+
# Finds the LocalVariableReadNode whose source range covers the given cursor.
|
|
202
|
+
class CursorLocalVarFinder < Prism::Visitor
|
|
203
|
+
attr_reader :result
|
|
204
|
+
|
|
205
|
+
def initialize(line, col)
|
|
206
|
+
@line = line
|
|
207
|
+
@col = col
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def visit_local_variable_read_node(node)
|
|
211
|
+
loc = node.location
|
|
212
|
+
if loc.start_line == @line &&
|
|
213
|
+
loc.start_column <= @col &&
|
|
214
|
+
loc.end_column > @col
|
|
215
|
+
@result = node
|
|
216
|
+
end
|
|
217
|
+
super
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Finds the line where a local variable was last declared (parameter or
|
|
222
|
+
# assignment) before a given line number.
|
|
223
|
+
class LocalVarDeclarationFinder < Prism::Visitor
|
|
224
|
+
def initialize(var_name, before_line)
|
|
225
|
+
@var_name = var_name
|
|
226
|
+
@before_line = before_line
|
|
227
|
+
@candidates = []
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def line
|
|
231
|
+
@candidates.last
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def visit_local_variable_write_node(node) = record(node) || super
|
|
235
|
+
def visit_local_variable_operator_write_node(node) = record(node) || super
|
|
236
|
+
def visit_local_variable_or_write_node(node) = record(node) || super
|
|
237
|
+
def visit_local_variable_and_write_node(node) = record(node) || super
|
|
238
|
+
def visit_required_parameter_node(node) = record(node) || super
|
|
239
|
+
def visit_optional_parameter_node(node) = record(node) || super
|
|
240
|
+
def visit_rest_parameter_node(node) = record(node) || super
|
|
241
|
+
def visit_keyword_rest_parameter_node(node) = record(node) || super
|
|
242
|
+
def visit_block_parameter_node(node) = record(node) || super
|
|
243
|
+
def visit_required_keyword_parameter_node(node) = record(node) || super
|
|
244
|
+
def visit_optional_keyword_parameter_node(node) = record(node) || super
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def record(node)
|
|
249
|
+
return unless node.name.to_s == @var_name
|
|
250
|
+
line_no = node.location.start_line
|
|
251
|
+
@candidates << line_no if line_no <= @before_line
|
|
252
|
+
end
|
|
253
|
+
end
|
|
173
254
|
end
|
data/lib/pink_spoon/version.rb
CHANGED
|
@@ -13,6 +13,100 @@ require_relative "code_lens_listener"
|
|
|
13
13
|
|
|
14
14
|
module RubyLsp
|
|
15
15
|
module PinkSpoon
|
|
16
|
+
# Prepended to RubyLsp::Requests::Definition to handle local variable
|
|
17
|
+
# go-to-definition. Ruby-lsp excludes LocalVariableReadNode from its
|
|
18
|
+
# node_types list, so when the cursor is on a local variable (including
|
|
19
|
+
# when it is the receiver of a method call), ruby-lsp sets target = nil
|
|
20
|
+
# and calls no listeners. This patch does a second locate pass for that
|
|
21
|
+
# exact case and dispatches to pink-spoon's DefinitionListener.
|
|
22
|
+
module LocalVariableDefinitionFallback
|
|
23
|
+
class << self
|
|
24
|
+
attr_accessor :addon_instance
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(document, global_state, position, dispatcher, sorbet_level)
|
|
28
|
+
super
|
|
29
|
+
|
|
30
|
+
return if @target
|
|
31
|
+
return unless document.is_a?(RubyLsp::RubyDocument)
|
|
32
|
+
|
|
33
|
+
char_position, _ = document.find_index_by_position(position)
|
|
34
|
+
lv_context = RubyLsp::RubyDocument.locate(
|
|
35
|
+
document.ast,
|
|
36
|
+
char_position,
|
|
37
|
+
node_types: [Prism::LocalVariableReadNode],
|
|
38
|
+
code_units_cache: document.code_units_cache,
|
|
39
|
+
)
|
|
40
|
+
return unless lv_context.node.is_a?(Prism::LocalVariableReadNode)
|
|
41
|
+
|
|
42
|
+
addon = LocalVariableDefinitionFallback.addon_instance
|
|
43
|
+
return unless addon
|
|
44
|
+
|
|
45
|
+
@_lv_dispatcher = Prism::Dispatcher.new
|
|
46
|
+
addon.create_definition_listener(@response_builder, document.uri, lv_context, @_lv_dispatcher)
|
|
47
|
+
@_lv_target = lv_context.node
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def perform
|
|
51
|
+
result = super
|
|
52
|
+
if @_lv_target && @_lv_dispatcher && result.empty?
|
|
53
|
+
@_lv_dispatcher.dispatch_once(@_lv_target)
|
|
54
|
+
return @response_builder.response
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Same pattern as LocalVariableDefinitionFallback but for hover.
|
|
61
|
+
# Hover#perform returns nil when target is nil; here we intercept,
|
|
62
|
+
# do a second locate for LocalVariableReadNode, and return hover content.
|
|
63
|
+
module LocalVariableHoverFallback
|
|
64
|
+
class << self
|
|
65
|
+
attr_accessor :addon_instance
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(document, global_state, position, dispatcher, sorbet_level)
|
|
69
|
+
super
|
|
70
|
+
|
|
71
|
+
return if @target
|
|
72
|
+
return unless document.is_a?(RubyLsp::RubyDocument)
|
|
73
|
+
|
|
74
|
+
char_position, _ = document.find_index_by_position(position)
|
|
75
|
+
lv_context = RubyLsp::RubyDocument.locate(
|
|
76
|
+
document.ast,
|
|
77
|
+
char_position,
|
|
78
|
+
node_types: [Prism::LocalVariableReadNode],
|
|
79
|
+
code_units_cache: document.code_units_cache,
|
|
80
|
+
)
|
|
81
|
+
return unless lv_context.node.is_a?(Prism::LocalVariableReadNode)
|
|
82
|
+
|
|
83
|
+
addon = LocalVariableHoverFallback.addon_instance
|
|
84
|
+
return unless addon
|
|
85
|
+
|
|
86
|
+
@_lv_hover_builder = RubyLsp::ResponseBuilders::Hover.new
|
|
87
|
+
@_lv_hover_dispatcher = Prism::Dispatcher.new
|
|
88
|
+
addon.create_hover_listener(@_lv_hover_builder, lv_context, @_lv_hover_dispatcher)
|
|
89
|
+
@_lv_hover_target = lv_context.node
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def perform
|
|
93
|
+
result = super
|
|
94
|
+
return result if result
|
|
95
|
+
|
|
96
|
+
return nil unless @_lv_hover_target && @_lv_hover_dispatcher
|
|
97
|
+
|
|
98
|
+
@_lv_hover_dispatcher.dispatch_once(@_lv_hover_target)
|
|
99
|
+
return nil if @_lv_hover_builder.empty?
|
|
100
|
+
|
|
101
|
+
RubyLsp::Interface::Hover.new(
|
|
102
|
+
contents: RubyLsp::Interface::MarkupContent.new(
|
|
103
|
+
kind: "markdown",
|
|
104
|
+
value: @_lv_hover_builder.response,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
16
110
|
class Addon < ::RubyLsp::Addon
|
|
17
111
|
def activate(global_state, outgoing_queue)
|
|
18
112
|
root = global_state.workspace_path
|
|
@@ -20,9 +114,22 @@ module RubyLsp
|
|
|
20
114
|
@rbi_index = ::PinkSpoon::RbiIndex.new(root)
|
|
21
115
|
@constant_resolver = ::PinkSpoon::ConstantResolver.new(root, @rbi_index)
|
|
22
116
|
@doc_extractor = ::PinkSpoon::DocExtractor.new(root)
|
|
117
|
+
|
|
118
|
+
unless RubyLsp::Requests::Definition.ancestors.include?(LocalVariableDefinitionFallback)
|
|
119
|
+
LocalVariableDefinitionFallback.addon_instance = self
|
|
120
|
+
RubyLsp::Requests::Definition.prepend(LocalVariableDefinitionFallback)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless RubyLsp::Requests::Hover.ancestors.include?(LocalVariableHoverFallback)
|
|
124
|
+
LocalVariableHoverFallback.addon_instance = self
|
|
125
|
+
RubyLsp::Requests::Hover.prepend(LocalVariableHoverFallback)
|
|
126
|
+
end
|
|
23
127
|
end
|
|
24
128
|
|
|
25
|
-
def deactivate
|
|
129
|
+
def deactivate
|
|
130
|
+
LocalVariableDefinitionFallback.addon_instance = nil
|
|
131
|
+
LocalVariableHoverFallback.addon_instance = nil
|
|
132
|
+
end
|
|
26
133
|
|
|
27
134
|
def name = "Pink Spoon"
|
|
28
135
|
def version = ::PinkSpoon::VERSION
|
|
@@ -41,11 +41,31 @@ module RubyLsp
|
|
|
41
41
|
source_location(resolved) || rbi_location(resolved)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# Bare call
|
|
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
|
+
|
|
60
|
+
# Bare call (no explicit receiver): current file first, then nesting.
|
|
61
|
+
# Checking the current file first avoids the project-wide scan in
|
|
62
|
+
# nesting_location picking up a same-named method in a different file
|
|
63
|
+
# (e.g. alphabetically earlier au/.../download.rb vs gb/.../download.rb).
|
|
45
64
|
if location.nil? && node.receiver.nil?
|
|
46
65
|
method_name = node.name.to_s
|
|
47
|
-
location =
|
|
48
|
-
|
|
66
|
+
location = current_file_location(method_name) ||
|
|
67
|
+
nesting_location(method_name, nesting) ||
|
|
68
|
+
included_module_location(method_name, program_node)
|
|
49
69
|
end
|
|
50
70
|
|
|
51
71
|
# Fallback: ruby-lsp resolves LocalVariableReadNode receivers up to the
|
|
@@ -94,12 +114,11 @@ module RubyLsp
|
|
|
94
114
|
def on_def_node_enter(node)
|
|
95
115
|
return unless node.equal?(@node_context.node)
|
|
96
116
|
|
|
97
|
-
nesting
|
|
98
|
-
|
|
99
|
-
return unless
|
|
117
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
118
|
+
type = qualified_type_from_nesting(nesting)
|
|
119
|
+
return unless type
|
|
100
120
|
|
|
101
|
-
|
|
102
|
-
loc = source_location({ type: type, method: node.name.to_s })
|
|
121
|
+
loc = source_location({ type: type, method: node.name.to_s })
|
|
103
122
|
push_location(loc[:file], loc[:line]) if loc
|
|
104
123
|
rescue
|
|
105
124
|
nil
|
|
@@ -134,11 +153,10 @@ module RubyLsp
|
|
|
134
153
|
end
|
|
135
154
|
|
|
136
155
|
# Not found in current file — walk the parent class chain.
|
|
137
|
-
nesting
|
|
138
|
-
|
|
139
|
-
return unless
|
|
156
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
157
|
+
current_type = qualified_type_from_nesting(nesting)
|
|
158
|
+
return unless current_type
|
|
140
159
|
|
|
141
|
-
current_type = class_node.constant_path.slice.delete_prefix("::")
|
|
142
160
|
@rbi_index.mixins_for(current_type).each do |parent_type|
|
|
143
161
|
loc = @doc_extractor&.find_ivar_in_type(parent_type, ivar_name)
|
|
144
162
|
if loc
|
|
@@ -150,6 +168,27 @@ module RubyLsp
|
|
|
150
168
|
nil
|
|
151
169
|
end
|
|
152
170
|
|
|
171
|
+
# Handles go-to-definition for real local variables (entry = ..., |entry|, etc).
|
|
172
|
+
def on_local_variable_read_node_enter(node)
|
|
173
|
+
return unless node.equal?(@node_context.node)
|
|
174
|
+
|
|
175
|
+
file = @uri.to_s.delete_prefix("file://")
|
|
176
|
+
return unless file && File.exist?(file)
|
|
177
|
+
|
|
178
|
+
var_name = node.name.to_s
|
|
179
|
+
read_line = node.location.start_line
|
|
180
|
+
|
|
181
|
+
result = Prism.parse_file(file)
|
|
182
|
+
finder = LocalVariableDefinitionFinder.new(var_name, read_line)
|
|
183
|
+
finder.visit(result.value)
|
|
184
|
+
line = finder.line
|
|
185
|
+
return unless line
|
|
186
|
+
|
|
187
|
+
push_location(file, line)
|
|
188
|
+
rescue
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
153
192
|
private
|
|
154
193
|
|
|
155
194
|
def navigate_super(node)
|
|
@@ -157,11 +196,10 @@ module RubyLsp
|
|
|
157
196
|
program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
158
197
|
return unless program_node
|
|
159
198
|
|
|
160
|
-
|
|
161
|
-
return unless
|
|
199
|
+
current_type = qualified_type_from_nesting(nesting)
|
|
200
|
+
return unless current_type
|
|
162
201
|
|
|
163
|
-
|
|
164
|
-
method_name = EnclosingMethodFinder.new(node.location.start_line).find(program_node)
|
|
202
|
+
method_name = EnclosingMethodFinder.new(node.location.start_line).find(program_node)
|
|
165
203
|
return unless method_name
|
|
166
204
|
|
|
167
205
|
@rbi_index.mixins_for(current_type).each do |parent_type|
|
|
@@ -203,15 +241,80 @@ module RubyLsp
|
|
|
203
241
|
nil
|
|
204
242
|
end
|
|
205
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
|
+
|
|
206
279
|
def guess_type_from_nesting(nesting)
|
|
207
|
-
|
|
208
|
-
class_node&.constant_path&.slice&.delete_prefix("::")
|
|
280
|
+
qualified_type_from_nesting(nesting)
|
|
209
281
|
end
|
|
210
282
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
215
318
|
end
|
|
216
319
|
|
|
217
320
|
def rbi_location(resolved)
|
|
@@ -225,40 +328,13 @@ module RubyLsp
|
|
|
225
328
|
# Infer the type from the innermost enclosing class/module in the nesting
|
|
226
329
|
# and look the method up in the RBI index or gem source.
|
|
227
330
|
def nesting_location(method_name, nesting)
|
|
228
|
-
|
|
331
|
+
type = qualified_type_from_nesting(nesting)
|
|
332
|
+
return nil unless type
|
|
229
333
|
|
|
230
|
-
class_node = nesting.reverse.find do |n|
|
|
231
|
-
n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)
|
|
232
|
-
end
|
|
233
|
-
return nil unless class_node
|
|
234
|
-
|
|
235
|
-
type = class_node.constant_path.slice.delete_prefix("::")
|
|
236
334
|
resolved = { type: type, method: method_name }
|
|
237
|
-
|
|
238
335
|
rbi_location(resolved) || source_location(resolved)
|
|
239
336
|
end
|
|
240
337
|
|
|
241
|
-
# Handles go-to-definition for real local variables (entry = ..., |entry|, etc).
|
|
242
|
-
def on_local_variable_read_node_enter(node)
|
|
243
|
-
return unless node.equal?(@node_context.node)
|
|
244
|
-
|
|
245
|
-
file = @uri.to_s.delete_prefix("file://")
|
|
246
|
-
return unless file && File.exist?(file)
|
|
247
|
-
|
|
248
|
-
var_name = node.name.to_s
|
|
249
|
-
read_line = node.location.start_line
|
|
250
|
-
|
|
251
|
-
result = Prism.parse_file(file)
|
|
252
|
-
finder = LocalVariableDefinitionFinder.new(var_name, read_line)
|
|
253
|
-
finder.visit(result.value)
|
|
254
|
-
line = finder.line
|
|
255
|
-
return unless line
|
|
256
|
-
|
|
257
|
-
push_location(file, line)
|
|
258
|
-
rescue
|
|
259
|
-
nil
|
|
260
|
-
end
|
|
261
|
-
|
|
262
338
|
# Last resort: scan the file the user is currently editing.
|
|
263
339
|
# Matches def, let(:name), let!(:name), subject(:name), subject!(:name).
|
|
264
340
|
def current_file_location(method_name)
|
|
@@ -280,13 +356,26 @@ module RubyLsp
|
|
|
280
356
|
end
|
|
281
357
|
|
|
282
358
|
def push_location(file, line)
|
|
359
|
+
target_uri = "file://#{file}"
|
|
360
|
+
|
|
361
|
+
begin
|
|
362
|
+
existing = @response_builder.response
|
|
363
|
+
return if existing.any? do |item|
|
|
364
|
+
(item.respond_to?(:target_uri) ? item.target_uri : item.uri) == target_uri
|
|
365
|
+
end
|
|
366
|
+
rescue
|
|
367
|
+
# Can't inspect existing results — proceed with the push
|
|
368
|
+
end
|
|
369
|
+
|
|
283
370
|
line_lsp = line - 1
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
371
|
+
range = Interface::Range.new(
|
|
372
|
+
start: Interface::Position.new(line: line_lsp, character: 0),
|
|
373
|
+
end: Interface::Position.new(line: line_lsp, character: 0),
|
|
374
|
+
)
|
|
375
|
+
@response_builder << Interface::LocationLink.new(
|
|
376
|
+
target_uri: target_uri,
|
|
377
|
+
target_range: range,
|
|
378
|
+
target_selection_range: range,
|
|
290
379
|
)
|
|
291
380
|
end
|
|
292
381
|
|
|
@@ -336,6 +425,16 @@ module RubyLsp
|
|
|
336
425
|
super
|
|
337
426
|
end
|
|
338
427
|
|
|
428
|
+
def visit_rest_parameter_node(node)
|
|
429
|
+
record(node)
|
|
430
|
+
super
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def visit_keyword_rest_parameter_node(node)
|
|
434
|
+
record(node)
|
|
435
|
+
super
|
|
436
|
+
end
|
|
437
|
+
|
|
339
438
|
# Block parameters: do |entry|
|
|
340
439
|
def visit_block_parameter_node(node)
|
|
341
440
|
record(node)
|
|
@@ -23,6 +23,7 @@ module RubyLsp
|
|
|
23
23
|
:on_constant_read_node_enter,
|
|
24
24
|
:on_constant_path_node_enter,
|
|
25
25
|
:on_instance_variable_read_node_enter,
|
|
26
|
+
:on_local_variable_read_node_enter,
|
|
26
27
|
)
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -39,11 +40,10 @@ module RubyLsp
|
|
|
39
40
|
def on_instance_variable_read_node_enter(node)
|
|
40
41
|
return unless node.equal?(@node_context.node)
|
|
41
42
|
|
|
42
|
-
nesting
|
|
43
|
-
|
|
44
|
-
return unless
|
|
43
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
44
|
+
type = qualified_type_from_nesting(nesting)
|
|
45
|
+
return unless type
|
|
45
46
|
|
|
46
|
-
type = class_node.constant_path.slice.delete_prefix("::")
|
|
47
47
|
accessor = node.name.to_s.delete_prefix("@")
|
|
48
48
|
|
|
49
49
|
content = @rbi_index.hover_content_for(type, accessor)
|
|
@@ -59,20 +59,123 @@ module RubyLsp
|
|
|
59
59
|
program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
60
60
|
return unless program_node
|
|
61
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
|
+
|
|
62
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
|
+
|
|
63
84
|
return unless resolved
|
|
64
85
|
|
|
65
86
|
rbi_content = @rbi_index.hover_content_for(resolved[:type], resolved[:method])
|
|
66
87
|
doc_content = @doc_extractor&.extract(resolved[:type], resolved[:method])
|
|
67
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
|
+
|
|
68
106
|
content = combine(rbi_content, doc_content)
|
|
69
107
|
return unless content
|
|
70
108
|
|
|
71
109
|
@response_builder.push(content, category: :documentation)
|
|
72
110
|
end
|
|
73
111
|
|
|
112
|
+
def on_local_variable_read_node_enter(node)
|
|
113
|
+
return unless node.equal?(@node_context.node)
|
|
114
|
+
|
|
115
|
+
nesting = @node_context.instance_variable_get(:@nesting_nodes)
|
|
116
|
+
program = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
|
|
117
|
+
return unless program
|
|
118
|
+
|
|
119
|
+
finder = LocalVarKindFinder.new(node.name.to_s, node.location.start_line)
|
|
120
|
+
finder.visit(program)
|
|
121
|
+
return unless finder.kind
|
|
122
|
+
|
|
123
|
+
label = case finder.kind
|
|
124
|
+
when :method_param then "parameter"
|
|
125
|
+
when :block_param then "block parameter"
|
|
126
|
+
else "local variable"
|
|
127
|
+
end
|
|
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)
|
|
133
|
+
rescue
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
74
137
|
private
|
|
75
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
|
+
|
|
76
179
|
def serve_constant_hover(type)
|
|
77
180
|
return unless @doc_extractor
|
|
78
181
|
|
|
@@ -90,5 +193,64 @@ module RubyLsp
|
|
|
90
193
|
"#{rbi}\n\n---\n\n#{doc}"
|
|
91
194
|
end
|
|
92
195
|
end
|
|
196
|
+
|
|
197
|
+
# Determines whether a local variable is a method parameter, block
|
|
198
|
+
# parameter, or a plain local variable assignment.
|
|
199
|
+
class LocalVarKindFinder < Prism::Visitor
|
|
200
|
+
def initialize(var_name, before_line)
|
|
201
|
+
@var_name = var_name
|
|
202
|
+
@before_line = before_line
|
|
203
|
+
@candidates = []
|
|
204
|
+
@context = []
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def kind
|
|
208
|
+
@candidates.last&.last
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def visit_def_node(node)
|
|
212
|
+
@context.push(:method)
|
|
213
|
+
super
|
|
214
|
+
@context.pop
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def visit_block_node(node)
|
|
218
|
+
@context.push(:block)
|
|
219
|
+
super
|
|
220
|
+
@context.pop
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def visit_lambda_node(node)
|
|
224
|
+
@context.push(:block)
|
|
225
|
+
super
|
|
226
|
+
@context.pop
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def visit_local_variable_write_node(node) = record(node, :local_var) || super
|
|
230
|
+
def visit_local_variable_operator_write_node(node) = record(node, :local_var) || super
|
|
231
|
+
def visit_local_variable_or_write_node(node) = record(node, :local_var) || super
|
|
232
|
+
def visit_local_variable_and_write_node(node) = record(node, :local_var) || super
|
|
233
|
+
|
|
234
|
+
def visit_required_parameter_node(node) = record_param(node) || super
|
|
235
|
+
def visit_optional_parameter_node(node) = record_param(node) || super
|
|
236
|
+
def visit_rest_parameter_node(node) = record_param(node) || super
|
|
237
|
+
def visit_keyword_rest_parameter_node(node) = record_param(node) || super
|
|
238
|
+
def visit_block_parameter_node(node) = record_param(node) || super
|
|
239
|
+
def visit_required_keyword_parameter_node(node) = record_param(node) || super
|
|
240
|
+
def visit_optional_keyword_parameter_node(node) = record_param(node) || super
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def record_param(node)
|
|
245
|
+
kind = @context.last == :block ? :block_param : :method_param
|
|
246
|
+
record(node, kind)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def record(node, kind)
|
|
250
|
+
return unless node.name.to_s == @var_name
|
|
251
|
+
line_no = node.location.start_line
|
|
252
|
+
@candidates << [line_no, kind] if line_no <= @before_line
|
|
253
|
+
end
|
|
254
|
+
end
|
|
93
255
|
end
|
|
94
256
|
end
|
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: []
|