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,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../../type_guessr/core/converter/prism_converter"
5
+ require_relative "../../type_guessr/core/index/location_index"
6
+ require_relative "../../type_guessr/core/registry/method_registry"
7
+ require_relative "../../type_guessr/core/registry/variable_registry"
8
+ require_relative "../../type_guessr/core/inference/resolver"
9
+ require_relative "../../type_guessr/core/signature_provider"
10
+ require_relative "../../type_guessr/core/rbs_provider"
11
+ require_relative "../../type_guessr/core/type_simplifier"
12
+ require_relative "type_inferrer"
13
+
14
+ module RubyLsp
15
+ module TypeGuessr
16
+ # RuntimeAdapter manages the IR graph and inference for TypeGuessr
17
+ # Converts files to IR graphs and provides type inference
18
+ class RuntimeAdapter
19
+ attr_reader :signature_provider
20
+
21
+ def initialize(global_state, message_queue = nil)
22
+ @global_state = global_state
23
+ @message_queue = message_queue
24
+ @converter = ::TypeGuessr::Core::Converter::PrismConverter.new
25
+ @location_index = ::TypeGuessr::Core::Index::LocationIndex.new
26
+ @signature_provider = build_signature_provider
27
+ @indexing_completed = false
28
+ @mutex = Mutex.new
29
+ @original_type_inferrer = nil
30
+
31
+ # Create shared ancestry provider
32
+ ancestry_provider = ->(class_name) { get_class_ancestors(class_name) }
33
+
34
+ # Create method registry with ancestry provider
35
+ @method_registry = ::TypeGuessr::Core::Registry::MethodRegistry.new(
36
+ ancestry_provider: ancestry_provider
37
+ )
38
+
39
+ # Create variable registry with ancestry provider
40
+ @variable_registry = ::TypeGuessr::Core::Registry::VariableRegistry.new(
41
+ ancestry_provider: ancestry_provider
42
+ )
43
+
44
+ # Create resolver with injected registries
45
+ @resolver = ::TypeGuessr::Core::Inference::Resolver.new(
46
+ @signature_provider,
47
+ method_registry: @method_registry,
48
+ variable_registry: @variable_registry
49
+ )
50
+
51
+ # Set up method list resolver callback
52
+ @resolver.method_list_resolver = ->(methods) { resolve_methods_to_class(methods) }
53
+
54
+ # Set up ancestry provider callback
55
+ @resolver.ancestry_provider = ancestry_provider
56
+
57
+ # Set up constant kind provider callback
58
+ @resolver.constant_kind_provider = ->(name) { get_constant_kind(name) }
59
+
60
+ # Set up class method lookup provider callback
61
+ @resolver.class_method_lookup_provider = lambda do |class_name, method_name|
62
+ lookup_class_method_owner(class_name, method_name)
63
+ end
64
+
65
+ # Set up type simplifier with ancestry provider
66
+ @resolver.type_simplifier = ::TypeGuessr::Core::TypeSimplifier.new(
67
+ ancestry_provider: ancestry_provider
68
+ )
69
+ end
70
+
71
+ # Swap ruby-lsp's TypeInferrer with TypeGuessr's custom implementation
72
+ # This enhances Go to Definition and other features with heuristic type inference
73
+ def swap_type_inferrer
74
+ return unless @global_state.respond_to?(:type_inferrer)
75
+
76
+ @original_type_inferrer = @global_state.type_inferrer
77
+ custom_inferrer = TypeInferrer.new(@global_state.index, self)
78
+ @global_state.instance_variable_set(:@type_inferrer, custom_inferrer)
79
+ log_message("TypeInferrer swapped for enhanced type inference")
80
+ rescue StandardError => e
81
+ log_message("Failed to swap TypeInferrer: #{e.message}")
82
+ end
83
+
84
+ # Restore the original TypeInferrer
85
+ def restore_type_inferrer
86
+ return unless @original_type_inferrer
87
+
88
+ @global_state.instance_variable_set(:@type_inferrer, @original_type_inferrer)
89
+ @original_type_inferrer = nil
90
+ log_message("TypeInferrer restored")
91
+ rescue StandardError => e
92
+ log_message("Failed to restore TypeInferrer: #{e.message}")
93
+ end
94
+
95
+ # Index a file by converting its Prism AST to IR graph
96
+ # @param uri [URI::Generic] File URI
97
+ # @param document [RubyLsp::Document] Document to index
98
+ def index_file(uri, document)
99
+ file_path = uri.to_standardized_path
100
+ return unless file_path
101
+
102
+ # Parse and convert to IR outside mutex
103
+ parsed = document.parse_result
104
+ return unless parsed.value
105
+
106
+ # Create a shared context for all statements
107
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new
108
+ nodes = parsed.value.statements&.body&.filter_map do |stmt|
109
+ @converter.convert(stmt, context)
110
+ end
111
+
112
+ @mutex.synchronize do
113
+ # Clear existing index for this file
114
+ @location_index.remove_file(file_path)
115
+ @resolver.clear_cache
116
+
117
+ # Index all nodes recursively with scope tracking
118
+ nodes&.each { |node| index_node_recursively(file_path, node, "") }
119
+
120
+ # Finalize the index for efficient lookups
121
+ @location_index.finalize!
122
+ end
123
+ rescue StandardError => e
124
+ log_message("Error in index_file #{uri}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
125
+ end
126
+
127
+ # Index source code directly (for testing)
128
+ # @param uri_string [String] File URI as string
129
+ # @param source [String] Source code to index
130
+ def index_source(uri_string, source)
131
+ require "uri"
132
+ uri = URI(uri_string)
133
+ file_path = uri.respond_to?(:to_standardized_path) ? uri.to_standardized_path : uri.path
134
+ file_path ||= uri_string.sub(%r{^file://}, "")
135
+ return unless file_path
136
+
137
+ @mutex.synchronize do
138
+ # Clear existing index for this file
139
+ @location_index.remove_file(file_path)
140
+ @resolver.clear_cache
141
+
142
+ # Parse source code
143
+ parsed = Prism.parse(source)
144
+ return unless parsed.value
145
+
146
+ # Create a shared context for all statements
147
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new
148
+
149
+ # Convert statements to IR nodes and index with scope tracking
150
+ parsed.value.statements&.body&.each do |stmt|
151
+ node = @converter.convert(stmt, context)
152
+ index_node_recursively(file_path, node, "") if node
153
+ end
154
+
155
+ # Finalize the index for efficient lookups
156
+ @location_index.finalize!
157
+ end
158
+ end
159
+
160
+ # Find IR node by its unique key
161
+ # @param node_key [String] The node key (scope_id:node_hash)
162
+ # @return [TypeGuessr::Core::IR::Node, nil] IR node or nil if not found
163
+ def find_node_by_key(node_key)
164
+ @mutex.synchronize do
165
+ @location_index.find_by_key(node_key)
166
+ end
167
+ end
168
+
169
+ # Infer type for an IR node
170
+ # @param node [TypeGuessr::Core::IR::Node] IR node
171
+ # @return [TypeGuessr::Core::Inference::Result] Inference result
172
+ def infer_type(node)
173
+ @mutex.synchronize do
174
+ @resolver.infer(node)
175
+ end
176
+ end
177
+
178
+ # Look up a method definition by class name and method name
179
+ # @param class_name [String] Class name (e.g., "User", "Admin::User")
180
+ # @param method_name [String] Method name (e.g., "initialize", "save")
181
+ # @return [TypeGuessr::Core::IR::DefNode, nil] DefNode or nil if not found
182
+ def lookup_method(class_name, method_name)
183
+ @mutex.synchronize do
184
+ @method_registry.lookup(class_name, method_name)
185
+ end
186
+ end
187
+
188
+ # Resolve method list to a type (for use during inference)
189
+ # @param methods [Array<Symbol>] Method names called on the parameter
190
+ # @return [Type, nil] Resolved type or nil if not resolvable
191
+ def resolve_methods_to_class(methods)
192
+ matching_classes = find_classes_defining_methods(methods)
193
+ @resolver.classes_to_type(matching_classes)
194
+ end
195
+
196
+ # Find classes that define all given methods
197
+ def find_classes_defining_methods(methods)
198
+ return [] if methods.empty?
199
+
200
+ index = @global_state.index
201
+ return [] unless index
202
+
203
+ # For each method, find classes that define it using fuzzy_search
204
+ method_sets = methods.map do |method_name|
205
+ entries = index.fuzzy_search(method_name.to_s) do |entry|
206
+ entry.is_a?(RubyIndexer::Entry::Method) && entry.name == method_name.to_s
207
+ end
208
+ entries.filter_map do |entry|
209
+ entry.owner.name if entry.respond_to?(:owner) && entry.owner
210
+ end.uniq
211
+ end
212
+
213
+ return [] if method_sets.empty? || method_sets.any?(&:empty?)
214
+
215
+ # Find intersection - classes that define ALL methods
216
+ method_sets.reduce(:&) || []
217
+ end
218
+
219
+ # Start background indexing of all project files
220
+ def start_indexing
221
+ Thread.new do
222
+ index = @global_state.index
223
+
224
+ # Wait for Ruby LSP's initial indexing to complete
225
+ log_message("Waiting for Ruby LSP initial indexing to complete...")
226
+ sleep(0.1) until index.initial_indexing_completed
227
+ log_message("Ruby LSP indexing completed. Starting TypeGuessr file indexing.")
228
+
229
+ # Get all indexable files (project + gems)
230
+ indexable_uris = index.configuration.indexable_uris
231
+ total = indexable_uris.size
232
+ log_message("Found #{total} files to process.")
233
+
234
+ # Index each file with progress reporting
235
+ processed = 0
236
+ last_report = 0
237
+ report_interval = [total / 10, 50].max
238
+
239
+ indexable_uris.each do |uri|
240
+ traverse_file(uri)
241
+ processed += 1
242
+
243
+ next unless processed - last_report >= report_interval
244
+
245
+ percent = (processed * 100.0 / total).round(1)
246
+ log_message("Indexing progress: #{processed}/#{total} (#{percent}%)")
247
+ last_report = processed
248
+ end
249
+
250
+ # Finalize the index ONCE after all files are processed
251
+ @mutex.synchronize { @location_index.finalize! }
252
+
253
+ log_message("File indexing completed. Processed #{total} files.")
254
+ @indexing_completed = true
255
+ rescue StandardError => e
256
+ log_message("Error during file indexing: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
257
+ @indexing_completed = true
258
+ end
259
+ end
260
+
261
+ # Check if initial indexing has completed
262
+ def indexing_completed?
263
+ @indexing_completed
264
+ end
265
+
266
+ # Get statistics about the index
267
+ # @return [Hash] Statistics
268
+ def stats
269
+ @location_index.stats
270
+ end
271
+
272
+ # Get all registered class names (thread-safe)
273
+ # @return [Array<String>] List of class names
274
+ def registered_classes
275
+ @mutex.synchronize { @method_registry.registered_classes }
276
+ end
277
+
278
+ # Get all methods for a specific class (thread-safe)
279
+ # @param class_name [String] Class name
280
+ # @return [Hash<String, DefNode>] Methods hash
281
+ def methods_for_class(class_name)
282
+ @mutex.synchronize { @method_registry.methods_for_class(class_name) }
283
+ end
284
+
285
+ # Search for methods matching a pattern (thread-safe)
286
+ # @param query [String] Search query (e.g., "User#save" or "save")
287
+ # @return [Array<Hash>] Array of method info hashes
288
+ def search_project_methods(query)
289
+ @mutex.synchronize do
290
+ @method_registry.search(query).map do |class_name, method_name, def_node|
291
+ {
292
+ class_name: class_name,
293
+ method_name: method_name,
294
+ full_name: "#{class_name}##{method_name}",
295
+ node_key: def_node.node_key(class_name),
296
+ location: { line: def_node.loc&.line }
297
+ }
298
+ end
299
+ end
300
+ end
301
+
302
+ private
303
+
304
+ # Traverse and index a single file
305
+ def traverse_file(uri)
306
+ file_path = uri.to_standardized_path
307
+ return unless file_path && File.exist?(file_path)
308
+
309
+ # Parse outside mutex (CPU-bound, no shared state)
310
+ source = File.read(file_path)
311
+ parsed = Prism.parse(source)
312
+ return unless parsed.value
313
+
314
+ # Create context and convert nodes outside mutex
315
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new
316
+ nodes = parsed.value.statements&.body&.filter_map do |stmt|
317
+ @converter.convert(stmt, context)
318
+ end
319
+
320
+ # Only hold mutex while modifying shared state
321
+ @mutex.synchronize do
322
+ @location_index.remove_file(file_path)
323
+ nodes&.each { |node| index_node_recursively(file_path, node, "") }
324
+ end
325
+ # NOTE: finalize! is called once after ALL files are indexed in start_indexing
326
+ rescue StandardError => e
327
+ bt = e.backtrace&.first(5)&.join("\n") || "(no backtrace)"
328
+ log_message("Error indexing #{uri}: #{e.class}: #{e.message}\n#{bt}")
329
+ end
330
+
331
+ # Recursively index a node and all its children with scope tracking
332
+ # @param file_path [String] Absolute file path
333
+ # @param node [TypeGuessr::Core::IR::Node] IR node to index
334
+ # @param scope_id [String] Current scope identifier (e.g., "User#save")
335
+ def index_node_recursively(file_path, node, scope_id)
336
+ return unless node
337
+
338
+ case node
339
+ when ::TypeGuessr::Core::IR::DefNode
340
+ index_def_node(file_path, node, scope_id)
341
+
342
+ when ::TypeGuessr::Core::IR::ClassModuleNode
343
+ index_class_module_node(file_path, node, scope_id)
344
+
345
+ when ::TypeGuessr::Core::IR::CallNode
346
+ @location_index.add(file_path, node, scope_id)
347
+ index_node_recursively(file_path, node.receiver, scope_id) if node.receiver
348
+ node.args&.each { |arg| index_node_recursively(file_path, arg, scope_id) }
349
+ node.block_params&.each { |param| index_node_recursively(file_path, param, scope_id) }
350
+ index_node_recursively(file_path, node.block_body, scope_id) if node.block_body
351
+
352
+ when ::TypeGuessr::Core::IR::MergeNode
353
+ @location_index.add(file_path, node, scope_id)
354
+ node.branches&.each { |branch| index_node_recursively(file_path, branch, scope_id) }
355
+
356
+ when ::TypeGuessr::Core::IR::InstanceVariableWriteNode
357
+ @location_index.add(file_path, node, scope_id)
358
+ @variable_registry.register_instance_variable(node.class_name, node.name, node) if node.class_name
359
+ index_node_recursively(file_path, node.value, scope_id) if node.value
360
+
361
+ when ::TypeGuessr::Core::IR::ClassVariableWriteNode
362
+ @location_index.add(file_path, node, scope_id)
363
+ @variable_registry.register_class_variable(node.class_name, node.name, node) if node.class_name
364
+ index_node_recursively(file_path, node.value, scope_id) if node.value
365
+
366
+ when ::TypeGuessr::Core::IR::LocalWriteNode
367
+ @location_index.add(file_path, node, scope_id)
368
+ index_node_recursively(file_path, node.value, scope_id) if node.value
369
+
370
+ when ::TypeGuessr::Core::IR::ParamNode
371
+ @location_index.add(file_path, node, scope_id)
372
+ index_node_recursively(file_path, node.default_value, scope_id) if node.default_value
373
+
374
+ when ::TypeGuessr::Core::IR::ReturnNode
375
+ @location_index.add(file_path, node, scope_id)
376
+ index_node_recursively(file_path, node.value, scope_id) if node.value
377
+
378
+ when ::TypeGuessr::Core::IR::ConstantNode
379
+ @location_index.add(file_path, node, scope_id)
380
+ index_node_recursively(file_path, node.dependency, scope_id) if node.dependency
381
+
382
+ when ::TypeGuessr::Core::IR::LiteralNode
383
+ @location_index.add(file_path, node, scope_id)
384
+ # Index value nodes (e.g., variable references in arrays/hashes/keyword args)
385
+ node.values&.each { |value| index_node_recursively(file_path, value, scope_id) }
386
+
387
+ else
388
+ # LocalReadNode, InstanceVariableReadNode, ClassVariableReadNode, etc.
389
+ @location_index.add(file_path, node, scope_id)
390
+ end
391
+ end
392
+
393
+ def index_def_node(file_path, node, scope_id)
394
+ method_scope = singleton_scope_for(scope_id, singleton: node.singleton)
395
+ @location_index.add(file_path, node, method_scope)
396
+
397
+ new_scope = method_scope.empty? ? "##{node.name}" : "#{method_scope}##{node.name}"
398
+ @method_registry.register("", node.name.to_s, node) if scope_id.empty?
399
+
400
+ node.params&.each { |param| index_node_recursively(file_path, param, new_scope) }
401
+ node.body_nodes&.each { |body_node| index_node_recursively(file_path, body_node, new_scope) }
402
+ end
403
+
404
+ def index_class_module_node(file_path, node, scope_id)
405
+ @location_index.add(file_path, node, scope_id)
406
+
407
+ new_scope = scope_id.empty? ? node.name : "#{scope_id}::#{node.name}"
408
+
409
+ node.methods&.each do |method|
410
+ if method.is_a?(::TypeGuessr::Core::IR::ClassModuleNode)
411
+ index_node_recursively(file_path, method, new_scope)
412
+ else
413
+ index_node_recursively(file_path, method, new_scope)
414
+
415
+ method_scope = singleton_scope_for(new_scope, singleton: method.singleton)
416
+ @method_registry.register(method_scope, method.name.to_s, method)
417
+ end
418
+ end
419
+ end
420
+
421
+ # Build SignatureProvider with configured type sources
422
+ # Currently uses RBSProvider for stdlib types
423
+ # Can be extended to add project RBS, Sorbet, etc.
424
+ def build_signature_provider
425
+ provider = ::TypeGuessr::Core::SignatureProvider.new
426
+
427
+ # Add stdlib RBS provider (lowest priority)
428
+ provider.add_provider(::TypeGuessr::Core::RBSProvider.instance)
429
+
430
+ # Future: Add project RBS provider (high priority)
431
+ # provider.add_provider(ProjectRBSProvider.new, priority: :high)
432
+
433
+ provider
434
+ end
435
+
436
+ # Build singleton class scope for method registration/lookup
437
+ # Singleton methods use "<Class:ClassName>" suffix to match RubyIndexer convention
438
+ # @param scope [String] Base scope (e.g., "RBS::Environment2")
439
+ # @param singleton [Boolean] Whether the method is a singleton method
440
+ # @return [String] Scope with singleton class suffix if applicable
441
+ def singleton_scope_for(scope, singleton:)
442
+ return scope unless singleton
443
+
444
+ parent_name = scope.split("::").last || "Object"
445
+ scope.empty? ? "<Class:Object>" : "#{scope}::<Class:#{parent_name}>"
446
+ end
447
+
448
+ # Get class ancestors from RubyIndexer
449
+ # @param class_name [String] Class name
450
+ # @return [Array<String>] Array of ancestor names
451
+ def get_class_ancestors(class_name)
452
+ return [] unless @global_state.index
453
+
454
+ @global_state.index.linearized_ancestors_of(class_name)
455
+ rescue RubyIndexer::Index::NonExistingNamespaceError
456
+ []
457
+ end
458
+
459
+ # Get constant kind from RubyIndexer
460
+ # @param constant_name [String] Constant name
461
+ # @return [Symbol, nil] :class, :module, or nil
462
+ def get_constant_kind(constant_name)
463
+ return nil unless @global_state.index
464
+
465
+ entries = @global_state.index[constant_name]
466
+ return nil if entries.nil? || entries.empty?
467
+
468
+ entry = entries.first
469
+ case entry
470
+ when RubyIndexer::Entry::Class then :class
471
+ when RubyIndexer::Entry::Module then :module
472
+ end
473
+ end
474
+
475
+ # Look up class method owner via RubyIndexer singleton class ancestry
476
+ # @param class_name [String] Class name (e.g., "C")
477
+ # @param method_name [String] Method name (e.g., "foo")
478
+ # @return [String, nil] Owner name (module or singleton class name) or nil
479
+ def lookup_class_method_owner(class_name, method_name)
480
+ return nil unless @global_state.index
481
+
482
+ # Query singleton class (e.g., "C::<Class:C>") for the method
483
+ # Ruby LSP uses unqualified name for singleton class (e.g., "RBS::Environment::<Class:Environment>")
484
+ unqualified_name = class_name.split("::").last
485
+ singleton_name = "#{class_name}::<Class:#{unqualified_name}>"
486
+ entries = @global_state.index.resolve_method(method_name, singleton_name)
487
+ return nil if entries.nil? || entries.empty?
488
+
489
+ entry = entries.first
490
+ entry.owner&.name
491
+ rescue RubyIndexer::Index::NonExistingNamespaceError
492
+ nil
493
+ end
494
+
495
+ def log_message(message)
496
+ return unless @message_queue
497
+ return if @message_queue.closed?
498
+
499
+ @message_queue << RubyLsp::Notification.window_log_message(
500
+ "[TypeGuessr] #{message}",
501
+ type: RubyLsp::Constant::MessageType::LOG
502
+ )
503
+ end
504
+ end
505
+ end
506
+ end