Class: RubyLsp::TypeGuessr::CodeIndexAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/ruby_lsp/type_guessr/code_index_adapter.rb

Overview

Adapter wrapping RubyIndexer for Core layer consumption Provides a stable interface isolating RubyIndexer API changes

Constant Summary collapse

OBJECT_METHOD_NAMES =

Public instance methods of Object (BasicObject + Kernel). Every class inherits these, so they have zero discriminating power for duck-type candidate search and should be excluded.

%w[
  ! != !~ == === <=>
  __id__ __send__
  class clone define_singleton_method display dup
  enum_for eql? equal? extend
  freeze frozen? hash inspect
  instance_of? instance_variable_defined?
  instance_variable_get instance_variable_set instance_variables
  is_a? itself kind_of?
  method methods nil? object_id
  private_methods protected_methods public_method public_methods public_send
  remove_instance_variable respond_to? respond_to_missing?
  send singleton_class singleton_method singleton_methods
  tap then to_enum to_s yield_self
].to_set.freeze

Instance Method Summary collapse

Constructor Details

#initialize(index) ⇒ CodeIndexAdapter

Returns a new instance of CodeIndexAdapter.



27
28
29
30
31
32
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 27

def initialize(index)
  @index = index
  @member_index = nil        # { method_name => [Entry] }
  @member_index_files = nil  # { file_path => [Entry] } for removal
  @method_classes = nil      # { method_name => Set[class_name] } including inherited
end

Instance Method Details

#ancestors_of(class_name) ⇒ Array<String>

Get linearized ancestor chain for a class

Parameters:

  • class_name (String)

    Fully qualified class name

Returns:

  • (Array<String>)

    Ancestor names in MRO order



162
163
164
165
166
167
168
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 162

def ancestors_of(class_name)
  return [] unless @index

  @index.linearized_ancestors_of(class_name)
rescue RubyIndexer::Index::NonExistingNamespaceError
  []
end

#build_member_index!Object

Build reverse index: method_name → [Entry::Member] Also builds @method_classes: method_name → Set including inherited methods. One-time full scan of index entries, called after initial indexing. Uses keys snapshot + point lookups to avoid Hash iteration conflict with concurrent Index#add on the main LSP thread.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 39

def build_member_index!
  return unless @index

  mi = Hash.new { |h, k| h[k] = [] }
  fi = Hash.new { |h, k| h[k] = [] }
  # owner_name → [method_name] for ancestor expansion
  owner_methods = Hash.new { |h, k| h[k] = [] }

  entries_hash = @index.instance_variable_get(:@entries)
  keys = entries_hash.keys # atomic snapshot under GIL

  keys.each do |name|
    (entries_hash[name] || []).each do |entry|
      next unless entry.is_a?(RubyIndexer::Entry::Member) && entry.owner

      mi[entry.name] << entry
      fp = entry.file_path
      fi[fp] << entry if fp

      owner_methods[entry.owner.name] << entry.name unless OBJECT_METHOD_NAMES.include?(entry.name)
    end
  end

  @member_index = mi
  @member_index_files = fi
  @method_classes = build_method_classes(owner_methods)
end

#class_method_owner(class_name, method_name) ⇒ String?

Look up owner of a class method

Parameters:

  • class_name (String)

    Class name

  • method_name (String)

    Method name

Returns:

  • (String, nil)

    Owner name or nil



189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 189

def class_method_owner(class_name, method_name)
  return nil unless @index

  unqualified_name = ::TypeGuessr::Core::IR.extract_last_name(class_name)
  singleton_name = "#{class_name}::<Class:#{unqualified_name}>"
  entries = @index.resolve_method(method_name, singleton_name)
  return nil if entries.nil? || entries.empty?

  entries.first.owner&.name
rescue RubyIndexer::Index::NonExistingNamespaceError
  nil
end

#constant_kind(constant_name) ⇒ Symbol?

Get kind of a constant

Parameters:

  • constant_name (String)

    Fully qualified constant name

Returns:

  • (Symbol, nil)

    :class, :module, or nil



173
174
175
176
177
178
179
180
181
182
183
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 173

def constant_kind(constant_name)
  return nil unless @index

  entries = @index[constant_name]
  return nil if entries.nil? || entries.empty?

  case entries.first
  when RubyIndexer::Entry::Class then :class
  when RubyIndexer::Entry::Module then :module
  end
end

#find_classes_defining_methods(called_methods) ⇒ Array<String>

Find classes that define ALL given methods (intersection) Each element responds to .name and .positional_count (duck typed)

