type-guessr 0.0.1 → 0.0.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -0
  3. data/exe/type-guessr +30 -0
  4. data/lib/ruby_lsp/type_guessr/addon.rb +20 -45
  5. data/lib/ruby_lsp/type_guessr/code_index_adapter.rb +352 -0
  6. data/lib/ruby_lsp/type_guessr/constants.rb +39 -0
  7. data/lib/ruby_lsp/type_guessr/{graph_builder.rb → debug_graph_builder.rb} +27 -22
  8. data/lib/ruby_lsp/type_guessr/debug_server.rb +20 -17
  9. data/lib/ruby_lsp/type_guessr/dsl/activerecord_adapter.rb +404 -0
  10. data/lib/ruby_lsp/type_guessr/dsl/ar_schema_watcher.rb +96 -0
  11. data/lib/ruby_lsp/type_guessr/dsl/ar_type_mapper.rb +51 -0
  12. data/lib/ruby_lsp/type_guessr/dsl.rb +3 -0
  13. data/lib/ruby_lsp/type_guessr/dsl_type_registrar.rb +60 -0
  14. data/lib/ruby_lsp/type_guessr/hover.rb +129 -261
  15. data/lib/ruby_lsp/type_guessr/rails_server_addon.rb +83 -0
  16. data/lib/ruby_lsp/type_guessr/runtime_adapter.rb +613 -277
  17. data/lib/ruby_lsp/type_guessr/type_inferrer.rb +8 -105
  18. data/lib/type-guessr.rb +3 -11
  19. data/lib/type_guessr/core/cache/gem_dependency_resolver.rb +113 -0
  20. data/lib/type_guessr/core/cache/gem_signature_cache.rb +98 -0
  21. data/lib/type_guessr/core/cache/gem_signature_extractor.rb +87 -0
  22. data/lib/type_guessr/core/cache.rb +5 -0
  23. data/lib/{ruby_lsp/type_guessr → type_guessr/core}/config.rb +19 -34
  24. data/lib/type_guessr/core/converter/call_converter.rb +161 -0
  25. data/lib/type_guessr/core/converter/container_mutation_converter.rb +241 -0
  26. data/lib/type_guessr/core/converter/context.rb +144 -0
  27. data/lib/type_guessr/core/converter/control_flow_converter.rb +425 -0
  28. data/lib/type_guessr/core/converter/definition_converter.rb +246 -0
  29. data/lib/type_guessr/core/converter/literal_converter.rb +217 -0
  30. data/lib/type_guessr/core/converter/prism_converter.rb +154 -1613
  31. data/lib/type_guessr/core/converter/rbs_converter.rb +35 -14
  32. data/lib/type_guessr/core/converter/registration.rb +100 -0
  33. data/lib/type_guessr/core/converter/variable_converter.rb +225 -0
  34. data/lib/type_guessr/core/converter.rb +4 -0
  35. data/lib/type_guessr/core/index/location_index.rb +32 -0
  36. data/lib/type_guessr/core/index.rb +3 -0
  37. data/lib/type_guessr/core/inference/resolver.rb +516 -349
  38. data/lib/type_guessr/core/inference.rb +4 -0
  39. data/lib/type_guessr/core/ir/nodes.rb +362 -103
  40. data/lib/type_guessr/core/ir.rb +3 -0
  41. data/lib/type_guessr/core/logger.rb +6 -13
  42. data/lib/type_guessr/core/node_context_helper.rb +126 -0
  43. data/lib/type_guessr/core/node_key_generator.rb +31 -0
  44. data/lib/type_guessr/core/registry/class_variable_registry.rb +63 -0
  45. data/lib/type_guessr/core/registry/instance_variable_registry.rb +84 -0
  46. data/lib/type_guessr/core/registry/method_registry.rb +65 -38
  47. data/lib/type_guessr/core/registry/signature_registry.rb +543 -0
  48. data/lib/type_guessr/core/registry.rb +6 -0
  49. data/lib/type_guessr/core/signature_builder.rb +39 -0
  50. data/lib/type_guessr/core/type_serializer.rb +96 -0
  51. data/lib/type_guessr/core/type_simplifier.rb +15 -12
  52. data/lib/type_guessr/core/types.rb +250 -32
  53. data/lib/type_guessr/core.rb +29 -0
  54. data/lib/type_guessr/mcp/file_watcher.rb +87 -0
  55. data/lib/type_guessr/mcp/server.rb +463 -0
  56. data/lib/type_guessr/mcp/standalone_runtime.rb +213 -0
  57. data/lib/type_guessr/version.rb +1 -1
  58. metadata +57 -8
  59. data/lib/type_guessr/core/rbs_provider.rb +0 -304
  60. data/lib/type_guessr/core/registry/variable_registry.rb +0 -87
  61. data/lib/type_guessr/core/signature_provider.rb +0 -101
@@ -1,14 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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"
4
+ require_relative "../../type_guessr/core"
5
+ require_relative "dsl_type_registrar"
6
+ require_relative "code_index_adapter"
12
7
  require_relative "type_inferrer"
13
8
 
14
9
  module RubyLsp
@@ -16,56 +11,52 @@ module RubyLsp
16
11
  # RuntimeAdapter manages the IR graph and inference for TypeGuessr
