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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fecc3a95f0a37f4c8a764c1dc116960bfd04f7911605512f591aed76d2981f64
4
- data.tar.gz: a76ef303890dd64f6028b2cee4f943462c173c684c1e4551c22560415b40cfd2
3
+ metadata.gz: 7a1dfe02635dfec7fd176f0727ddbf7feff1a23e17ef63fdcee94c283a4e008c
4
+ data.tar.gz: 355c66c3af0e1cb8169d2b3b2887f161558376578a9b009bca577943d536e460
5
5
  SHA512:
6
- metadata.gz: '06128423c7fc856fe1bda76bc2f754e8911556e0f1bb2098866c050db9c909553fe51475591c1bd3996b7af49ce383cc8fce48754f4e0f71c7484534535af6e9'
7
- data.tar.gz: 4d28946be92e5ceb745533cb8e1b9f65edf4390248add98b16c96c69c4252a14ac0bfae429df27ab839c3d99649191ba13c37d6a53c696c8ee772e029fca66d8
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|
@@ -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 = 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] = {} }
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 = Hash.new { |h, k| h[k] = [] }
206
- @scope = []
207
- @pending_sig = nil
208
- @comment_map = comment_map
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
- push_scope(node.constant_path) { super }
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
- # Record superclass as a parent so method lookups walk the chain.
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
@@ -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 = uri_to_path(params.dig(:textDocument, :uri))
96
- line = params.dig(:position, :line)
97
- col = params.dig(:position, :character)
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
- return nil unless resolved
101
-
102
- location = @definition_finder.find(resolved[:type], resolved[:method])
103
- return nil unless location
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
- uri: path_to_uri(location[:file]),
107
- range: {
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PinkSpoon
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -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; end
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 (no explicit receiver): try two more strategies.
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 = nesting_location(method_name, nesting) ||
48
- current_file_location(method_name)
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 = @node_context.instance_variable_get(:@nesting_nodes)
98
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
99
- return unless class_node
117
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
118
+ type = qualified_type_from_nesting(nesting)
119
+ return unless type
100
120
 
101
- type = class_node.constant_path.slice.delete_prefix("::")
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 = @node_context.instance_variable_get(:@nesting_nodes)
138
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
139
- 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
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
- class_node = nesting.reverse.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
161
- return unless class_node
199
+ current_type = qualified_type_from_nesting(nesting)
200
+ return unless current_type
162
201
 
163
- current_type = class_node.constant_path.slice.delete_prefix("::")
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
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
208
- class_node&.constant_path&.slice&.delete_prefix("::")
280
+ qualified_type_from_nesting(nesting)
209
281
  end
210
282
 
211
- def goto_constant(type)
212
- location = @doc_extractor&.find_constant_source(type)
213
- return unless location
214
- 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
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
- return nil unless nesting
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
- @response_builder << Interface::Location.new(
285
- uri: "file://#{file}",
286
- range: Interface::Range.new(
287
- start: Interface::Position.new(line: line_lsp, character: 0),
288
- end: Interface::Position.new(line: line_lsp, character: 0),
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 = @node_context.instance_variable_get(:@nesting_nodes)
43
- class_node = nesting&.reverse&.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
44
- return unless class_node
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.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-02 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: []