type-guessr 0.0.1

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,565 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module TypeGuessr
5
+ # Hover provider for TypeGuessr
6
+ class Hover
7
+ # Core layer shortcuts
8
+ Types = ::TypeGuessr::Core::Types
9
+ private_constant :Types
10
+
11
+ # Define all node types that should trigger hover content
12
+ HOVER_NODE_TYPES = %i[
13
+ local_variable_read
14
+ local_variable_write
15
+ local_variable_target
16
+ instance_variable_read
17
+ instance_variable_write
18
+ instance_variable_target
19
+ class_variable_read
20
+ class_variable_write
21
+ class_variable_target
22
+ global_variable_read
23
+ global_variable_write
24
+ global_variable_target
25
+ required_parameter
26
+ optional_parameter
27
+ rest_parameter
28
+ required_keyword_parameter
29
+ optional_keyword_parameter
30
+ keyword_rest_parameter
31
+ block_parameter
32
+ forwarding_parameter
33
+ call
34
+ def
35
+ self
36
+ ].freeze
37
+
38
+ def initialize(runtime_adapter, response_builder, node_context, dispatcher, global_state)
39
+ @runtime_adapter = runtime_adapter
40
+ @response_builder = response_builder
41
+ @node_context = node_context
42
+ @global_state = global_state
43
+
44
+ register_listeners(dispatcher)
45
+ end
46
+
47
+ # Dynamically define handler methods for each node type
48
+ HOVER_NODE_TYPES.each do |node_type|
49
+ define_method(:"on_#{node_type}_node_enter") do |node|
50
+ add_hover_content(node)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def register_listeners(dispatcher)
57
+ dispatcher.register(
58
+ self,
59
+ *HOVER_NODE_TYPES.map { |type| :"on_#{type}_node_enter" }
60
+ )
61
+ end
62
+
63
+ # Core IR module shortcut
64
+ IR = ::TypeGuessr::Core::IR
65
+ private_constant :IR
66
+
67
+ def add_hover_content(node)
68
+ # Generate node_key from scope and Prism node
69
+ # DefNode is indexed with parent scope (not including the method itself)
70
+ exclude_method = node.is_a?(Prism::DefNode)
71
+ scope_id = generate_scope_id(exclude_method: exclude_method)
72
+ node_hash = generate_node_hash(node)
73
+ return unless node_hash
74
+
75
+ node_key = "#{scope_id}:#{node_hash}"
76
+
77
+ # Find IR node by key (O(1) lookup)
78
+ ir_node = @runtime_adapter.find_node_by_key(node_key)
79
+ return unless ir_node
80
+
81
+ # Handle DefNode specially - show method signature
82
+ if ir_node.is_a?(IR::DefNode)
83
+ add_def_node_hover(ir_node)
84
+ return
85
+ end
86
+
87
+ # Handle CallNode specially - show RBS method signature
88
+ if ir_node.is_a?(IR::CallNode)
89
+ add_call_node_hover(ir_node)
90
+ return
91
+ end
92
+
93
+ # Infer type
94
+ result = @runtime_adapter.infer_type(ir_node)
95
+
96
+ # Format type with definition link if available
97
+ formatted_type = format_type_with_link(result.type)
98
+
99
+ # Build hover content
100
+ content = "**Guessed Type:** #{formatted_type}"
101
+ content += build_debug_info(result, ir_node) if debug_enabled?
102
+
103
+ @response_builder.push(content, category: :documentation)
104
+ rescue StandardError => e
105
+ warn "[TypeGuessr] Error in add_hover_content: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
106
+ end
107
+
108
+ def add_def_node_hover(def_node)
109
+ # Build method signature: (params) -> return_type
110
+ params_str = format_params(def_node.params)
111
+ return_result = @runtime_adapter.infer_type(def_node)
112
+ return_type_str = return_result.type.to_s
113
+
114
+ signature = "(#{params_str}) -> #{return_type_str}"
115
+ content = "**Guessed Signature:** `#{signature}`"
116
+
117
+ content += build_debug_info(return_result) if debug_enabled?
118
+
119
+ @response_builder.push(content, category: :documentation)
120
+ end
121
+
122
+ def add_call_node_hover(call_node)
123
+ # Special case: Handle .new calls to show constructor signature
124
+ # Support both ClassName.new (ConstantNode) and self.new (SelfNode) in singleton methods
125
+ if call_node.method == :new &&
126
+ (call_node.receiver.is_a?(IR::ConstantNode) || call_node.receiver.is_a?(IR::SelfNode))
127
+ add_new_call_hover(call_node)
128
+ return
129
+ end
130
+
131
+ # Get receiver type to look up method signature
132
+ if call_node.receiver
133
+ # For ConstantNode receiver (e.g., File.exist?, RBS::Environment.from_loader),
134
+ # directly create SingletonType without relying on constant_kind_provider
135
+ receiver_type = if call_node.receiver.is_a?(IR::ConstantNode)
136
+ Types::SingletonType.new(call_node.receiver.name)
137
+ else
138
+ @runtime_adapter.infer_type(call_node.receiver).type
139
+ end
140
+
141
+ # Get the class name for signature lookup
142
+ class_name = extract_class_name(receiver_type)
143
+
144
+ if class_name
145
+ # Look up signature via SignatureProvider
146
+ # Use class method lookup for SingletonType (e.g., RBS::Environment.from_loader)
147
+ signatures = if receiver_type.is_a?(Types::SingletonType)
148
+ @runtime_adapter.signature_provider.get_class_method_signatures(
149
+ class_name, call_node.method.to_s
150
+ )
151
+ else
152
+ @runtime_adapter.signature_provider.get_method_signatures(
153
+ class_name, call_node.method.to_s
154
+ )
155
+ end
156
+
157
+ if signatures.any?
158
+ # Format the signature(s)
159
+ sig_strs = signatures.map { |sig| sig.method_type.to_s }
160
+ content = "**Guessed Signature:** `#{sig_strs.first}`"
161
+
162
+ if debug_enabled?
163
+ content += "\n\n**[TypeGuessr Debug]**"
164
+ content += "\n\n**Receiver:** `#{receiver_type}`"
165
+ if sig_strs.size > 1
166
+ content += "\n\n**Overloads:**\n"
167
+ sig_strs.each { |s| content += "- `#{s}`\n" }
168
+ end
169
+ end
170
+
171
+ @response_builder.push(content, category: :documentation)
172
+ return
173
+ end
174
+ end
175
+ end
176
+
177
+ # Fallback: show inferred return type as signature format
178
+ # All method calls should show signature format (not just project methods)
179
+ result = @runtime_adapter.infer_type(call_node)
180
+ type_str = result.type.to_s
181
+
182
+ # Build signature with parameter info from RubyIndexer
183
+ params_str = build_call_signature_params(call_node)
184
+ content = "**Guessed Signature:** `(#{params_str}) -> #{type_str}`"
185
+ content += build_debug_info(result) if debug_enabled?
186
+ @response_builder.push(content, category: :documentation)
187
+ end
188
+
189
+ # Build parameter signature for a method call using RubyIndexer
190
+ def build_call_signature_params(call_node)
191
+ method_entry = lookup_method_entry_for_call(call_node)
192
+
193
+ if method_entry&.signatures&.any?
194
+ format_params_from_entry(method_entry, call_node.args)
195
+ elsif call_node.args&.any?
196
+ format_params_from_args(call_node.args)
197
+ else
198
+ ""
199
+ end
200
+ end
201
+
202
+ # Look up method entry from RubyIndexer based on call node
203
+ def lookup_method_entry_for_call(call_node)
204
+ return nil unless @global_state&.index
205
+ return nil unless call_node.receiver
206
+
207
+ receiver_result = @runtime_adapter.infer_type(call_node.receiver)
208
+
209
+ case receiver_result.type
210
+ when Types::SingletonType
211
+ lookup_class_method_entry(receiver_result.type.name, call_node.method.to_s)
212
+ when Types::ClassInstance
213
+ lookup_instance_method_entry(receiver_result.type.name, call_node.method.to_s)
214
+ end
215
+ end
216
+
217
+ # Format parameters from RubyIndexer method entry with inferred argument types
218
+ def format_params_from_entry(method_entry, args)
219
+ params = method_entry.signatures.first.parameters
220
+ return "" if params.nil? || params.empty?
221
+
222
+ params.each_with_index.map do |param, i|
223
+ arg_type = if args && i < args.size
224
+ @runtime_adapter.infer_type(args[i]).type.to_s
225
+ else
226
+ "untyped"
227
+ end
228
+ format_single_param(param, arg_type)
229
+ end.join(", ")
230
+ end
231
+
232
+ # Format a single parameter based on its type
233
+ def format_single_param(param, arg_type)
234
+ param_name = param.name.to_s
235
+
236
+ case param
237
+ when RubyIndexer::Entry::RequiredParameter
238
+ "#{arg_type} #{param_name}"
239
+ when RubyIndexer::Entry::OptionalParameter
240
+ "?#{arg_type} #{param_name}"
241
+ when RubyIndexer::Entry::RestParameter
242
+ "*#{arg_type} #{param_name}"
243
+ when RubyIndexer::Entry::KeywordParameter
244
+ "#{param_name}: #{arg_type}"
245
+ when RubyIndexer::Entry::OptionalKeywordParameter
246
+ "?#{param_name}: #{arg_type}"
247
+ when RubyIndexer::Entry::KeywordRestParameter
248
+ "**#{arg_type} #{param_name}"
249
+ when RubyIndexer::Entry::BlockParameter
250
+ "&#{param_name}"
251
+ else
252
+ "#{arg_type} #{param_name}"
253
+ end
254
+ end
255
+
256
+ # Format arguments when no method entry is available
257
+ def format_params_from_args(args)
258
+ args.each_with_index.map do |arg, i|
259
+ arg_type = @runtime_adapter.infer_type(arg).type.to_s
260
+ "#{arg_type} arg#{i + 1}"
261
+ end.join(", ")
262
+ end
263
+
264
+ # Look up class method entry from RubyIndexer
265
+ def lookup_class_method_entry(class_name, method_name)
266
+ return nil unless @global_state&.index
267
+
268
+ # Query singleton class for the method
269
+ # Ruby LSP uses unqualified name for singleton class (e.g., "RBS::Environment::<Class:Environment>")
270
+ unqualified_name = class_name.split("::").last
271
+ singleton_name = "#{class_name}::<Class:#{unqualified_name}>"
272
+ entries = @global_state.index.resolve_method(method_name, singleton_name)
273
+ return nil if entries.nil? || entries.empty?
274
+
275
+ entries.first
276
+ rescue RubyIndexer::Index::NonExistingNamespaceError
277
+ nil
278
+ end
279
+
280
+ # Look up instance method entry from RubyIndexer
281
+ def lookup_instance_method_entry(class_name, method_name)
282
+ return nil unless @global_state&.index
283
+
284
+ entries = @global_state.index.resolve_method(method_name, class_name)
285
+ return nil if entries.nil? || entries.empty?
286
+
287
+ entries.first
288
+ rescue RubyIndexer::Index::NonExistingNamespaceError
289
+ nil
290
+ end
291
+
292
+ def extract_class_name(type)
293
+ case type
294
+ when Types::ClassInstance
295
+ type.name
296
+ when Types::SingletonType
297
+ type.name
298
+ when Types::ArrayType
299
+ "Array"
300
+ when Types::HashType, Types::HashShape
301
+ "Hash"
302
+ end
303
+ end
304
+
305
+ # Handle .new calls to show constructor signature
306
+ def add_new_call_hover(call_node)
307
+ # Resolve constant to get class name (handles aliases)
308
+ receiver_result = @runtime_adapter.infer_type(call_node.receiver)
309
+ class_name = case receiver_result.type
310
+ when Types::SingletonType then receiver_result.type.name
311
+ else call_node.receiver.name
312
+ end
313
+
314
+ # Look up initialize method signature
315
+ init_info = lookup_initialize_signature(class_name)
316
+
317
+ content = if init_info
318
+ case init_info[:source]
319
+ when :project
320
+ params_str = format_params(init_info[:params])
321
+ "**Guessed Signature:** `(#{params_str}) -> #{class_name}`"
322
+ when :rbs
323
+ sig_str = init_info[:signature].method_type.to_s
324
+ sig_str = sig_str.sub(/-> .+$/, "-> #{class_name}")
325
+ "**Guessed Signature:** `#{sig_str}`"
326
+ end
327
+ else
328
+ "**Guessed Signature:** `() -> #{class_name}`"
329
+ end
330
+
331
+ content += build_debug_info_for_new(init_info, class_name) if debug_enabled?
332
+
333
+ @response_builder.push(content, category: :documentation)
334
+ end
335
+
336
+ # Look up initialize method signature for a class
337
+ def lookup_initialize_signature(class_name)
338
+ # 1. Try project methods first
339
+ def_node = @runtime_adapter.lookup_method(class_name, "initialize")
340
+ return { params: def_node.params, source: :project } if def_node
341
+
342
+ # 2. Fall back to RBS signatures
343
+ signatures = @runtime_adapter.signature_provider.get_method_signatures(
344
+ class_name, "initialize"
345
+ )
346
+ return { signature: signatures.first, source: :rbs } if signatures.any?
347
+
348
+ nil
349
+ end
350
+
351
+ # Build debug info for .new calls
352
+ def build_debug_info_for_new(init_info, class_name)
353
+ info = "\n\n**[TypeGuessr Debug]**"
354
+ info += "\n\n**Class:** `#{class_name}`"
355
+ info += "\n\n**Source:** #{init_info&.[](:source) || :inferred}"
356
+ info
357
+ end
358
+
359
+ def format_params(params)
360
+ return "" if params.nil? || params.empty?
361
+
362
+ params.map do |param|
363
+ param_type = infer_param_type(param)
364
+ type_str = param_type.to_s
365
+
366
+ case param.kind
367
+ when :required
368
+ "#{type_str} #{param.name}"
369
+ when :optional
370
+ "?#{type_str} #{param.name}"
371
+ when :rest
372
+ "*#{type_str} #{param.name}"
373
+ when :keyword_required
374
+ "#{param.name}: #{type_str}"
375
+ when :keyword_optional
376
+ "#{param.name}: ?#{type_str}"
377
+ when :keyword_rest
378
+ "**#{type_str} #{param.name}"
379
+ when :block
380
+ "&#{type_str} #{param.name}"
381
+ when :forwarding
382
+ "..."
383
+ else
384
+ "#{type_str} #{param.name}"
385
+ end
386
+ end.join(", ")
387
+ end
388
+
389
+ def infer_param_type(param)
390
+ result = @runtime_adapter.infer_type(param)
391
+ result.type
392
+ end
393
+
394
+ def debug_enabled?
395
+ Config.debug?
396
+ end
397
+
398
+ def build_debug_info(result, ir_node = nil)
399
+ info = "\n\n**[TypeGuessr Debug]**"
400
+ info += "\n\n**Reason:** #{result.reason}"
401
+ info += "\n\n**Source:** #{result.source}"
402
+ if ir_node
403
+ called_methods = extract_called_methods(ir_node)
404
+ info += "\n\n**Method calls:** #{called_methods.join(", ")}" if called_methods.any?
405
+ end
406
+ info
407
+ end
408
+
409
+ def extract_called_methods(ir_node)
410
+ case ir_node
411
+ when IR::LocalWriteNode, IR::LocalReadNode,
412
+ IR::InstanceVariableWriteNode, IR::InstanceVariableReadNode,
413
+ IR::ClassVariableWriteNode, IR::ClassVariableReadNode,
414
+ IR::ParamNode
415
+ ir_node.called_methods || []
416
+ when IR::BlockParamSlot
417
+ # For block params, check the underlying param node
418
+ []
419
+ else
420
+ []
421
+ end
422
+ end
423
+
424
+ # Generate scope_id from node_context
425
+ # Format: "ClassName#method_name" or "ClassName" or "#method_name" or ""
426
+ # @param exclude_method [Boolean] Whether to exclude method from scope (for DefNode)
427
+ def generate_scope_id(exclude_method: false)
428
+ class_path = @node_context.nesting.map do |n|
429
+ n.is_a?(String) ? n : n.name.to_s
430
+ end.join("::")
431
+
432
+ method_name = exclude_method ? nil : @node_context.surrounding_method
433
+
434
+ if method_name
435
+ "#{class_path}##{method_name}"
436
+ else
437
+ class_path
438
+ end
439
+ end
440
+
441
+ # Generate node_hash from Prism node to match IR node_hash format
442
+ def generate_node_hash(node)
443
+ line = node.location.start_line
444
+ case node
445
+ when Prism::LocalVariableWriteNode, Prism::LocalVariableTargetNode
446
+ "local_write:#{node.name}:#{line}"
447
+ when Prism::LocalVariableReadNode
448
+ "local_read:#{node.name}:#{line}"
449
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableTargetNode
450
+ "ivar_write:#{node.name}:#{line}"
451
+ when Prism::InstanceVariableReadNode
452
+ "ivar_read:#{node.name}:#{line}"
453
+ when Prism::ClassVariableWriteNode, Prism::ClassVariableTargetNode
454
+ "cvar_write:#{node.name}:#{line}"
455
+ when Prism::ClassVariableReadNode
456
+ "cvar_read:#{node.name}:#{line}"
457
+ when Prism::GlobalVariableWriteNode, Prism::GlobalVariableTargetNode
458
+ "global_write:#{node.name}:#{line}"
459
+ when Prism::GlobalVariableReadNode
460
+ "global_read:#{node.name}:#{line}"
461
+ when Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
462
+ Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode,
463
+ Prism::KeywordRestParameterNode, Prism::BlockParameterNode
464
+ # Check if this is a block parameter (parent is BlockParametersNode)
465
+ if block_parameter?(node)
466
+ index = block_parameter_index(node)
467
+ "bparam:#{index}:#{line}"
468
+ else
469
+ "param:#{node.name}:#{line}"
470
+ end
471
+ when Prism::ForwardingParameterNode
472
+ "param:...:#{line}"
473
+ when Prism::CallNode
474
+ # Use message_loc for accurate line number
475
+ call_line = node.message_loc&.start_line || line
476
+ "call:#{node.name}:#{call_line}"
477
+ when Prism::DefNode
478
+ # Use name_loc for accurate line number
479
+ def_line = node.name_loc&.start_line || line
480
+ "def:#{node.name}:#{def_line}"
481
+ when Prism::SelfNode
482
+ class_path = @node_context.nesting.map do |n|
483
+ n.is_a?(String) ? n : n.name.to_s
484
+ end.join("::")
485
+ "self:#{class_path}:#{line}"
486
+ end
487
+ end
488
+
489
+ # Check if a parameter node is inside a block (not a method definition)
490
+ def block_parameter?(node)
491
+ call_node = @node_context.call_node
492
+ return false unless call_node&.block
493
+
494
+ # Check if this parameter is in the block's parameters
495
+ block_params = call_node.block.parameters&.parameters
496
+ return false unless block_params
497
+
498
+ all_params = collect_block_params(block_params)
499
+ all_params.include?(node)
500
+ end
501
+
502
+ # Get the index of a block parameter
503
+ def block_parameter_index(node)
504
+ call_node = @node_context.call_node
505
+ return 0 unless call_node&.block
506
+
507
+ block_params = call_node.block.parameters&.parameters
508
+ return 0 unless block_params
509
+
510
+ all_params = collect_block_params(block_params)
511
+ all_params.index(node) || 0
512
+ end
513
+
514
+ # Collect all positional parameters from a ParametersNode
515
+ def collect_block_params(params_node)
516
+ all_params = []
517
+ all_params.concat(params_node.requireds || [])
518
+ all_params.concat(params_node.optionals || [])
519
+ all_params << params_node.rest if params_node.rest
520
+ all_params.concat(params_node.posts || [])
521
+ all_params
522
+ end
523
+
524
+ # Format type with definition link if available
525
+ def format_type_with_link(type)
526
+ formatted = type.to_s
527
+
528
+ # Only link ClassInstance types
529
+ return "`#{formatted}`" unless type.is_a?(Types::ClassInstance)
530
+
531
+ # Try to find the class definition in the index
532
+ entry = find_type_entry(type.name)
533
+ return "`#{formatted}`" unless entry
534
+
535
+ location_link = build_location_link(entry)
536
+ return "`#{formatted}`" unless location_link
537
+
538
+ "[`#{formatted}`](#{location_link})"
539
+ end
540
+
541
+ # Find entry for a type name in RubyIndexer
542
+ def find_type_entry(type_name)
543
+ return nil unless @global_state&.index
544
+
545
+ entries = @global_state.index.resolve(type_name, [])
546
+ return nil if entries.nil? || entries.empty?
547
+
548
+ # Return the first class/module entry
549
+ entries.find { |e| e.is_a?(RubyIndexer::Entry::Namespace) }
550
+ end
551
+
552
+ # Build a location link from an entry
553
+ def build_location_link(entry)
554
+ uri = entry.uri
555
+ return nil if uri.nil?
556
+
557
+ location = entry.respond_to?(:name_location) ? entry.name_location : entry.location
558
+ return nil if location.nil?
559
+
560
+ "#{uri}#L#{location.start_line},#{location.start_column + 1}-" \
561
+ "#{location.end_line},#{location.end_column + 1}"
562
+ end
563
+ end
564
+ end
565
+ end