pink_spoon 0.1.1 → 0.1.2

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: d7bf9b870581c1eb0fc630bb13d4ef6137767dd64d7546dcb74d92859a20e0f6
4
+ data.tar.gz: 7fce3bba4461680ad220526783e3179dd238fe536b83402fd945938fabf18453
5
5
  SHA512:
6
- metadata.gz: '06128423c7fc856fe1bda76bc2f754e8911556e0f1bb2098866c050db9c909553fe51475591c1bd3996b7af49ce383cc8fce48754f4e0f71c7484534535af6e9'
7
- data.tar.gz: 4d28946be92e5ceb745533cb8e1b9f65edf4390248add98b16c96c69c4252a14ac0bfae429df27ab839c3d99649191ba13c37d6a53c696c8ee772e029fca66d8
6
+ metadata.gz: 4f4cff255afdd927f04e5c4d17116a83374b8f97490713b16d158b5bd025915c7c4ac190e9f59ba1f01f8ca1fb2264adc0cd513f464c822b27e02186b46975ca
7
+ data.tar.gz: 7d4ffa93a4cd9d458b53897bc5878499dc1356d045c3a3d18ba72dd44bc9a719118f7d9bd3ebce95e23a8c0f48a227883b9d5167829cb5623f7bf4abff89a97f
@@ -14,14 +14,16 @@ 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
+ @const_locations = {}
26
+ @const_resolve_cache = {}
25
27
  build
26
28
  end
27
29
 
@@ -109,6 +111,20 @@ module PinkSpoon
109
111
  @mixins[normalise(type)].dup
110
112
  end
111
113
 
114
+ # Returns { file:, line: } pointing at the real gem source for a constant,
115
+ # resolved from the "# source://" comment in the RBI file, or nil.
116
+ # Resolution is deferred to call time and nil results are not cached so
117
+ # transient Bundler state changes don't permanently break lookups.
118
+ def const_source_for(type)
119
+ raw = @const_locations[normalise(type)]
120
+ return nil unless raw
121
+ cached = @const_resolve_cache[raw]
122
+ return cached if cached
123
+ loc = resolve_source_uri(raw)
124
+ @const_resolve_cache[raw] = loc if loc
125
+ loc
126
+ end
127
+
112
128
  # Returns { param_name => type_string } for type#method, or nil.
113
129
  # Follows the mixin chain.
114
130
  def params_for(type, method_name, _seen = [])
@@ -186,6 +202,44 @@ module PinkSpoon
186
202
  visitor.mixins.each do |type, mixin_list|
187
203
  @mixins[normalise(type)].concat(mixin_list.map { normalise(_1) }).uniq!
188
204
  end