Parameters:

  • called_methods (Array<#name, #positional_count>)

    Methods to search

Returns:

  • (Array<String>)

    Class names defining all methods



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 102

def find_classes_defining_methods(called_methods)
  return [] if called_methods.empty?
  return [] unless @index

  # Exclude Object methods — all classes have them, zero discriminating power
  called_methods = called_methods.reject { |cm| OBJECT_METHOD_NAMES.include?(cm.name.to_s) }
  return [] if called_methods.empty?

  # Pivot approach: 1 lookup + (N-1) resolve_method calls
  # Pick the longest method name as pivot (likely most specific → fewest candidates)
  pivot = called_methods.max_by { |cm| cm.name.to_s.length }
  rest = called_methods - [pivot]

  # Collect candidates: use @method_classes (includes inherited) when available
  candidates = if @method_classes
                 (@method_classes[pivot.name.to_s] || Set.new).to_a
               else
                 entries = if @member_index
                             @member_index[pivot.name.to_s] || []
                           else
                             @index.fuzzy_search(pivot.name.to_s) do |entry|
                               entry.is_a?(RubyIndexer::Entry::Member) && entry.name == pivot.name.to_s
                             end
                           end
                 entries = filter_by_arity(entries, pivot.positional_count, pivot.keywords) if pivot.positional_count
                 entries.filter_map do |entry|
                   entry.owner.name if entry.respond_to?(:owner) && entry.owner
                 end.uniq
               end

  return [] if candidates.empty?

  # When using method_classes, verify pivot arity too (entries-based path already filtered)
  methods_to_verify = @method_classes ? called_methods : rest
  return candidates if methods_to_verify.empty?

  # Verify each candidate has ALL methods via resolve_method
  # resolve_method walks the ancestor chain, so inherited methods are found
  result = candidates.select do |class_name|
    methods_to_verify.all? do |cm|
      method_entries = @index.resolve_method(cm.name.to_s, class_name)
      next false if method_entries.nil? || method_entries.empty?
      next true unless cm.positional_count

      method_entries.any? { |e| accepts_arity?(e, cm.positional_count, cm.keywords) }
    rescue RubyIndexer::Index::NonExistingNamespaceError
      false
    end
  end

  # Prefer non-singleton classes for duck typing.
  # Singleton classes (e.g., "Foo::<Class:Foo>") represent class objects,
  # not instances — they match class methods, not instance methods.
  non_singleton = result.grep_v(/::<Class:[^>]+>\z/)
  non_singleton.empty? ? result : non_singleton
end

#instance_method_owner(class_name, method_name) ⇒ String?

Look up owner of an instance method

Parameters:

  • class_name (String)

    Class name

  • method_name (String)

    Method name

Returns:

  • (String, nil)

    Owner name or nil



239
240
241
242
243
244
245
246
247
248
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 239

def instance_method_owner(class_name, method_name)
  return nil unless @index

  entries = @index.resolve_method(method_name, class_name)
  return nil if entries.nil? || entries.empty?

  entries.first.owner&.name
rescue RubyIndexer::Index::NonExistingNamespaceError
  nil
end

#member_entries_for_file(file_path) ⇒ Array<RubyIndexer::Entry::Member>

Get all member entries indexed for a specific file

Parameters:

  • file_path (String)

    Absolute file path

Returns:

  • (Array<RubyIndexer::Entry::Member>)

    Member entries for the file



92
93
94
95
96
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 92

def member_entries_for_file(file_path)
  return [] unless @member_index_files

  @member_index_files[file_path] || []
end

#method_definition_file_path(class_name, method_name, singleton: false) ⇒ String?

Look up file path where a method is defined

Parameters:

  • class_name (String)

    Class name

  • method_name (String)

    Method name

  • singleton (Boolean) (defaults to: false)

    true for class methods

Returns:

  • (String, nil)

    File path or nil if not found



220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 220

def method_definition_file_path(class_name, method_name, singleton: false)
  return nil unless @index

  target = if singleton
             uq = ::TypeGuessr::Core::IR.extract_last_name(class_name)
             "#{class_name}::<Class:#{uq}>"
           else
             class_name
           end
  entries = @index.resolve_method(method_name, target)
  entries&.first&.file_path
rescue RubyIndexer::Index::NonExistingNamespaceError
  nil
end

#refresh_member_index!(file_uri) ⇒ Object

Incrementally update member_index for a single file. Must be called AFTER RubyIndexer has re-indexed the file.

Parameters:

  • file_uri (URI::Generic)

    File URI (same format as RubyIndexer entries)



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 70

def refresh_member_index!(file_uri)
  return unless @member_index

  file_path = file_uri.respond_to?(:full_path) ? file_uri.full_path : file_uri.path

  # Remove old entries
  old_entries = @member_index_files.delete(file_path) || []
  old_entries.each { |entry| @member_index[entry.name]&.delete(entry) }

  # Add new entries from RubyIndexer
  new_entries = @index.entries_for(file_uri, RubyIndexer::Entry::Member) || []
  new_entries = new_entries.select(&:owner)
  new_entries.each { |entry| @member_index[entry.name] << entry }
  @member_index_files[file_path] = new_entries unless new_entries.empty?

  # Rebuild method_classes from updated member_index
  rebuild_method_classes! if @method_classes
end

#register_method_class(class_name, method_name) ⇒ Object

Inject a custom method into the duck-typing reverse index. Used by DSL adapters to register framework-generated methods (e.g., AR column accessors).



252
253
254
255
256
257
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 252

def register_method_class(class_name, method_name)
  return unless @method_classes

  @method_classes[method_name] ||= Set.new
  @method_classes[method_name] << class_name
end

#resolve_constant_name(short_name, nesting) ⇒ String?

Resolve a short constant name to its fully qualified name using nesting context

Parameters:

  • short_name (String)

    Short constant name (e.g., “RuntimeAdapter”)

  • nesting (Array<String>)

    Nesting context (e.g., [“RubyLsp”, “TypeGuessr”])

Returns:

  • (String, nil)

    Fully qualified name or nil if not found



206
207
208
209
210
211
212
213
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 206

def resolve_constant_name(short_name, nesting)
  return nil unless @index

  entries = @index.resolve(short_name, nesting)
  entries&.first&.name
rescue StandardError
  nil
end

#unregister_method_classes(class_name) ⇒ Object

Remove all entries for a class from the duck-typing reverse index. Used when purging DSL registrations (e.g., schema change).



261
262
263
264
265
# File 'lib/ruby_lsp/type_guessr/code_index_adapter.rb', line 261

def unregister_method_classes(class_name)
  return unless @method_classes

  @method_classes.each_value { |set| set.delete(class_name) }
end