17
12
  # Converts files to IR graphs and provides type inference
18
13
  class RuntimeAdapter
19
- attr_reader :signature_provider
14
+ attr_reader :signature_registry, :location_index, :resolver, :method_registry
20
15
 
21
16
  def initialize(global_state, message_queue = nil)
22
17
  @global_state = global_state
23
18
  @message_queue = message_queue
24
19
  @converter = ::TypeGuessr::Core::Converter::PrismConverter.new
25
20
  @location_index = ::TypeGuessr::Core::Index::LocationIndex.new
26
- @signature_provider = build_signature_provider
27
21
  @indexing_completed = false
28
22
  @mutex = Mutex.new
29
23
  @original_type_inferrer = nil
30
24
 
31
- # Create shared ancestry provider
32
- ancestry_provider = ->(class_name) { get_class_ancestors(class_name) }
25
+ # Create CodeIndexAdapter wrapping RubyIndexer
26
+ @code_index = CodeIndexAdapter.new(global_state&.index)
33
27
 
34
- # Create method registry with ancestry provider
28
+ # Create SignatureRegistry with code_index for ancestor chain traversal
29
+ @signature_registry = ::TypeGuessr::Core::Registry::SignatureRegistry.new(code_index: @code_index)
30
+ ::TypeGuessr::Core::Registry::SignatureRegistry.instance = @signature_registry
31
+
32
+ # Create method registry with code_index for inheritance lookup
35
33
  @method_registry = ::TypeGuessr::Core::Registry::MethodRegistry.new(
36
- ancestry_provider: ancestry_provider
34
+ code_index: @code_index
35
+ )
36
+
37
+ # Create variable registries (ivar needs code_index for inheritance lookup)
38
+ @ivar_registry = ::TypeGuessr::Core::Registry::InstanceVariableRegistry.new(
39
+ code_index: @code_index
37
40
  )
41
+ @cvar_registry = ::TypeGuessr::Core::Registry::ClassVariableRegistry.new
38
42
 
39
- # Create variable registry with ancestry provider
40
- @variable_registry = ::TypeGuessr::Core::Registry::VariableRegistry.new(
41
- ancestry_provider: ancestry_provider
43
+ # Create type simplifier with code_index for inheritance lookup
44
+ type_simplifier = ::TypeGuessr::Core::TypeSimplifier.new(
45
+ code_index: @code_index
42
46
  )
43
47
 
44
- # Create resolver with injected registries
48
+ # Create resolver with signature_registry and registries
45
49
  @resolver = ::TypeGuessr::Core::Inference::Resolver.new(
46
- @signature_provider,
50
+ @signature_registry,
51
+ code_index: @code_index,
47
52
  method_registry: @method_registry,
48
- variable_registry: @variable_registry
53
+ ivar_registry: @ivar_registry,
54
+ cvar_registry: @cvar_registry,
55
+ type_simplifier: type_simplifier
49
56
  )
50
57
 
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
- )
58
+ # Build method signatures from DefNodes using resolver
59
+ @signature_builder = ::TypeGuessr::Core::SignatureBuilder.new(@resolver)
69
60
  end
70
61
 
71
62
  # Swap ruby-lsp's TypeInferrer with TypeGuessr's custom implementation
@@ -99,27 +90,9 @@ module RubyLsp
99
90
  file_path = uri.to_standardized_path
100
91
  return unless file_path
101
92
 
102
- # Parse and convert to IR outside mutex
103
93
  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
94
 
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
95
+ index_file_with_prism_result(file_path, parsed)
123
96
  rescue StandardError => e