205
+
206
+ visitor.const_sources.each do |type, raw_uri|
207
+ @const_locations[normalise(type)] = raw_uri
208
+ end
209
+ end
210
+
211
+ # Parses "# source://gem-name//lib/path/to/file.rb#42" and resolves
212
+ # the gem name to a real file path.
213
+ #
214
+ # Primary: scan $LOAD_PATH for entries containing /gems/<gem-name>-<version>/.
215
+ # $LOAD_PATH is fixed at process start and unaffected by Bundler state changes
216
+ # that ruby-lsp triggers during its indexing phase.
217
+ #
218
+ # Fallback: scan Gem.path directories (covers cases where the gem's lib dir
219
+ # is not explicitly on the load path).
220
+ def resolve_source_uri(comment)
221
+ m = comment.match(%r{source://([^/]+)//(.+?)#(\d+)})
222
+ return nil unless m
223
+ gem_name, rel_path, line = m[1], m[2], m[3].to_i
224
+ lp_pattern = %r{/gems/(#{Regexp.escape(gem_name)}-[^/]+)/}
225
+
226
+ $LOAD_PATH.each do |lp|
227
+ next unless (lm = lp.match(lp_pattern))
228
+ gem_dir = lp[0, lp.index(lm[0]) + lm[0].length - 1]
229
+ file = File.join(gem_dir, rel_path)
230
+ return { file: file, line: line } if File.exist?(file)
231
+ end
232
+
233
+ Gem.path.each do |gem_base|
234
+ Dir.glob("#{gem_base}/gems/#{gem_name}-*/").sort.reverse.each do |gem_dir|
235
+ file = File.join(gem_dir, rel_path)
236
+ return { file: file, line: line } if File.exist?(file)
237
+ end
238
+ end
239
+
240
+ nil
241
+ rescue
242
+ nil
189
243
  end
190
244
 
191
245
  def normalise(type)
@@ -198,25 +252,31 @@ module PinkSpoon
198
252
  # - extend/include calls (mixins)
199
253
  # ------------------------------------------------------------------
200
254
  class RbiVisitor < Prism::Visitor
201
- attr_reader :entries, :mixins
255
+ attr_reader :entries, :mixins, :const_sources
202
256
 
203
257
  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
258
+ @entries = []
259
+ @mixins = Hash.new { |h, k| h[k] = [] }
260
+ @const_sources = {}
261
+ @scope = []
262
+ @pending_sig = nil
263
+ @comment_map = comment_map
209
264
  end
210
265
 
211
266
  def visit_module_node(node)
212
- push_scope(node.constant_path) { super }
267
+ source_comment = source_comment_at(node.location.start_line)
268
+ push_scope(node.constant_path) do
269
+ @const_sources[@scope.join("::")] = source_comment if source_comment
270
+ super
271
+ end
213
272
  end
214
273
 
215
274
  def visit_class_node(node)
275
+ source_comment = source_comment_at(node.location.start_line)
216
276
  push_scope(node.constant_path) do
217
- # Record superclass as a parent so method lookups walk the chain.
277
+ type = @scope.join("::")
278
+ @const_sources[type] = source_comment if source_comment
218
279
  if node.superclass
219
- type = @scope.join("::")
220
280
  parent = const_path_to_string(node.superclass).delete_prefix("::")
221
281
  @mixins[type] << parent unless @mixins[type].include?(parent)
222
282
  end
@@ -263,6 +323,11 @@ module PinkSpoon
263
323
 
264
324
  private
265
325
 
326
+ def source_comment_at(start_line)
327
+ c = @comment_map[start_line - 1]
328
+ c if c&.include?("source://")
329
+ end
330
+
266
331
  def record_mixins(call_node)
267
332
  type = @scope.join("::")
268
333
  return if type.empty?
@@ -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.2"
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,14 @@ 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 (no explicit receiver): current file first, then nesting.
45
+ # Checking the current file first avoids the project-wide scan in
46
+ # nesting_location picking up a same-named method in a different file
47
+ # (e.g. alphabetically earlier au/.../download.rb vs gb/.../download.rb).
45
48
  if location.nil? && node.receiver.nil?
46
49
  method_name = node.name.to_s
47
- location = nesting_location(method_name, nesting) ||
48
- current_file_location(method_name)
50
+ location = current_file_location(method_name) ||
51
+ nesting_location(method_name, nesting)
49
52
  end
50
53
 
51
54
  # Fallback: ruby-lsp resolves LocalVariableReadNode receivers up to the
@@ -150,6 +153,27 @@ module RubyLsp
150
153
  nil
151
154
  end
152
155
 
156
+ # Handles go-to-definition for real local variables (entry = ..., |entry|, etc).
157
+ def on_local_variable_read_node_enter(node)
158
+ return unless node.equal?(@node_context.node)
159
+
160
+ file = @uri.to_s.delete_prefix("file://")
161
+ return unless file && File.exist?(file)
162
+
163
+ var_name = node.name.to_s
164
+ read_line = node.location.start_line
165
+
166
+ result = Prism.parse_file(file)
167
+ finder = LocalVariableDefinitionFinder.new(var_name, read_line)
168
+ finder.visit(result.value)
169
+ line = finder.line
170
+ return unless line
171
+
172
+ push_location(file, line)
173
+ rescue
174
+ nil
175
+ end
176
+
153
177
  private
154
178
 
155
179
  def navigate_super(node)
@@ -209,7 +233,8 @@ module RubyLsp
209
233
  end
210
234
 
211
235
  def goto_constant(type)
212
- location = @doc_extractor&.find_constant_source(type)
236
+ location = @rbi_index.const_source_for(type)
237
+ location ||= @doc_extractor&.find_constant_source(type)
213
238
  return unless location
214
239
  push_location(location[:file], location[:line])
215
240
  end
@@ -238,27 +263,6 @@ module RubyLsp
238
263
  rbi_location(resolved) || source_location(resolved)
239
264
  end
240
265
 
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
266
  # Last resort: scan the file the user is currently editing.
263
267
  # Matches def, let(:name), let!(:name), subject(:name), subject!(:name).
264
268
  def current_file_location(method_name)
@@ -280,13 +284,26 @@ module RubyLsp
280
284
  end
281
285
 
282
286
  def push_location(file, line)
287
+ target_uri = "file://#{file}"
288
+
289
+ begin
290
+ existing = @response_builder.response
291
+ return if existing.any? do |item|
292
+ (item.respond_to?(:target_uri) ? item.target_uri : item.uri) == target_uri
293
+ end
294
+ rescue
295
+ # Can't inspect existing results — proceed with the push
296
+ end
297
+
283
298
  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
- ),
299
+ range = Interface::Range.new(
300
+ start: Interface::Position.new(line: line_lsp, character: 0),
301
+ end: Interface::Position.new(line: line_lsp, character: 0),
302
+ )
303
+ @response_builder << Interface::LocationLink.new(
304
+ target_uri: target_uri,
305
+ target_range: range,
306
+ target_selection_range: range,
290
307
  )
291
308
  end
292
309
 
@@ -336,6 +353,16 @@ module RubyLsp
336
353
  super
337
354
  end
338
355
 
356
+ def visit_rest_parameter_node(node)
357
+ record(node)
358
+ super
359
+ end
360
+
361
+ def visit_keyword_rest_parameter_node(node)
362
+ record(node)
363
+ super
364
+ end
365
+
339
366
  # Block parameters: do |entry|
340
367
  def visit_block_parameter_node(node)
341
368
  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
 
@@ -71,6 +72,27 @@ module RubyLsp
71
72
  @response_builder.push(content, category: :documentation)
72
73
  end
73
74
 
75
+ def on_local_variable_read_node_enter(node)
76
+ return unless node.equal?(@node_context.node)
77
+
78
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
79
+ program = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
80
+ return unless program
81
+
82
+ finder = LocalVarKindFinder.new(node.name.to_s, node.location.start_line)
83
+ finder.visit(program)
84
+ return unless finder.kind
85
+
86
+ label = case finder.kind
87
+ when :method_param then "parameter"
88
+ when :block_param then "block parameter"
89
+ else "local variable"
90
+ end
91
+ @response_builder.push("**`#{node.name}`** — *#{label}*", category: :documentation)
92
+ rescue
93
+ nil
94
+ end
95
+
74
96
  private
75
97
 
76
98
  def serve_constant_hover(type)
@@ -90,5 +112,64 @@ module RubyLsp
90
112
  "#{rbi}\n\n---\n\n#{doc}"
91
113
  end
92
114
  end
115
+
116
+ # Determines whether a local variable is a method parameter, block
117
+ # parameter, or a plain local variable assignment.
118
+ class LocalVarKindFinder < Prism::Visitor
119
+ def initialize(var_name, before_line)
120
+ @var_name = var_name
121
+ @before_line = before_line
122
+ @candidates = []
123
+ @context = []
124
+ end
125
+
126
+ def kind
127
+ @candidates.last&.last
128
+ end
129
+
130
+ def visit_def_node(node)
131
+ @context.push(:method)
132
+ super
133
+ @context.pop
134
+ end
135
+
136
+ def visit_block_node(node)
137
+ @context.push(:block)
138
+ super
139
+ @context.pop
140
+ end
141
+
142
+ def visit_lambda_node(node)
143
+ @context.push(:block)
144
+ super
145
+ @context.pop
146
+ end
147
+
148
+ def visit_local_variable_write_node(node) = record(node, :local_var) || super
149
+ def visit_local_variable_operator_write_node(node) = record(node, :local_var) || super
150
+ def visit_local_variable_or_write_node(node) = record(node, :local_var) || super
151
+ def visit_local_variable_and_write_node(node) = record(node, :local_var) || super
152
+
153
+ def visit_required_parameter_node(node) = record_param(node) || super
154
+ def visit_optional_parameter_node(node) = record_param(node) || super
155
+ def visit_rest_parameter_node(node) = record_param(node) || super
156
+ def visit_keyword_rest_parameter_node(node) = record_param(node) || super
157
+ def visit_block_parameter_node(node) = record_param(node) || super
158
+ def visit_required_keyword_parameter_node(node) = record_param(node) || super
159
+ def visit_optional_keyword_parameter_node(node) = record_param(node) || super
160
+
161
+ private
162
+
163
+ def record_param(node)
164
+ kind = @context.last == :block ? :block_param : :method_param
165
+ record(node, kind)
166
+ end
167
+
168
+ def record(node, kind)
169
+ return unless node.name.to_s == @var_name
170
+ line_no = node.location.start_line
171
+ @candidates << [line_no, kind] if line_no <= @before_line
172
+ end
173
+ end
93
174
  end
94
175
  end
metadata CHANGED
@@ -1,13 +1,13 @@
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jānis Harbs
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-02 00:00:00.000000000 Z
10
+ date: 2026-06-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: prism