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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7bf9b870581c1eb0fc630bb13d4ef6137767dd64d7546dcb74d92859a20e0f6
4
- data.tar.gz: 7fce3bba4461680ad220526783e3179dd238fe536b83402fd945938fabf18453
3
+ metadata.gz: 7a1dfe02635dfec7fd176f0727ddbf7feff1a23e17ef63fdcee94c283a4e008c
4
+ data.tar.gz: 355c66c3af0e1cb8169d2b3b2887f161558376578a9b009bca577943d536e460
5
5
  SHA512:
6
- metadata.gz: 4f4cff255afdd927f04e5c4d17116a83374b8f97490713b16d158b5bd025915c7c4ac190e9f59ba1f01f8ca1fb2264adc0cd513f464c822b27e02186b46975ca
7
- data.tar.gz: 7d4ffa93a4cd9d458b53897bc5878499dc1356d045c3a3d18ba72dd44bc9a719118f7d9bd3ebce95e23a8c0f48a227883b9d5167829cb5623f7bf4abff89a97f
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 element type from receiver.
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&.receiver
316
+ return nil unless finder.enclosing_call
317
+
318
+ call = finder.enclosing_call
319
+ method_name = call.name.to_s
278
320
 
279
- call = finder.enclosing_call
280
- receiver_type = resolve_receiver(call.receiver, ast, source, _seen)
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
- method_name = call.name.to_s
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
- 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("::")
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
- # Fall back to project source (lib/, app/).
130
- %w[lib app].each do |subdir|
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
- Dir.glob("#{dir}/**/*.rb").sort.each do |file|
134
- line = first_line_matching(file, pattern)
135
- return { file: file, line: line } if line
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|
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PinkSpoon
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -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 = @node_context.instance_variable_get(:@nesting_nodes)
101
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
102
- return unless class_node
117
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
118
+ type = qualified_type_from_nesting(nesting)
119
+ return unless type
103
120
 
104
- type = class_node.constant_path.slice.delete_prefix("::")
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 = @node_context.instance_variable_get(:@nesting_nodes)
141
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
142
- return unless class_node
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
- class_node = nesting.reverse.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
185
- return unless class_node
199
+ current_type = qualified_type_from_nesting(nesting)
200
+ return unless current_type
186
201
 
187
- current_type = class_node.constant_path.slice.delete_prefix("::")
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
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
232
- class_node&.constant_path&.slice&.delete_prefix("::")
280
+ qualified_type_from_nesting(nesting)
233
281
  end
234
282
 
235
- def goto_constant(type)
236
- location = @rbi_index.const_source_for(type)
237
- location ||= @doc_extractor&.find_constant_source(type)
238
- return unless location
239
- push_location(location[:file], location[:line])
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
- return nil unless nesting
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 = @node_context.instance_variable_get(:@nesting_nodes)
44
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
45
- return unless class_node
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
- @response_builder.push("**`#{node.name}`** — *#{label}*", category: :documentation)
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.2
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-03 00:00:00.000000000 Z
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.6.2
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: []