124
97
  log_message("Error in index_file #{uri}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
125
98
  end
@@ -134,26 +107,26 @@ module RubyLsp
134
107
  file_path ||= uri_string.sub(%r{^file://}, "")
135
108
  return unless file_path
136
109
 
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
110
+ parsed = Prism.parse(source)
145
111
 
146
- # Create a shared context for all statements
147
- context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new
112
+ index_file_with_prism_result(file_path, parsed)
113
+ end
148
114
 
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
115
+ # Build member_index for duck type resolution (exposed for testing)
116
+ def build_member_index!
117
+ @code_index.build_member_index!
118
+ end
154
119
 
155
- # Finalize the index for efficient lookups
156
- @location_index.finalize!
120
+ # Remove indexed data for a file
121
+ # @param file_path [String] File path to remove
122
+ def remove_indexed_file(file_path)
123
+ @mutex.synchronize do
124
+ @location_index.remove_file(file_path)
125
+ @method_registry.remove_file(file_path)
126
+ @ivar_registry.remove_file(file_path)
127
+ @cvar_registry.remove_file(file_path)
128
+ @resolver.clear_cache
129
+ @code_index.refresh_member_index!(URI::Generic.from_path(path: file_path))
157
130
  end
158
131
  end
159
132
 
@@ -175,6 +148,51 @@ module RubyLsp
175
148
  end
176
149
  end
177
150
 
151
+ # Build a MethodSignature from a DefNode
152
+ # @param def_node [TypeGuessr::Core::IR::DefNode] Method definition node
153
+ # @return [TypeGuessr::Core::Types::MethodSignature] Structured method signature
154
+ def build_method_signature(def_node)
155
+ @mutex.synchronize do
156
+ @signature_builder.build_from_def_node(def_node)
157
+ end
158
+ end
159
+
160
+ # Build a constructor signature for Class.new calls
161
+ # Maps .new to #initialize and returns ClassName instance
162
+ # Checks project methods first, then falls back to RBS
163
+ # @param class_name [String] Class name (e.g., "User")
164
+ # @return [Hash] { signature: MethodSignature, source: :project | :rbs | :default }
165
+ def build_constructor_signature(class_name)
166
+ @mutex.synchronize do
167
+ instance_type = ::TypeGuessr::Core::Types::ClassInstance.for(class_name)
168
+
169
+ # 1. Try project methods first
170
+ init_def = @method_registry.lookup(class_name, "initialize")
171
+ if init_def
172
+ sig = @signature_builder.build_from_def_node(init_def)
173
+ return {
174
+ signature: ::TypeGuessr::Core::Types::MethodSignature.new(sig.params, instance_type),
175
+ source: :project
176
+ }
177
+ end
178
+
179
+ # 2. Fall back to RBS
180
+ rbs_sigs = @signature_registry.get_method_signatures(class_name, "initialize")
181
+ if rbs_sigs.any?
182
+ return {
183
+ rbs_signature: rbs_sigs.first,
184
+ source: :rbs
185
+ }
186
+ end
187
+
188
+ # 3. Default: no initialize found
189
+ {
190
+ signature: ::TypeGuessr::Core::Types::MethodSignature.new([], instance_type),
191
+ source: :default
192
+ }
193
+ end
194
+ end
195
+
178
196
  # Look up a method definition by class name and method name
179
197
  # @param class_name [String] Class name (e.g., "User", "Admin::User")
180
198
  # @param method_name [String] Method name (e.g., "initialize", "save")
@@ -185,35 +203,17 @@ module RubyLsp
185
203
  end
186
204
  end
187
205
 
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)
206
+ # Check if a method should skip stdlib/gem RBS lookup in hover.
207
+ # Returns true for entries registered by DSL adapters (e.g., AR column accessor)
208
+ # so that hover goes directly to the resolver fallback instead of showing RBS signatures.
209
+ def skip_stdlib_rbs_method?(class_name, method_name)
210
+ entry = @signature_registry.lookup(class_name, method_name)
211
+ entry.is_a?(::TypeGuessr::Core::Registry::SignatureRegistry::GemMethodEntry) && entry.skip_stdlib_rbs?
194
212
  end
195
213
 
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(:&) || []
214
+ def skip_stdlib_rbs_class_method?(class_name, method_name)
215
+ entry = @signature_registry.lookup_class_method(class_name, method_name)
216
+ entry.is_a?(::TypeGuessr::Core::Registry::SignatureRegistry::GemMethodEntry) && entry.skip_stdlib_rbs?
217
217
  end
218
218
 
219
219
  # Start background indexing of all project files
@@ -224,34 +224,48 @@ module RubyLsp
224
224
  # Wait for Ruby LSP's initial indexing to complete
225
225
  log_message("Waiting for Ruby LSP initial indexing to complete...")
226
226
  sleep(0.1) until index.initial_indexing_completed
227
- log_message("Ruby LSP indexing completed. Starting TypeGuessr file indexing.")
227
+ log_message("Ruby LSP indexing completed.")
228
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.")
229
+ # Preload RBS signatures while waiting for other addons to finish
230
+ @signature_registry.preload
233
231
 
234
- # Index each file with progress reporting
235
- processed = 0
236
- last_report = 0
237
- report_interval = [total / 10, 50].max
232
+ # Wait for other addons (ruby-lsp-rails, etc.) to finish registering entries
233
+ wait_for_index_stabilization(index)
238
234
 
239
- indexable_uris.each do |uri|
240
- traverse_file(uri)
241
- processed += 1
235
+ # Build member_index AFTER all entries are registered
236
+ @code_index.build_member_index!
237
+ log_message("Member index built.")
242
238
 
243
- next unless processed - last_report >= report_interval
239
+ # Get all indexable files (project + gems)
240
+ indexable_uris = index.configuration.indexable_uris
241
+ file_paths = indexable_uris.filter_map(&:to_standardized_path)
242
+ total = file_paths.size
243
+ log_message("Found #{total} files to process.")
244
244
 
245
- percent = (processed * 100.0 / total).round(1)
246
- log_message("Indexing progress: #{processed}/#{total} (#{percent}%)")
247
- last_report = processed
245
+ # Try cache-first flow if Gemfile.lock exists
246
+ lockfile_path = find_lockfile
247
+ result = nil
248
+ if lockfile_path
249
+ result = index_with_gem_cache(file_paths, lockfile_path)
250
+ else
251
+ index_all_files(file_paths)
248
252
  end
253
+ # Connect on-demand inference callback for Unguessed gem methods
254
+ @signature_registry.on_demand_inferrer = method(:infer_gem_file_on_demand)
249
255
 
250
- # Finalize the index ONCE after all files are processed
251
- @mutex.synchronize { @location_index.finalize! }
256
+ # Register DSL types (AR column accessors, enums, associations, scopes)
257
+ register_dsl_types
252
258
 
253
- log_message("File indexing completed. Processed #{total} files.")
254
259
  @indexing_completed = true
260
+
261
+ # Start schema watch loop for auto-refresh
262
+ start_schema_watch_loop
263
+
264
+ # Background inference: fully infer gems that have Unguessed entries (opt-in)
265
+ if ::TypeGuessr::Core::Config.background_gem_indexing? && result && result[:unguessed_gems].any?
266
+ background_infer_gems(result[:unguessed_gems],
267
+ result[:cache])
268
+ end
255
269
  rescue StandardError => e
256
270
  log_message("Error during file indexing: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
257
271
  @indexing_completed = true
@@ -269,12 +283,6 @@ module RubyLsp
269
283
  @location_index.stats
270
284
  end
271
285
 
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
286
  # Get all methods for a specific class (thread-safe)
279
287
  # @param class_name [String] Class name
280
288
  # @return [Hash<String, DefNode>] Methods hash
@@ -299,200 +307,528 @@ module RubyLsp
299
307
  end
300
308
  end
301
309
 
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)
310
+ # Resolve a short constant name to fully qualified name
311
+ # @param short_name [String] Short constant name
312
+ # @param nesting [Array<String>] Nesting context
313
+ # @return [String, nil] Fully qualified name or nil
314
+ def resolve_constant_name(short_name, nesting)
315
+ @code_index&.resolve_constant_name(short_name, nesting)
316
+ end
308
317
 
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
318
+ # Look up RBS method signatures with owner resolution
319
+ # Finds the actual class that defines the method (e.g., Object for #tap)
320
+ # @param class_name [String] Receiver class name
321
+ # @param method_name [String] Method name
322
+ # @return [Hash] { signatures: Array<Signature>, owner: String }
323
+ def get_rbs_method_signatures(class_name, method_name)
324
+ @mutex.synchronize do
325
+ # Find actual owner class (e.g., Object for tap on MyClass)
326
+ owner_class = @code_index&.instance_method_owner(class_name, method_name) || class_name
313
327
 
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)
328
+ signatures = @signature_registry.get_method_signatures(owner_class, method_name)
329
+ { signatures: signatures, owner: owner_class }
318
330
  end
331
+ end
319
332
 
320
- # Only hold mutex while modifying shared state
333
+ # Look up RBS class method signatures with owner resolution
334
+ # @param class_name [String] Class name
335
+ # @param method_name [String] Method name
336
+ # @return [Hash] { signatures: Array<Signature>, owner: String }
337
+ def get_rbs_class_method_signatures(class_name, method_name)
321
338
  @mutex.synchronize do
322
- @location_index.remove_file(file_path)
323
- nodes&.each { |node| index_node_recursively(file_path, node, "") }
339
+ # Find actual owner class for class methods
340
+ owner_class = @code_index&.class_method_owner(class_name, method_name) || class_name
341
+
342
+ # Convert singleton format (e.g., "File::<Class:File>") to simple class name ("File")
343
+ # SignatureRegistry expects simple class names for RBS lookup
344
+ owner_class = extract_class_from_singleton(owner_class)
345
+
346
+ signatures = @signature_registry.get_class_method_signatures(owner_class, method_name)
347
+ { signatures: signatures, owner: owner_class }
324
348
  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) }
