pink_spoon 0.1.0

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.
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module PinkSpoon
5
+ # Fired on every textDocument/definition request.
6
+ # Resolves the call under the cursor, looks up the RBI definition location,
7
+ # and pushes an Interface::Location into the response.
8
+ class DefinitionListener
9
+ def initialize(response_builder, uri, node_context, dispatcher, resolver, rbi_index, doc_extractor = nil)
10
+ @response_builder = response_builder
11
+ @uri = uri
12
+ @node_context = node_context
13
+ @resolver = resolver
14
+ @rbi_index = rbi_index
15
+ @doc_extractor = doc_extractor
16
+
17
+ dispatcher.register(
18
+ self,
19
+ :on_call_node_enter,
20
+ :on_local_variable_read_node_enter,
21
+ :on_constant_read_node_enter,
22
+ :on_constant_path_node_enter,
23
+ :on_instance_variable_read_node_enter,
24
+ :on_forwarding_super_node_enter,
25
+ :on_super_node_enter,
26
+ :on_symbol_node_enter,
27
+ :on_def_node_enter,
28
+ )
29
+ end
30
+
31
+ def on_call_node_enter(node)
32
+ return unless node.equal?(@node_context.node)
33
+
34
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
35
+ program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
36
+ return unless program_node
37
+
38
+ resolved = @resolver.resolve_from_call(node, program_node, nesting)
39
+
40
+ location = if resolved
41
+ source_location(resolved) || rbi_location(resolved)
42
+ end
43
+
44
+ # Bare call (no explicit receiver): try two more strategies.
45
+ if location.nil? && node.receiver.nil?
46
+ method_name = node.name.to_s
47
+ location = nesting_location(method_name, nesting) ||
48
+ current_file_location(method_name)
49
+ end
50
+
51
+ # Fallback: ruby-lsp resolves LocalVariableReadNode receivers up to the
52
+ # parent CallNode (LocalVariableReadNode is not in the definition node_types
53
+ # list). If method resolution failed, navigate to the receiver variable's
54
+ # declaration instead — that is almost certainly what the user clicked on.
55
+ if location.nil? && node.receiver
56
+ location = receiver_variable_location(node.receiver)
57
+ end
58
+
59
+ return unless location
60
+
61
+ push_location(location[:file], location[:line])
62
+ end
63
+
64
+ def on_constant_read_node_enter(node)
65
+ return unless node.equal?(@node_context.node)
66
+ goto_constant(node.name.to_s)
67
+ end
68
+
69
+ def on_constant_path_node_enter(node)
70
+ return unless node.equal?(@node_context.node)
71
+ goto_constant(node.slice.delete_prefix("::"))
72
+ end
73
+
74
+ # Symbol go-to-def: :method_name → def method_name
75
+ def on_symbol_node_enter(node)
76
+ return unless node.equal?(@node_context.node)
77
+
78
+ method_name = node.value
79
+ return unless method_name && !method_name.empty?
80
+
81
+ loc = current_file_location(method_name)
82
+ return push_location(loc[:file], loc[:line]) if loc
83
+
84
+ loc = @doc_extractor&.find_source(
85
+ guess_type_from_nesting(@node_context.instance_variable_get(:@nesting_nodes)),
86
+ method_name
87
+ )
88
+ push_location(loc[:file], loc[:line]) if loc
89
+ rescue
90
+ nil
91
+ end
92
+
93
+ # Hover on def: cursor on the method name in a definition
94
+ def on_def_node_enter(node)
95
+ return unless node.equal?(@node_context.node)
96
+
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
100
+
101
+ type = class_node.constant_path.slice.delete_prefix("::")
102
+ loc = source_location({ type: type, method: node.name.to_s })
103
+ push_location(loc[:file], loc[:line]) if loc
104
+ rescue
105
+ nil
106
+ end
107
+
108
+ def on_forwarding_super_node_enter(node)
109
+ return unless node.equal?(@node_context.node)
110
+ navigate_super(node)
111
+ end
112
+
113
+ def on_super_node_enter(node)
114
+ return unless node.equal?(@node_context.node)
115
+ navigate_super(node)
116
+ end
117
+
118
+ def on_instance_variable_read_node_enter(node)
119
+ return unless node.equal?(@node_context.node)
120
+
121
+ file = @uri.to_s.delete_prefix("file://")
122
+ return unless file && File.exist?(file)
123
+
124
+ ivar_name = node.name.to_s
125
+ read_line = node.location.start_line
126
+
127
+ result = Prism.parse_file(file)
128
+ finder = IvarAssignmentFinder.new(ivar_name, read_line)
129
+ finder.visit(result.value)
130
+
131
+ if finder.line
132
+ push_location(file, finder.line)
133
+ return
134
+ end
135
+
136
+ # 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
140
+
141
+ current_type = class_node.constant_path.slice.delete_prefix("::")
142
+ @rbi_index.mixins_for(current_type).each do |parent_type|
143
+ loc = @doc_extractor&.find_ivar_in_type(parent_type, ivar_name)
144
+ if loc
145
+ push_location(loc[:file], loc[:line])
146
+ return
147
+ end
148
+ end
149
+ rescue
150
+ nil
151
+ end
152
+
153
+ private
154
+
155
+ def navigate_super(node)
156
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
157
+ program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
158
+ return unless program_node
159
+
160
+ class_node = nesting.reverse.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
161
+ return unless class_node
162
+
163
+ current_type = class_node.constant_path.slice.delete_prefix("::")
164
+ method_name = EnclosingMethodFinder.new(node.location.start_line).find(program_node)
165
+ return unless method_name
166
+
167
+ @rbi_index.mixins_for(current_type).each do |parent_type|
168
+ resolved = { type: parent_type, method: method_name }
169
+ location = rbi_location(resolved) || source_location(resolved)
170
+ if location
171
+ push_location(location[:file], location[:line])
172
+ return
173
+ end
174
+ end
175
+ end
176
+
177
+ # When go-to-def is clicked on a local variable or ivar that is the
178
+ # receiver of a method call, navigate to where that variable is declared.
179
+ def receiver_variable_location(receiver)
180
+ file = @uri.to_s.delete_prefix("file://")
181
+ return nil unless file && File.exist?(file)
182
+
183
+ result = Prism.parse_file(file)
184
+
185
+ case receiver
186
+ when Prism::LocalVariableReadNode
187
+ finder = LocalVariableDefinitionFinder.new(
188
+ receiver.name.to_s,
189
+ receiver.location.start_line,
190
+ )
191
+ finder.visit(result.value)
192
+ { file: file, line: finder.line } if finder.line
193
+
194
+ when Prism::InstanceVariableReadNode
195
+ finder = IvarAssignmentFinder.new(
196
+ receiver.name.to_s,
197
+ receiver.location.start_line,
198
+ )
199
+ finder.visit(result.value)
200
+ { file: file, line: finder.line } if finder.line
201
+ end
202
+ rescue
203
+ nil
204
+ end
205
+
206
+ 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("::")
209
+ end
210
+
211
+ def goto_constant(type)
212
+ location = @doc_extractor&.find_constant_source(type)
213
+ return unless location
214
+ push_location(location[:file], location[:line])
215
+ end
216
+
217
+ def rbi_location(resolved)
218
+ @rbi_index.rbi_location_for(resolved[:type], resolved[:method])
219
+ end
220
+
221
+ def source_location(resolved)
222
+ @doc_extractor&.find_source(resolved[:type], resolved[:method])
223
+ end
224
+
225
+ # Infer the type from the innermost enclosing class/module in the nesting
226
+ # and look the method up in the RBI index or gem source.
227
+ def nesting_location(method_name, nesting)
228
+ return nil unless nesting
229
+
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
+ resolved = { type: type, method: method_name }
237
+
238
+ rbi_location(resolved) || source_location(resolved)
239
+ end
240
+
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
+ # Last resort: scan the file the user is currently editing.
263
+ # Matches def, let(:name), let!(:name), subject(:name), subject!(:name).
264
+ def current_file_location(method_name)
265
+ file = @uri.to_s.delete_prefix("file://")
266
+ return nil unless file && File.exist?(file)
267
+
268
+ e = Regexp.escape(method_name)
269
+ def_re = /\bdef\s+(?:self\.)?#{e}(?=[\s\(;]|\z)/
270
+ let_re = /\blet[!]?\s*\(\s*:#{e}\b/
271
+ subj_re = /\bsubject[!]?\s*\(\s*:#{e}\b/
272
+
273
+ File.foreach(file).with_index(1) do |raw, i|
274
+ return { file: file, line: i } if raw.match?(def_re) || raw.match?(let_re) || raw.match?(subj_re)
275
+ end
276
+
277
+ nil
278
+ rescue
279
+ nil
280
+ end
281
+
282
+ def push_location(file, line)
283
+ 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
+ ),
290
+ )
291
+ end
292
+
293
+ # Finds the line where a local variable was last assigned before
294
+ # a given line. Handles plain writes, block params, and method params.
295
+ class LocalVariableDefinitionFinder < Prism::Visitor
296
+ attr_reader :line
297
+
298
+ def initialize(var_name, before_line)
299
+ @var_name = var_name
300
+ @before_line = before_line
301
+ @candidates = []
302
+ end
303
+
304
+ def line
305
+ @candidates.last
306
+ end
307
+
308
+ def visit_local_variable_write_node(node)
309
+ record(node)
310
+ super
311
+ end
312
+
313
+ def visit_local_variable_operator_write_node(node)
314
+ record(node)
315
+ super
316
+ end
317
+
318
+ def visit_local_variable_or_write_node(node)
319
+ record(node)
320
+ super
321
+ end
322
+
323
+ def visit_local_variable_and_write_node(node)
324
+ record(node)
325
+ super
326
+ end
327
+
328
+ # Method parameters: def foo(entry)
329
+ def visit_required_parameter_node(node)
330
+ record(node)
331
+ super
332
+ end
333
+
334
+ def visit_optional_parameter_node(node)
335
+ record(node)
336
+ super
337
+ end
338
+
339
+ # Block parameters: do |entry|
340
+ def visit_block_parameter_node(node)
341
+ record(node)
342
+ super
343
+ end
344
+
345
+ def visit_required_keyword_parameter_node(node)
346
+ record(node)
347
+ super
348
+ end
349
+
350
+ def visit_optional_keyword_parameter_node(node)
351
+ record(node)
352
+ super
353
+ end
354
+
355
+ private
356
+
357
+ def record(node)
358
+ return unless node.name.to_s == @var_name
359
+ line_no = node.location.start_line
360
+ @candidates << line_no if line_no <= @before_line
361
+ end
362
+ end
363
+
364
+ # Finds the name of the innermost method def that contains a target line.
365
+ class EnclosingMethodFinder < Prism::Visitor
366
+ def initialize(target_line)
367
+ @target_line = target_line
368
+ @best_name = nil
369
+ @best_span = nil
370
+ end
371
+
372
+ def find(ast)
373
+ visit(ast)
374
+ @best_name
375
+ end
376
+
377
+ def visit_def_node(node)
378
+ loc = node.location
379
+ return super unless @target_line >= loc.start_line && @target_line <= loc.end_line
380
+
381
+ span = loc.end_line - loc.start_line
382
+ if @best_span.nil? || span < @best_span
383
+ @best_span = span
384
+ @best_name = node.name.to_s
385
+ end
386
+ super
387
+ end
388
+ end
389
+
390
+ # Finds the nearest instance variable write (@ivar = ...) before a given line.
391
+ class IvarAssignmentFinder < Prism::Visitor
392
+ def initialize(ivar_name, before_line)
393
+ @ivar_name = ivar_name
394
+ @before_line = before_line
395
+ @candidates = []
396
+ end
397
+
398
+ def line
399
+ @candidates.last
400
+ end
401
+
402
+ def visit_instance_variable_write_node(node)
403
+ if node.name.to_s == @ivar_name && node.location.start_line <= @before_line
404
+ @candidates << node.location.start_line
405
+ end
406
+ super
407
+ end
408
+
409
+ def visit_instance_variable_operator_write_node(node)
410
+ if node.name.to_s == @ivar_name && node.location.start_line <= @before_line
411
+ @candidates << node.location.start_line
412
+ end
413
+ super
414
+ end
415
+
416
+ def visit_instance_variable_or_write_node(node)
417
+ if node.name.to_s == @ivar_name && node.location.start_line <= @before_line
418
+ @candidates << node.location.start_line
419
+ end
420
+ super
421
+ end
422
+ end
423
+ end
424
+ end
425
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module PinkSpoon
5
+ # Fired on every textDocument/hover request.
6
+ # Registers for two node types:
7
+ # on_program_node_enter — captures the root AST so AssignmentFinder can
8
+ # scan the whole file for constant assignments.
9
+ # on_call_node_enter — resolves the receiver type for the call under
10
+ # the cursor and pushes a sig string into the
11
+ # hover panel.
12
+ class HoverListener
13
+ def initialize(response_builder, node_context, dispatcher, resolver, rbi_index, doc_extractor = nil)
14
+ @response_builder = response_builder
15
+ @node_context = node_context
16
+ @resolver = resolver
17
+ @rbi_index = rbi_index
18
+ @doc_extractor = doc_extractor
19
+
20
+ dispatcher.register(
21
+ self,
22
+ :on_call_node_enter,
23
+ :on_constant_read_node_enter,
24
+ :on_constant_path_node_enter,
25
+ :on_instance_variable_read_node_enter,
26
+ )
27
+ end
28
+
29
+ def on_constant_read_node_enter(node)
30
+ return unless node.equal?(@node_context.node)
31
+ serve_constant_hover(node.name.to_s)
32
+ end
33
+
34
+ def on_constant_path_node_enter(node)
35
+ return unless node.equal?(@node_context.node)
36
+ serve_constant_hover(node.slice.delete_prefix("::"))
37
+ end
38
+
39
+ def on_instance_variable_read_node_enter(node)
40
+ return unless node.equal?(@node_context.node)
41
+
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
45
+
46
+ type = class_node.constant_path.slice.delete_prefix("::")
47
+ accessor = node.name.to_s.delete_prefix("@")
48
+
49
+ content = @rbi_index.hover_content_for(type, accessor)
50
+ return unless content
51
+
52
+ @response_builder.push(content, category: :documentation)
53
+ end
54
+
55
+ def on_call_node_enter(node)
56
+ return unless node.equal?(@node_context.node)
57
+
58
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
59
+ program_node = nesting&.find { |n| n.is_a?(Prism::ProgramNode) }
60
+ return unless program_node
61
+
62
+ resolved = @resolver.resolve_from_call(node, program_node)
63
+ return unless resolved
64
+
65
+ rbi_content = @rbi_index.hover_content_for(resolved[:type], resolved[:method])
66
+ doc_content = @doc_extractor&.extract(resolved[:type], resolved[:method])
67
+
68
+ content = combine(rbi_content, doc_content)
69
+ return unless content
70
+
71
+ @response_builder.push(content, category: :documentation)
72
+ end
73
+
74
+ private
75
+
76
+ def serve_constant_hover(type)
77
+ return unless @doc_extractor
78
+
79
+ doc = @doc_extractor.extract_for_constant(type)
80
+ return unless doc
81
+
82
+ @response_builder.push("**`#{type}`**\n\n#{doc}", category: :documentation)
83
+ end
84
+
85
+ def combine(rbi, doc)
86
+ return nil unless rbi || doc
87
+ return rbi unless doc
88
+ return doc unless rbi
89
+
90
+ "#{rbi}\n\n---\n\n#{doc}"
91
+ end
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pink_spoon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jānis Harbs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-06-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.24'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ruby-lsp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.22'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.22'
40
+ description: Augments ruby-lsp hover and go-to-definition with type information from
41
+ sorbet/rbi/**/*.rbi. Drop it in your Gemfile and it just works.
42
+ executables:
43
+ - pink-spoon
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - bin/install-addon
48
+ - bin/pink-spoon
49
+ - lib/pink_spoon.rb
50
+ - lib/pink_spoon/constant_resolver.rb
51
+ - lib/pink_spoon/definition_finder.rb
52
+ - lib/pink_spoon/doc_extractor.rb
53
+ - lib/pink_spoon/rbi_index.rb
54
+ - lib/pink_spoon/server.rb
55
+ - lib/pink_spoon/version.rb
56
+ - lib/ruby_lsp/pink_spoon/addon.rb
57
+ - lib/ruby_lsp/pink_spoon/code_lens_listener.rb
58
+ - lib/ruby_lsp/pink_spoon/completion_listener.rb
59
+ - lib/ruby_lsp/pink_spoon/definition_listener.rb
60
+ - lib/ruby_lsp/pink_spoon/hover_listener.rb
61
+ licenses: []
62
+ metadata: {}
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.1'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.2
78
+ specification_version: 4
79
+ summary: 'ruby-lsp addon: resolves types from Sorbet RBI files'
80
+ test_files: []