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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 896566907c498dda0251546023a2d6cd8431cc8fdb5f7cbb72c6ef902067d95a
4
- data.tar.gz: a8104ed21dff5b62e066223b7fe3a9ff4f9f8b49a5ae2296bd9537a86478d07c
3
+ metadata.gz: 8ff8e62640844d1f305406b2ecf2c67d139e20aa1710e245b9650ce89f051c2d
4
+ data.tar.gz: ce2659a63614befd227cd54e2755dcda8a32dfd88640665b09101d97cc211071
5
5
  SHA512:
6
- metadata.gz: d0823d8578c5d00d9475f1473349b1ec9aeb96e3e025907a00b933b1e4b32f04663e2e1fe26df0977565d224d54eae4c9a355011826bc8ba2b56496a7750dd3a
7
- data.tar.gz: 02000caaf827f2e0297a80ae7296261b4d803e7f49ba8a64d114733dfdea82e079ee5967e9a7c02a311538d095b1fc25bbf3784db4c0a8d5a1be46cac7a5c24a
6
+ metadata.gz: b075460f82c3d9a9046e5b2e6ca87173fe84dd62244f240654bea83a7afbb4f1de785d53b2907030b37d6bdd4004a68fda4ca38a2ce4ea1e2720ed5534d30a32
7
+ data.tar.gz: 80468b024537670450a638f51cfae739ef1efe68227b89c675f095d9ecf1e57dbf648d65f15526f8cce8b5a9d2b9e74313c5f9dc7da9191e5221a2c4f9f6517b
data/README.md CHANGED
@@ -80,6 +80,47 @@ When enabled, a web server starts at `http://127.0.0.1:<port>` (default port: 70
80
80
 
81
81
  This is useful for understanding how TypeGuessr analyzes your codebase and debugging type inference issues.
82
82
 
83
+ ## MCP Server
84
+
85
+ TypeGuessr can run as a standalone [MCP](https://modelcontextprotocol.io/) server, exposing its type inference engine to AI tools like Claude Code.
86
+
87
+ ### Setup
88
+
89
+ Using Claude Code CLI:
90
+
91
+ ```bash
92
+ claude mcp add type-guessr -- bundle exec type-guessr mcp
93
+ ```
94
+
95
+ Or add to your project's `.mcp.json` manually:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "type-guessr": {
101
+ "command": "bundle",
102
+ "args": ["exec", "type-guessr", "mcp"]
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ Or run directly:
109
+
110
+ ```bash
111
+ bundle exec type-guessr mcp [project_path]
112
+ ```
113
+
114
+ If `project_path` is omitted, the current directory is used.
115
+
116
+ ### Available Tools
117
+
118
+ | Tool | Description |
119
+ |------|-------------|
120
+ | `infer_type` | Infer the type at a specific file/line/column |
121
+ | `get_method_signature` | Get the inferred signature of a method |
122
+ | `search_methods` | Search for method definitions by name or pattern |
123
+
83
124
  ## Development
84
125
 
85
126
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/type-guessr ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+
6
+ command = ARGV[0]
7
+
8
+ case command
9
+ when "mcp"
10
+ project_path = ARGV[1] || Dir.pwd
11
+ require_relative "../lib/type_guessr/mcp/server"
12
+ server = TypeGuessr::MCP::Server.new(project_path: project_path)
13
+ server.start
14
+ when "version", "--version", "-v"
15
+ require_relative "../lib/type_guessr/version"
16
+ puts "type-guessr #{TypeGuessr::VERSION}"
17
+ when "help", "--help", "-h", nil
18
+ puts <<~USAGE
19
+ Usage: type-guessr <command> [options]
20
+
21
+ Commands:
22
+ mcp [project_path] Start MCP server for the given project (default: current directory)
23
+ version Show version
24
+ help Show this help
25
+ USAGE
26
+ else
27
+ warn "Unknown command: #{command}"
28
+ warn "Run 'type-guessr help' for usage"
29
+ exit 1
30
+ end
@@ -1,44 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_lsp/addon"
4
- require_relative "config"
4
+ require_relative "constants"
5
5
  require_relative "runtime_adapter"
6
6
  require_relative "hover"
7
7
  require_relative "debug_server"
8
- require_relative "../../type_guessr/core/rbs_provider"
9
8
 
10
9
  module RubyLsp
11
10
  module TypeGuessr
12
11
  # TypeGuessr addon for Ruby LSP
13
12
  # Provides heuristic type inference without requiring type annotations
14
13
  class Addon < ::RubyLsp::Addon
15
- # Node types to add to Ruby LSP's ALLOWED_TARGETS for hover support
16
- HOVER_TARGET_NODES = [
17
- Prism::LocalVariableReadNode,
18
- Prism::LocalVariableWriteNode,
19
- Prism::LocalVariableTargetNode,
20
- Prism::InstanceVariableReadNode,
21
- Prism::InstanceVariableWriteNode,
22
- Prism::InstanceVariableTargetNode,
23
- Prism::ClassVariableReadNode,
24
- Prism::ClassVariableWriteNode,
25
- Prism::ClassVariableTargetNode,
26
- Prism::GlobalVariableReadNode,
27
- Prism::GlobalVariableWriteNode,
28
- Prism::GlobalVariableTargetNode,
29
- Prism::SelfNode,
30
- Prism::RequiredParameterNode,
31
- Prism::OptionalParameterNode,
32
- Prism::RestParameterNode,
33
- Prism::RequiredKeywordParameterNode,
34
- Prism::OptionalKeywordParameterNode,
35
- Prism::KeywordRestParameterNode,
36
- Prism::BlockParameterNode,
37
- Prism::ForwardingParameterNode,
38
- Prism::CallNode,
39
- Prism::DefNode,
40
- ].freeze
41
-
42
14
  attr_reader :runtime_adapter
43
15
 
44
16
  def name
@@ -46,6 +18,14 @@ module RubyLsp
46
18
  end
47
19
 
48
20
  def activate(global_state, message_queue)
21
+ unless ::TypeGuessr::Core::Config.enabled?
22
+ message_queue << RubyLsp::Notification.window_log_message(
23
+ "[TypeGuessr] Disabled via config",
24
+ type: RubyLsp::Constant::MessageType::LOG
25
+ )
26
+ return
27
+ end
28
+
49
29
  @global_state = global_state
50
30
  @message_queue = message_queue
51
31
  @runtime_adapter = RuntimeAdapter.new(global_state, message_queue)
@@ -54,19 +34,16 @@ module RubyLsp
54
34
  # Extend Ruby LSP's hover targets to include variables and parameters
55
35
  extend_hover_targets
56
36
 
57
- # Preload RBS environment
58
- ::TypeGuessr::Core::RBSProvider.instance.preload
59
-
60
- # Start background indexing
37
+ # Start indexing (RBS preload, member_index, project files, gem cache)
61
38
  @runtime_adapter.start_indexing
62
39
 
63
40
  # Swap TypeInferrer for enhanced Go to Definition
64
41
  @runtime_adapter.swap_type_inferrer
65
42
 
66
43
  # Start debug server if enabled
67
- start_debug_server if Config.debug_server_enabled?
44
+ start_debug_server if ::TypeGuessr::Core::Config.debug_server_enabled?
68
45
 
69
- debug_status = Config.debug? ? " (debug mode enabled)" : ""
46
+ debug_status = ::TypeGuessr::Core::Config.debug? ? " (debug mode enabled)" : ""
70
47
  message_queue << RubyLsp::Notification.window_log_message(
71
48
  "[TypeGuessr] Activated#{debug_status}",
72
49
  type: RubyLsp::Constant::MessageType::LOG
@@ -79,14 +56,14 @@ module RubyLsp
79
56
  end
80
57
 
81
58
  def create_hover_listener(response_builder, node_context, dispatcher)
82
- return unless Config.enabled?
59
+ return unless @runtime_adapter
83
60
 
84
61
  Hover.new(@runtime_adapter, response_builder, node_context, dispatcher, @global_state)
85
62
  end
86
63
 
87
64
  # Handle file changes
88
65
  def workspace_did_change_watched_files(changes)
89
- return unless Config.enabled?
66
+ return unless @runtime_adapter
90
67
 
91
68
  changes.each do |change|
92
69
  uri = URI(change[:uri])
@@ -98,22 +75,20 @@ module RubyLsp
98
75
  reindex_file(uri)
99
76
  when RubyLsp::Constant::FileChangeType::DELETED
100
77
  file_path = uri.to_standardized_path
101
- @runtime_adapter.instance_variable_get(:@location_index).remove_file(file_path) if file_path
78
+ @runtime_adapter.remove_indexed_file(file_path) if file_path
102
79
  end
103
80
  end
104
81
  end
105
82
 
106
- private
107
-
108
- def extend_hover_targets
83
+ private def extend_hover_targets
109
84
  targets = RubyLsp::Listeners::Hover::ALLOWED_TARGETS
110
85
 
111
- HOVER_TARGET_NODES.each do |target|
86
+ Constants::HOVER_NODE_MAPPING.each_value do |target|
112
87
  targets << target unless targets.include?(target)
113
88
  end
114
89
  end
115
90
 
116
- def reindex_file(uri)
91
+ private def reindex_file(uri)
117
92
  file_path = uri.path
118
93
  return unless file_path && File.exist?(file_path)
119
94
 
@@ -123,8 +98,8 @@ module RubyLsp
123
98
  warn("[TypeGuessr] Error indexing #{uri}: #{e.message}")
124
99
  end
125
100
 
126
- def start_debug_server
127
- port = Config.debug_server_port
101
+ private def start_debug_server
102
+ port = ::TypeGuessr::Core::Config.debug_server_port
128
103
  warn("[TypeGuessr] Starting debug server on port #{port}...")
129
104
  @debug_server = DebugServer.new(@global_state, @runtime_adapter, port: port)
130
105
  @debug_server.start
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module TypeGuessr
5
+ # Adapter wrapping RubyIndexer for Core layer consumption
6
+ # Provides a stable interface isolating RubyIndexer API changes
7
+ class CodeIndexAdapter
8
+ # Public instance methods of Object (BasicObject + Kernel).
9
+ # Every class inherits these, so they have zero discriminating power
10
+ # for duck-type candidate search and should be excluded.
11
+ OBJECT_METHOD_NAMES = %w[
12
+ ! != !~ == === <=>
13
+ __id__ __send__
14
+ class clone define_singleton_method display dup
15
+ enum_for eql? equal? extend
16
+ freeze frozen? hash inspect
17
+ instance_of? instance_variable_defined?
18
+ instance_variable_get instance_variable_set instance_variables
19
+ is_a? itself kind_of?
20
+ method methods nil? object_id
21
+ private_methods protected_methods public_method public_methods public_send
22
+ remove_instance_variable respond_to? respond_to_missing?
23
+ send singleton_class singleton_method singleton_methods
24
+ tap then to_enum to_s yield_self
25
+ ].to_set.freeze
26
+
27
+ def initialize(index)
28
+ @index = index
29
+ @member_index = nil # { method_name => [Entry] }
30
+ @member_index_files = nil # { file_path => [Entry] } for removal
31
+ @method_classes = nil # { method_name => Set[class_name] } including inherited
32
+ end
33
+
34
+ # Build reverse index: method_name → [Entry::Member]
35
+ # Also builds @method_classes: method_name → Set[class_name] including inherited methods.
36
+ # One-time full scan of index entries, called after initial indexing.
37
+ # Uses keys snapshot + point lookups to avoid Hash iteration conflict
38
+ # with concurrent Index#add on the main LSP thread.
39
+ def build_member_index!
40
+ return unless @index
41
+
42
+ mi = Hash.new { |h, k| h[k] = [] }
43
+ fi = Hash.new { |h, k| h[k] = [] }
44
+ # owner_name → [method_name] for ancestor expansion
45
+ owner_methods = Hash.new { |h, k| h[k] = [] }
46
+
47
+ entries_hash = @index.instance_variable_get(:@entries)
48
+ keys = entries_hash.keys # atomic snapshot under GIL
49
+
50
+ keys.each do |name|
51
+ (entries_hash[name] || []).each do |entry|
52
+ next unless entry.is_a?(RubyIndexer::Entry::Member) && entry.owner
53
+
54
+ mi[entry.name] << entry
55
+ fp = entry.file_path
56
+ fi[fp] << entry if fp
57
+
58
+ owner_methods[entry.owner.name] << entry.name unless OBJECT_METHOD_NAMES.include?(entry.name)
59
+ end
60
+ end
61
+
62
+ @member_index = mi
63
+ @member_index_files = fi
64
+ @method_classes = build_method_classes(owner_methods)
65
+ end
66
+
67
+ # Incrementally update member_index for a single file.
68
+ # Must be called AFTER RubyIndexer has re-indexed the file.
69
+ # @param file_uri [URI::Generic] File URI (same format as RubyIndexer entries)
70
+ def refresh_member_index!(file_uri)
71
+ return unless @member_index
72
+
73
+ file_path = file_uri.respond_to?(:full_path) ? file_uri.full_path : file_uri.path
74
+
75
+ # Remove old entries
76
+ old_entries = @member_index_files.delete(file_path) || []
77
+ old_entries.each { |entry| @member_index[entry.name]&.delete(entry) }
78
+
79
+ # Add new entries from RubyIndexer
80
+ new_entries = @index.entries_for(file_uri, RubyIndexer::Entry::Member) || []
81
+ new_entries = new_entries.select(&:owner)
82
+ new_entries.each { |entry| @member_index[entry.name] << entry }
83
+ @member_index_files[file_path] = new_entries unless new_entries.empty?
84
+
85
+ # Rebuild method_classes from updated member_index
86
+ rebuild_method_classes! if @method_classes
87
+ end
88
+
89
+ # Get all member entries indexed for a specific file
90
+ # @param file_path [String] Absolute file path
91
+ # @return [Array<RubyIndexer::Entry::Member>] Member entries for the file
92
+ def member_entries_for_file(file_path)
93
+ return [] unless @member_index_files
94
+
95
+ @member_index_files[file_path] || []
96
+ end
97
+
98
+ # Find classes that define ALL given methods (intersection)
99
+ # Each element responds to .name and .positional_count (duck typed)
100
+ # @param called_methods [Array<#name, #positional_count>] Methods to search
101
+ # @return [Array<String>] Class names defining all methods
102
+ def find_classes_defining_methods(called_methods)
103
+ return [] if called_methods.empty?
104
+ return [] unless @index
105
+
106
+ # Exclude Object methods — all classes have them, zero discriminating power
107
+ called_methods = called_methods.reject { |cm| OBJECT_METHOD_NAMES.include?(cm.name.to_s) }
108
+ return [] if called_methods.empty?
109
+
110
+ # Pivot approach: 1 lookup + (N-1) resolve_method calls
111
+ # Pick the longest method name as pivot (likely most specific → fewest candidates)
112
+ pivot = called_methods.max_by { |cm| cm.name.to_s.length }
113
+ rest = called_methods - [pivot]
114
+
115
+ # Collect candidates: use @method_classes (includes inherited) when available
116
+ candidates = if @method_classes
117
+ (@method_classes[pivot.name.to_s] || Set.new).to_a
118
+ else
119
+ entries = if @member_index
120
+ @member_index[pivot.name.to_s] || []
121
+ else
122
+ @index.fuzzy_search(pivot.name.to_s) do |entry|
123
+ entry.is_a?(RubyIndexer::Entry::Member) && entry.name == pivot.name.to_s
124
+ end
125
+ end
126
+ entries = filter_by_arity(entries, pivot.positional_count, pivot.keywords) if pivot.positional_count
127
+ entries.filter_map do |entry|
128
+ entry.owner.name if entry.respond_to?(:owner) && entry.owner
129
+ end.uniq
130
+ end
131
+
132
+ return [] if candidates.empty?
133
+
134
+ # When using method_classes, verify pivot arity too (entries-based path already filtered)
135
+ methods_to_verify = @method_classes ? called_methods : rest
136
+ return candidates if methods_to_verify.empty?
137
+
138
+ # Verify each candidate has ALL methods via resolve_method
139
+ # resolve_method walks the ancestor chain, so inherited methods are found
140
+ result = candidates.select do |class_name|
141
+ methods_to_verify.all? do |cm|
142
+ method_entries = @index.resolve_method(cm.name.to_s, class_name)
143
+ next false if method_entries.nil? || method_entries.empty?
144
+ next true unless cm.positional_count
145
+
146
+ method_entries.any? { |e| accepts_arity?(e, cm.positional_count, cm.keywords) }
147
+ rescue RubyIndexer::Index::NonExistingNamespaceError
148
+ false
149
+ end
150
+ end
151
+
152
+ # Prefer non-singleton classes for duck typing.
153
+ # Singleton classes (e.g., "Foo::<Class:Foo>") represent class objects,
154
+ # not instances — they match class methods, not instance methods.
155
+ non_singleton = result.grep_v(/::<Class:[^>]+>\z/)
156
+ non_singleton.empty? ? result : non_singleton
157
+ end
158
+
159
+ # Get linearized ancestor chain for a class
160
+ # @param class_name [String] Fully qualified class name
161
+ # @return [Array<String>] Ancestor names in MRO order
162
+ def ancestors_of(class_name)
163
+ return [] unless @index
164
+
165
+ @index.linearized_ancestors_of(class_name)
166
+ rescue RubyIndexer::Index::NonExistingNamespaceError
167
+ []
168
+ end
169
+
170
+ # Get kind of a constant
171
+ # @param constant_name [String] Fully qualified constant name
172
+ # @return [Symbol, nil] :class, :module, or nil
173
+ def constant_kind(constant_name)
174
+ return nil unless @index
175
+
176
+ entries = @index[constant_name]
177
+ return nil if entries.nil? || entries.empty?
178
+
179
+ case entries.first
180
+ when RubyIndexer::Entry::Class then :class
181
+ when RubyIndexer::Entry::Module then :module
182
+ end
183
+ end
184
+
185
+ # Look up owner of a class method
186
+ # @param class_name [String] Class name
187
+ # @param method_name [String] Method name
188
+ # @return [String, nil] Owner name or nil
189
+ def class_method_owner(class_name, method_name)
190
+ return nil unless @index
191
+
192
+ unqualified_name = ::TypeGuessr::Core::IR.extract_last_name(class_name)
193
+ singleton_name = "#{class_name}::<Class:#{unqualified_name}>"
194
+ entries = @index.resolve_method(method_name, singleton_name)
195
+ return nil if entries.nil? || entries.empty?
196
+
197
+ entries.first.owner&.name
198
+ rescue RubyIndexer::Index::NonExistingNamespaceError
199
+ nil
200
+ end
201
+
202
+ # Resolve a short constant name to its fully qualified name using nesting context
203
+ # @param short_name [String] Short constant name (e.g., "RuntimeAdapter")
204
+ # @param nesting [Array<String>] Nesting context (e.g., ["RubyLsp", "TypeGuessr"])
205
+ # @return [String, nil] Fully qualified name or nil if not found
206
+ def resolve_constant_name(short_name, nesting)
207
+ return nil unless @index
208
+
209
+ entries = @index.resolve(short_name, nesting)
210
+ entries&.first&.name
211
+ rescue StandardError
212
+ nil
213
+ end
214
+
215
+ # Look up file path where a method is defined
216
+ # @param class_name [String] Class name
217
+ # @param method_name [String] Method name
218
+ # @param singleton [Boolean] true for class methods
219
+ # @return [String, nil] File path or nil if not found
220
+ def method_definition_file_path(class_name, method_name, singleton: false)
221
+ return nil unless @index
222
+
223
+ target = if singleton
224
+ uq = ::TypeGuessr::Core::IR.extract_last_name(class_name)
225
+ "#{class_name}::<Class:#{uq}>"
226
+ else
227
+ class_name
228
+ end
229
+ entries = @index.resolve_method(method_name, target)
230
+ entries&.first&.file_path
231
+ rescue RubyIndexer::Index::NonExistingNamespaceError
232
+ nil
233
+ end
234
+
235
+ # Look up owner of an instance method
236
+ # @param class_name [String] Class name
237
+ # @param method_name [String] Method name
238
+ # @return [String, nil] Owner name or nil
239
+ def instance_method_owner(class_name, method_name)
240
+ return nil unless @index
241
+
242
+ entries = @index.resolve_method(method_name, class_name)
243
+ return nil if entries.nil? || entries.empty?
244
+
245
+ entries.first.owner&.name
246
+ rescue RubyIndexer::Index::NonExistingNamespaceError
247
+ nil
248
+ end
249
+
250
+ # Inject a custom method into the duck-typing reverse index.
251
+ # Used by DSL adapters to register framework-generated methods (e.g., AR column accessors).
252
+ def register_method_class(class_name, method_name)
253
+ return unless @method_classes
254
+
255
+ @method_classes[method_name] ||= Set.new
256
+ @method_classes[method_name] << class_name
257
+ end
258
+
259
+ # Remove all entries for a class from the duck-typing reverse index.
260
+ # Used when purging DSL registrations (e.g., schema change).
261
+ def unregister_method_classes(class_name)
262
+ return unless @method_classes
263
+
264
+ @method_classes.each_value { |set| set.delete(class_name) }
265
+ end
266
+
267
+ # Rebuild @method_classes from current @member_index state.
268
+ private def rebuild_method_classes!
269
+ owner_methods = {}
270
+ @member_index.each do |method_name, entries|
271
+ next if OBJECT_METHOD_NAMES.include?(method_name)
272
+
273
+ entries.each do |entry|
274
+ next unless entry.owner
275
+
276
+ (owner_methods[entry.owner.name] ||= []) << method_name
277
+ end
278
+ end
279
+ @method_classes = build_method_classes(owner_methods)
280
+ end
281
+
282
+ # Build method_name → Set[class_name] including inherited methods via ancestor chain.
283
+ # @param owner_methods [Hash{String => Array<String>}] owner_name → [method_names]
284
+ # @return [Hash{String => Set<String>}] method_name → Set[class_name]
285
+ private def build_method_classes(owner_methods)
286
+ mc = Hash.new { |h, k| h[k] = Set.new }
287
+
288
+ # Register direct definitions
289
+ owner_methods.each do |owner_name, method_names|
290
+ method_names.each { |mn| mc[mn] << owner_name }
291
+ end
292
+
293
+ # Expand with inherited: for each class, walk ancestors and register child under ancestor's methods
294
+ # Use .keys snapshot to avoid "can't add a new key during iteration"
295
+ owner_methods.keys.each do |class_name|
296
+ ancestors = @index.linearized_ancestors_of(class_name)
297
+ ancestors.drop(1).each do |ancestor_name|
298
+ next unless owner_methods.key?(ancestor_name)
299
+
300
+ owner_methods[ancestor_name].each do |method_name|
301
+ mc[method_name] << class_name
302
+ end
303
+ end
304
+ rescue RubyIndexer::Index::NonExistingNamespaceError
305
+ next
306
+ end
307
+
308
+ mc
309
+ end
310
+
311
+ private def filter_by_arity(entries, count, keywords)
312
+ entries.select { |entry| accepts_arity?(entry, count, keywords) }
313
+ end
314
+
315
+ private def accepts_arity?(entry, count, keywords)
316
+ sigs = entry.signatures
317
+ # Accessor (reader) has no signatures → accepts 0 arguments only
318
+ return count.zero? && keywords.empty? if sigs.empty?
319
+
320
+ sigs.any? do |sig|
321
+ required = 0
322
+ optional = 0
323
+ has_rest = false
324
+ has_keyword_params = false
325
+
326
+ sig.parameters.each do |p|
327
+ case p
328
+ when RubyIndexer::Entry::RequiredParameter then required += 1
329
+ when RubyIndexer::Entry::OptionalParameter then optional += 1
330
+ when RubyIndexer::Entry::RestParameter then has_rest = true
331
+ when RubyIndexer::Entry::KeywordParameter,
332
+ RubyIndexer::Entry::OptionalKeywordParameter,
333
+ RubyIndexer::Entry::KeywordRestParameter
334
+ has_keyword_params = true
335
+ end
336
+ end
337
+
338
+ # When call has keywords but method has no keyword params,
339
+ # keywords are implicitly converted to a Hash positional argument
340
+ effective_count = count
341
+ effective_count += 1 if keywords.any? && !has_keyword_params
342
+
343
+ if has_rest
344
+ effective_count >= required
345
+ else
346
+ effective_count.between?(required, required + optional)
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module RubyLsp
6
+ module TypeGuessr
7
+ # Shared constants for TypeGuessr addon
8
+ module Constants
9
+ # Mapping from node type symbols to Prism node classes
10
+ # - keys: Used for dispatcher event handler registration (hover.rb)
11
+ # - values: Used for extending Ruby LSP's ALLOWED_TARGETS (addon.rb)
12
+ HOVER_NODE_MAPPING = {
13
+ local_variable_read: Prism::LocalVariableReadNode,
14
+ local_variable_write: Prism::LocalVariableWriteNode,
15
+ local_variable_target: Prism::LocalVariableTargetNode,
16
+ instance_variable_read: Prism::InstanceVariableReadNode,
17
+ instance_variable_write: Prism::InstanceVariableWriteNode,
18
+ instance_variable_target: Prism::InstanceVariableTargetNode,
19
+ class_variable_read: Prism::ClassVariableReadNode,
20
+ class_variable_write: Prism::ClassVariableWriteNode,
21
+ class_variable_target: Prism::ClassVariableTargetNode,
22
+ global_variable_read: Prism::GlobalVariableReadNode,
23
+ global_variable_write: Prism::GlobalVariableWriteNode,
24
+ global_variable_target: Prism::GlobalVariableTargetNode,
25
+ required_parameter: Prism::RequiredParameterNode,
26
+ optional_parameter: Prism::OptionalParameterNode,
27
+ rest_parameter: Prism::RestParameterNode,
28
+ required_keyword_parameter: Prism::RequiredKeywordParameterNode,
29
+ optional_keyword_parameter: Prism::OptionalKeywordParameterNode,
30
+ keyword_rest_parameter: Prism::KeywordRestParameterNode,
31
+ block_parameter: Prism::BlockParameterNode,
32
+ forwarding_parameter: Prism::ForwardingParameterNode,
33
+ call: Prism::CallNode,
34
+ def: Prism::DefNode,
35
+ self: Prism::SelfNode
36
+ }.freeze
37
+ end
38
+ end
39
+ end