349
+ end
350
+
351
+ # Cache-first indexing: process gems with cache, then project files
352
+ # @return [Hash] { cache:, unguessed_gems: [...] }
353
+ private def index_with_gem_cache(file_paths, lockfile_path)
354
+ resolver_class = ::TypeGuessr::Core::Cache::GemDependencyResolver
355
+ dep_resolver = resolver_class.new(lockfile_path)
356
+ partitioned = dep_resolver.partition(file_paths)
357
+ gems = partitioned[:gems]
358
+ project_files = partitioned[:project_files]
359
+
360
+ log_message("Partitioned: #{gems.size} gems, #{project_files.size} project files.")
361
+
362
+ cache = ::TypeGuessr::Core::Cache::GemSignatureCache.new
363
+ ordered = dep_resolver.topological_order(gems.keys)
364
+
365
+ # Process each gem in dependency order, collecting those needing background inference
366
+ unguessed_gems = []
367
+ ordered.each do |gem_name|
368
+ gem_info = gems[gem_name]
369
+ needs_inference = process_gem(gem_name, gem_info, cache)
370
+ unguessed_gems << { name: gem_name, info: gem_info } if needs_inference
371
+ end
372
+
373
+ # Index project files into main registries
374
+ index_all_files(project_files)
375
+
376
+ { cache: cache, unguessed_gems: unguessed_gems }
377
+ end
378
+
379
+ # Process a single gem: cache hit → load, cache miss → save unguessed cache
380
+ # @return [Boolean] Whether background inference is needed for this gem
381
+ private def process_gem(gem_name, gem_info, cache)
382
+ version = gem_info[:version]
383
+ deps = gem_info[:transitive_deps]
384
+ file_count = gem_info[:files].size
385
+
386
+ log_message("Processing #{gem_name}-#{version} (#{file_count} files)...")
386
387
 
388
+ if cache.cached?(gem_name, version, deps)
389
+ data = load_gem_from_cache(gem_name, version, deps, cache)
390
+ return false unless data
391
+
392
+ # Previously timed out — don't retry background inference
393
+ return false if data["inference_timeout"]
394
+
395
+ !data["fully_inferred"]
387
396
  else
388
- # LocalReadNode, InstanceVariableReadNode, ClassVariableReadNode, etc.
389
- @location_index.add(file_path, node, scope_id)
397
+ save_unguessed_cache(gem_name, gem_info, cache)
398
+ true
399
+ end
400
+ end
401
+
402
+ # Load gem signatures from disk cache into SignatureRegistry
403
+ # @return [Hash, nil] Loaded cache data or nil on failure
404
+ private def load_gem_from_cache(gem_name, version, deps, cache)
405
+ data = cache.load(gem_name, version, deps)
406
+ unless data
407
+ log_message("Cache corrupt for #{gem_name}-#{version}, skipping.")
408
+ return nil
390
409
  end
410
+
411
+ @signature_registry.load_gem_cache(data["instance_methods"], kind: :instance)
412
+ @signature_registry.load_gem_cache(data["class_methods"], kind: :class)
413
+ log_message(
414
+ "Loaded cached signatures for #{gem_name}-#{version} " \
415
+ "(fully_inferred=#{data["fully_inferred"]}, inference_timeout=#{data["inference_timeout"]})."
416
+ )
417
+ data
391
418
  end
392
419
 
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)
420
+ # Infer gem method signatures using temporary registries, then cache
421
+ # @return [Hash, nil] { instance_methods:, class_methods: } or nil on error
422
+ private def infer_and_cache_gem(gem_name, gem_info, cache)
423
+ version = gem_info[:version]
424
+ files = gem_info[:files]
425
+ deps = gem_info[:transitive_deps]
426
+
427
+ # Phase A: Parse + IR conversion
428
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
429
+
430
+ temp_location_index = ::TypeGuessr::Core::Index::LocationIndex.new
431
+ temp_method_registry = ::TypeGuessr::Core::Registry::MethodRegistry.new(code_index: @code_index)
432
+ temp_ivar_registry = ::TypeGuessr::Core::Registry::InstanceVariableRegistry.new(code_index: @code_index)
433
+ temp_cvar_registry = ::TypeGuessr::Core::Registry::ClassVariableRegistry.new
434
+
435
+ converter = ::TypeGuessr::Core::Converter::PrismConverter.new
436
+ files.each do |file_path|
437
+ parse_and_index_file(file_path, converter, temp_location_index,
438
+ temp_method_registry, temp_ivar_registry, temp_cvar_registry)
439
+ end
440
+ temp_location_index.finalize!
441
+
442
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
443
+
444
+ # Phase B: Inference (extract signatures)
445
+ type_simplifier = ::TypeGuessr::Core::TypeSimplifier.new(code_index: @code_index)
446
+ temp_resolver = ::TypeGuessr::Core::Inference::Resolver.new(
447
+ @signature_registry,
448
+ code_index: @code_index,
449
+ method_registry: temp_method_registry,
450
+ ivar_registry: temp_ivar_registry,
451
+ cvar_registry: temp_cvar_registry,
452
+ type_simplifier: type_simplifier
453
+ )
454
+ temp_builder = ::TypeGuessr::Core::SignatureBuilder.new(temp_resolver)
455
+
456
+ extractor = ::TypeGuessr::Core::Cache::GemSignatureExtractor.new(
457
+ signature_builder: temp_builder,
458
+ method_registry: temp_method_registry,
459
+ location_index: temp_location_index
460
+ )
461
+ timeout = ::TypeGuessr::Core::Config.gem_inference_timeout
462
+ signatures = extractor.extract(files, timeout: timeout)
463
+
464
+ t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
465
+
466
+ # Inference timed out — save as unguessed with timeout flag
467
+ unless signatures
468
+ log_message(
469
+ "Inference timeout for #{gem_name}-#{version} " \
470
+ "(#{files.size} files, parse=#{(t1 - t0).round(2)}s, " \
471
+ "infer>#{timeout}s), deferring to on-demand"
472
+ )
473
+ save_unguessed_cache(gem_name, gem_info, cache, inference_timeout: true)
474
+ return nil
475
+ end
396
476
 
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?
477
+ # Phase C: Disk save
478
+ cache.save(gem_name, version, deps,
479
+ instance_methods: signatures[:instance_methods],
480
+ class_methods: signatures[:class_methods])
399
481
 
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) }
482
+ t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
483
+
484
+ # Phase D: Registry load
485
+ @signature_registry.load_gem_cache(signatures[:instance_methods], kind: :instance)
486
+ @signature_registry.load_gem_cache(signatures[:class_methods], kind: :class)
487
+
488
+ t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
489
+
490
+ log_message(
491
+ "Cached #{gem_name}-#{version} (#{files.size} files, " \
492
+ "#{signatures[:instance_methods].size} classes) " \
493
+ "[parse=#{(t1 - t0).round(2)}s infer=#{(t2 - t1).round(2)}s " \
494
+ "save=#{(t3 - t2).round(2)}s load=#{(t4 - t3).round(2)}s]"
495
+ )
496
+
497
+ signatures
402
498
  end
403
499
 
404
- def index_class_module_node(file_path, node, scope_id)
405
- @location_index.add(file_path, node, scope_id)
500
+ # Generate an Unguessed cache from member_index entries (no parse/infer needed).
501
+ # Method names and parameter structure come from RubyIndexer; types are set to Unguessed.
502
+ private def save_unguessed_cache(gem_name, gem_info, cache, inference_timeout: false)
503
+ version = gem_info[:version]
504
+ files = gem_info[:files]
505
+ deps = gem_info[:transitive_deps]
506
+
507
+ instance_methods = {}
508
+ class_methods = {}
509
+
510
+ files.each do |file_path|
511
+ @code_index.member_entries_for_file(file_path).each do |entry|
512
+ owner_name = entry.owner.name
513
+
514
+ if owner_name.match?(/::<Class:[^>]+>\z/)
515
+ class_name = extract_class_from_singleton(owner_name)
516
+ target = class_methods
517
+ else
518
+ class_name = owner_name
519
+ target = instance_methods
520
+ end
521
+
522
+ target[class_name] ||= {}
523
+ target[class_name][entry.name] = {
524
+ "return_type" => { "_type" => "Unguessed" },
525
+ "params" => build_params_from_entry(entry)
526
+ }
527
+ end
528
+ end
406
529
 
407
- new_scope = scope_id.empty? ? node.name : "#{scope_id}::#{node.name}"
530
+ cache.save(gem_name, version, deps,
531
+ instance_methods: instance_methods,
532
+ class_methods: class_methods,
533
+ fully_inferred: false,
534
+ inference_timeout: inference_timeout)
408
535
 
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)
536
+ @signature_registry.load_gem_cache(instance_methods, kind: :instance)
537
+ @signature_registry.load_gem_cache(class_methods, kind: :class)
414
538
 
415
- method_scope = singleton_scope_for(new_scope, singleton: method.singleton)
416
- @method_registry.register(method_scope, method.name.to_s, method)
539
+ log_message(
540
+ "Saved unguessed cache for #{gem_name}-#{version} " \
541
+ "(#{instance_methods.size} instance classes, #{class_methods.size} class method classes)"
542
+ )
543
+ end
544
+
545
+ # Build serialized params array from a RubyIndexer::Entry::Member
546
+ private def build_params_from_entry(entry)
547
+ sigs = entry.signatures
548
+ return [] if sigs.empty?
549
+
550
+ sigs.first.parameters.filter_map do |p|
551
+ kind = case p
552
+ when RubyIndexer::Entry::RequiredParameter then "required"
553
+ when RubyIndexer::Entry::OptionalParameter then "optional"
554
+ when RubyIndexer::Entry::RestParameter then "rest"
555
+ when RubyIndexer::Entry::KeywordParameter then "keyword_required"
556
+ when RubyIndexer::Entry::OptionalKeywordParameter then "keyword_optional"
557
+ when RubyIndexer::Entry::KeywordRestParameter then "keyword_rest"
558
+ when RubyIndexer::Entry::BlockParameter then "block"
559
+ else next
560
+ end
561
+ { "name" => p.name.to_s, "kind" => kind, "type" => { "_type" => "Unguessed" } }
562
+ end
563
+ end
564
+
565
+ # Fallback: index all files into main registries (no cache)
566
+ private def index_all_files(file_paths)
567
+ file_paths.each do |file_path|
568
+ next unless File.exist?(file_path)
569
+
570
+ source = File.read(file_path)
571
+ parsed = Prism.parse(source)
572
+ next unless parsed.value
573
+
574
+ @mutex.synchronize do
575
+ @location_index.remove_file(file_path)
576
+ @method_registry.remove_file(file_path)
577
+ @ivar_registry.remove_file(file_path)
578
+ @cvar_registry.remove_file(file_path)
579
+
580
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new(
581
+ file_path: file_path,
582
+ location_index: @location_index,
583
+ method_registry: @method_registry,
584
+ ivar_registry: @ivar_registry,
585
+ cvar_registry: @cvar_registry
586
+ )
587
+
588
+ parsed.value.statements&.body&.each do |stmt|
589
+ @converter.convert(stmt, context)
590
+ end
417
591
  end
592
+ rescue StandardError => e
593
+ log_message("Error indexing #{file_path}: #{e.class}: #{e.message}")
418
594
  end
595
+
596
+ @mutex.synchronize { @location_index.finalize! }
597
+ log_message("Indexed #{file_paths.size} files.")
419
598
  end
420
599
 
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
600
+ # Parse and index a file into given registries (no mutex, used for temp gem registries)
601
+ private def parse_and_index_file(file_path, converter, location_index,
602
+ method_registry, ivar_registry, cvar_registry)
603
+ return unless File.exist?(file_path)
426
604
 
427
- # Add stdlib RBS provider (lowest priority)
428
- provider.add_provider(::TypeGuessr::Core::RBSProvider.instance)
605
+ source = File.read(file_path)
606
+ parsed = Prism.parse(source)
607
+ return unless parsed.value
429
608
 
430
- # Future: Add project RBS provider (high priority)
431
- # provider.add_provider(ProjectRBSProvider.new, priority: :high)
609
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new(
610
+ file_path: file_path,
611
+ location_index: location_index,
612
+ method_registry: method_registry,
613
+ ivar_registry: ivar_registry,
614
+ cvar_registry: cvar_registry
615
+ )
432
616
 
433
- provider
617
+ parsed.value.statements&.body&.each do |stmt|
618
+ converter.convert(stmt, context)
619
+ end
620
+ rescue StandardError => e
621
+ log_message("Error indexing gem file #{file_path}: #{e.class}: #{e.message}")
434
622
  end
435
623
 
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
624
+ # Find Gemfile.lock in the workspace
625
+ private def find_lockfile
626
+ workspace_path = @global_state.workspace_path
627
+ return nil unless workspace_path
443
628
 
444
- parent_name = scope.split("::").last || "Object"
445
- scope.empty? ? "<Class:Object>" : "#{scope}::<Class:#{parent_name}>"
629
+ lockfile = File.join(workspace_path, "Gemfile.lock")
630
+ File.exist?(lockfile) ? lockfile : nil
446
631
  end
447
632
 
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
633
+ private def index_file_with_prism_result(file_path, prism_result)
634
+ return unless prism_result.value
635
+
636
+ @mutex.synchronize do
637
+ # Clear existing index for this file
638
+ @location_index.remove_file(file_path)
639
+ @method_registry.remove_file(file_path)
640
+ @ivar_registry.remove_file(file_path)
641
+ @cvar_registry.remove_file(file_path)
642
+ @resolver.clear_cache
643
+
644
+ # Create context with index/registry injection - nodes are registered during conversion
645
+ context = ::TypeGuessr::Core::Converter::PrismConverter::Context.new(
646
+ file_path: file_path,
647
+ location_index: @location_index,
648
+ method_registry: @method_registry,
649
+ ivar_registry: @ivar_registry,
650
+ cvar_registry: @cvar_registry
651
+ )
652
+
653
+ prism_result.value.statements&.body&.each do |stmt|
654
+ @converter.convert(stmt, context)
655
+ end
453
656
 
454
- @global_state.index.linearized_ancestors_of(class_name)
455
- rescue RubyIndexer::Index::NonExistingNamespaceError
456
- []
657
+ # Finalize the index for efficient lookups
658
+ @location_index.finalize!
659
+
660
+ # Update member_index (RubyIndexer is already updated by ruby-lsp)
661
+ @code_index.refresh_member_index!(URI::Generic.from_path(path: file_path))
662
+ end
663
+ end
664
+
665
+ # Extract simple class name from singleton format
666
+ # "File::<Class:File>" -> "File"
667
+ # "Namespace::MyClass::<Class:MyClass>" -> "Namespace::MyClass"
668
+ # @param owner_class [String] Owner class name (may be singleton format)
669
+ # @return [String] Simple class name
670
+ private def extract_class_from_singleton(owner_class)
671
+ # Match singleton pattern: "ClassName::<Class:ClassName>"
672
+ if owner_class.match?(/::<Class:[^>]+>\z/)
673
+ owner_class.sub(/::<Class:[^>]+>\z/, "")
674
+ else
675
+ owner_class
676
+ end
677
+ end
678
+
679
+ # Background inference: fully infer gems with Unguessed entries after indexing.
680
+ # Runs in the same indexing thread after @indexing_completed = true.
681
+ # Replaces in-memory Unguessed entries and updates disk cache to fully_inferred.
682
+ private def background_infer_gems(unguessed_gems, cache)
683
+ log_message("Starting background inference for #{unguessed_gems.size} gems...")
684
+
685
+ unguessed_gems.each do |entry|
686
+ signatures = infer_and_cache_gem(entry[:name], entry[:info], cache)
687
+ next unless signatures
688
+
689
+ @signature_registry.replace_unguessed_entries(signatures[:instance_methods], kind: :instance)
690
+ @signature_registry.replace_unguessed_entries(signatures[:class_methods], kind: :class)
691
+ rescue StandardError => e
692
+ log_message("Background inference failed for #{entry[:name]}: #{e.message}")
693
+ end
694
+
695
+ log_message("Background inference completed.")
696
+ end
697
+
698
+ # On-demand inference for a single gem file.
699
+ # Triggered when SignatureRegistry encounters an Unguessed return type.
700
+ # Parses and infers the file containing the method definition,
701
+ # then replaces Unguessed entries with actual inferred types.
702
+ private def infer_gem_file_on_demand(class_name, method_name, kind)
703
+ return if @inferring_on_demand # re-entrancy guard
704
+
705
+ singleton = kind == :class
706
+ file_path = @code_index.method_definition_file_path(class_name, method_name, singleton: singleton)
707
+ return unless file_path
708
+
709
+ @inferring_on_demand = true
710
+ begin
711
+ temp_location_index = ::TypeGuessr::Core::Index::LocationIndex.new
712
+ temp_method_registry = ::TypeGuessr::Core::Registry::MethodRegistry.new(code_index: @code_index)
713
+ temp_ivar_registry = ::TypeGuessr::Core::Registry::InstanceVariableRegistry.new(code_index: @code_index)
714
+ temp_cvar_registry = ::TypeGuessr::Core::Registry::ClassVariableRegistry.new
715
+
716
+ converter = ::TypeGuessr::Core::Converter::PrismConverter.new
717
+ parse_and_index_file(file_path, converter, temp_location_index,
718
+ temp_method_registry, temp_ivar_registry, temp_cvar_registry)
719
+ temp_location_index.finalize!
720
+
721
+ type_simplifier = ::TypeGuessr::Core::TypeSimplifier.new(code_index: @code_index)
722
+ temp_resolver = ::TypeGuessr::Core::Inference::Resolver.new(
723
+ @signature_registry,
724
+ code_index: @code_index,
725
+ method_registry: temp_method_registry,
726
+ ivar_registry: temp_ivar_registry,
727
+ cvar_registry: temp_cvar_registry,
728
+ type_simplifier: type_simplifier
729
+ )
730
+ temp_builder = ::TypeGuessr::Core::SignatureBuilder.new(temp_resolver)
731
+ extractor = ::TypeGuessr::Core::Cache::GemSignatureExtractor.new(
732
+ signature_builder: temp_builder,
733
+ method_registry: temp_method_registry,
734
+ location_index: temp_location_index
735
+ )
736
+
737
+ signatures = extractor.extract([file_path])
738
+
739
+ @signature_registry.replace_unguessed_entries(signatures[:instance_methods], kind: :instance)
740
+ @signature_registry.replace_unguessed_entries(signatures[:class_methods], kind: :class)
741
+ rescue StandardError => e
742
+ log_message("On-demand inference failed for #{class_name}##{method_name}: #{e.message}")
743
+ ensure
744
+ @inferring_on_demand = false
745
+ end
457
746
  end
458
747
 
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
748
+ # Poll index entry count until it stops growing for stable_threshold consecutive checks.
749
+ # Other addons (ruby-lsp-rails, etc.) may register entries after initial_indexing_completed.
750
+ private def wait_for_index_stabilization(index, interval: 1, stable_threshold: 3)
751
+ previous_count = index.length
752
+ stable_ticks = 0
464
753
 
465
- entries = @global_state.index[constant_name]
466
- return nil if entries.nil? || entries.empty?
754
+ loop do
755
+ sleep(interval)
756
+ current_count = index.length
467
757
 
468
- entry = entries.first
469
- case entry
470
- when RubyIndexer::Entry::Class then :class
471
- when RubyIndexer::Entry::Module then :module
758
+ if current_count == previous_count
759
+ stable_ticks += 1
760
+ break if stable_ticks >= stable_threshold
761
+ else
762
+ log_message("Index still growing: #{current_count} entries (+#{current_count - previous_count})")
763
+ stable_ticks = 0
764
+ previous_count = current_count
765
+ end
472
766
  end
767
+
768
+ log_message("Index stabilized at #{previous_count} entries.")
769
+ end
770
+
771
+ private def discover_runner_client
772
+ rails_addon = ::RubyLsp::Addon.get("Ruby LSP Rails", ">= 0.4.0", "< 0.5.0")
773
+ rails_addon.rails_runner_client
774
+ rescue ::RubyLsp::Addon::AddonNotFoundError, ::RubyLsp::Addon::IncompatibleApiError
775
+ nil
776
+ rescue StandardError => e
777
+ log_message("Failed to discover RunnerClient: #{e.message}")
778
+ nil
779
+ end
780
+
781
+ private def register_dsl_types
782
+ workspace_path = @global_state.workspace_path
783
+ return unless workspace_path
784
+
785
+ @dsl_registrar = DslTypeRegistrar.new(
786
+ signature_registry: @signature_registry,
787
+ code_index: @code_index,
788
+ project_root: workspace_path
789
+ )
790
+ @dsl_registrar.on_log { |msg| log_message("[DSL] #{msg}") }
791
+
792
+ runner_client = wait_for_runner_client
793
+ @dsl_registrar.register_all(runner_client: runner_client)
794
+ rescue StandardError => e
795
+ log_message("DSL type registration failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
473
796
  end
474
797
 
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
798
+ # Wait for ruby-lsp-rails RunnerClient to transition from NullClient.
799
+ # 3s interval × max 10 attempts = max 30s.
800
+ private def wait_for_runner_client
801
+ log_message("[DSL] Waiting for RunnerClient...")
802
+ max_attempts = 10
803
+
804
+ max_attempts.times do |_i|
805
+ client = discover_runner_client
806
+ if client.respond_to?(:connected?) && client.connected?
807
+ log_message("[DSL] RunnerClient ready (#{client.class})")
808
+ return client
809
+ end
481
810
 
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?
811
+ sleep(3)
812
+ end
488
813
 
489
- entry = entries.first
490
- entry.owner&.name
491
- rescue RubyIndexer::Index::NonExistingNamespaceError
814
+ log_message("[DSL] RunnerClient timeout after #{max_attempts} attempts")
492
815
  nil
493
816
  end
494
817
 
495
- def log_message(message)
818
+ private def start_schema_watch_loop
819
+ return unless @dsl_registrar
820
+
821
+ Thread.new do
822
+ loop do
823
+ sleep(45)
824
+ @dsl_registrar.check_and_refresh(runner_client: discover_runner_client)
825
+ rescue StandardError => e
826
+ log_message("Schema watch error: #{e.message}")
827
+ end
828
+ end
829
+ end
830
+
831
+ private def log_message(message)
496
832
  return unless @message_queue
497
833
  return if @message_queue.closed?
498
834