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.
- checksums.yaml +4 -4
- data/README.md +41 -0
- data/exe/type-guessr +30 -0
- data/lib/ruby_lsp/type_guessr/addon.rb +20 -45
- data/lib/ruby_lsp/type_guessr/code_index_adapter.rb +352 -0
- data/lib/ruby_lsp/type_guessr/constants.rb +39 -0
- data/lib/ruby_lsp/type_guessr/{graph_builder.rb → debug_graph_builder.rb} +27 -22
- data/lib/ruby_lsp/type_guessr/debug_server.rb +20 -17
- data/lib/ruby_lsp/type_guessr/dsl/activerecord_adapter.rb +404 -0
- data/lib/ruby_lsp/type_guessr/dsl/ar_schema_watcher.rb +96 -0
- data/lib/ruby_lsp/type_guessr/dsl/ar_type_mapper.rb +51 -0
- data/lib/ruby_lsp/type_guessr/dsl.rb +3 -0
- data/lib/ruby_lsp/type_guessr/dsl_type_registrar.rb +60 -0
- data/lib/ruby_lsp/type_guessr/hover.rb +129 -261
- data/lib/ruby_lsp/type_guessr/rails_server_addon.rb +83 -0
- data/lib/ruby_lsp/type_guessr/runtime_adapter.rb +613 -277
- data/lib/ruby_lsp/type_guessr/type_inferrer.rb +8 -105
- data/lib/type-guessr.rb +3 -11
- data/lib/type_guessr/core/cache/gem_dependency_resolver.rb +113 -0
- data/lib/type_guessr/core/cache/gem_signature_cache.rb +98 -0
- data/lib/type_guessr/core/cache/gem_signature_extractor.rb +87 -0
- data/lib/type_guessr/core/cache.rb +5 -0
- data/lib/{ruby_lsp/type_guessr → type_guessr/core}/config.rb +19 -34
- data/lib/type_guessr/core/converter/call_converter.rb +161 -0
- data/lib/type_guessr/core/converter/container_mutation_converter.rb +241 -0
- data/lib/type_guessr/core/converter/context.rb +144 -0
- data/lib/type_guessr/core/converter/control_flow_converter.rb +425 -0
- data/lib/type_guessr/core/converter/definition_converter.rb +246 -0
- data/lib/type_guessr/core/converter/literal_converter.rb +217 -0
- data/lib/type_guessr/core/converter/prism_converter.rb +154 -1613
- data/lib/type_guessr/core/converter/rbs_converter.rb +35 -14
- data/lib/type_guessr/core/converter/registration.rb +100 -0
- data/lib/type_guessr/core/converter/variable_converter.rb +225 -0
- data/lib/type_guessr/core/converter.rb +4 -0
- data/lib/type_guessr/core/index/location_index.rb +32 -0
- data/lib/type_guessr/core/index.rb +3 -0
- data/lib/type_guessr/core/inference/resolver.rb +516 -349
- data/lib/type_guessr/core/inference.rb +4 -0
- data/lib/type_guessr/core/ir/nodes.rb +362 -103
- data/lib/type_guessr/core/ir.rb +3 -0
- data/lib/type_guessr/core/logger.rb +6 -13
- data/lib/type_guessr/core/node_context_helper.rb +126 -0
- data/lib/type_guessr/core/node_key_generator.rb +31 -0
- data/lib/type_guessr/core/registry/class_variable_registry.rb +63 -0
- data/lib/type_guessr/core/registry/instance_variable_registry.rb +84 -0
- data/lib/type_guessr/core/registry/method_registry.rb +65 -38
- data/lib/type_guessr/core/registry/signature_registry.rb +543 -0
- data/lib/type_guessr/core/registry.rb +6 -0
- data/lib/type_guessr/core/signature_builder.rb +39 -0
- data/lib/type_guessr/core/type_serializer.rb +96 -0
- data/lib/type_guessr/core/type_simplifier.rb +15 -12
- data/lib/type_guessr/core/types.rb +250 -32
- data/lib/type_guessr/core.rb +29 -0
- data/lib/type_guessr/mcp/file_watcher.rb +87 -0
- data/lib/type_guessr/mcp/server.rb +463 -0
- data/lib/type_guessr/mcp/standalone_runtime.rb +213 -0
- data/lib/type_guessr/version.rb +1 -1
- metadata +57 -8
- data/lib/type_guessr/core/rbs_provider.rb +0 -304
- data/lib/type_guessr/core/registry/variable_registry.rb +0 -87
- data/lib/type_guessr/core/signature_provider.rb +0 -101
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "prism"
|
|
5
|
+
require "rbs"
|
|
6
|
+
require "mcp"
|
|
7
|
+
require "ruby_indexer/ruby_indexer"
|
|
8
|
+
|
|
9
|
+
# Load ruby-lsp for RubyDocument.locate and NodeContext
|
|
10
|
+
require "ruby_lsp/internal"
|
|
11
|
+
|
|
12
|
+
# Load core components
|
|
13
|
+
require_relative "../core"
|
|
14
|
+
|
|
15
|
+
# Load CodeIndexAdapter, StandaloneRuntime, and FileWatcher
|
|
16
|
+
require_relative "../../ruby_lsp/type_guessr/code_index_adapter"
|
|
17
|
+
require_relative "standalone_runtime"
|
|
18
|
+
require_relative "file_watcher"
|
|
19
|
+
|
|
20
|
+
module TypeGuessr
|
|
21
|
+
module MCP
|
|
22
|
+
# MCP server that exposes type-guessr's inference engine as tools.
|
|
23
|
+
# Indexes the target project on startup and serves type queries via stdio transport.
|
|
24
|
+
#
|
|
25
|
+
# Usage:
|
|
26
|
+
# server = TypeGuessr::MCP::Server.new(project_path: "/path/to/project")
|
|
27
|
+
# server.start
|
|
28
|
+
class Server
|
|
29
|
+
# @return [StandaloneRuntime] The runtime used for inference (available after #start)
|
|
30
|
+
attr_reader :runtime
|
|
31
|
+
|
|
32
|
+
# @param project_path [String] Path to the Ruby project to analyze
|
|
33
|
+
def initialize(project_path:)
|
|
34
|
+
@project_path = File.expand_path(project_path)
|
|
35
|
+
@runtime = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Build index and start the MCP server (blocks on stdio transport)
|
|
39
|
+
def start
|
|
40
|
+
warn "[type-guessr] Initializing for project: #{@project_path}"
|
|
41
|
+
|
|
42
|
+
index = build_ruby_index
|
|
43
|
+
@runtime = build_runtime(index)
|
|
44
|
+
index_project_files
|
|
45
|
+
start_file_watcher
|
|
46
|
+
|
|
47
|
+
server = ::MCP::Server.new(
|
|
48
|
+
name: "type-guessr",
|
|
49
|
+
version: TypeGuessr::VERSION,
|
|
50
|
+
tools: build_tools
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
warn "[type-guessr] Server ready"
|
|
54
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
55
|
+
transport.open
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private def build_ruby_index
|
|
59
|
+
@ruby_index = Dir.chdir(@project_path) do
|
|
60
|
+
config = RubyIndexer::Configuration.new
|
|
61
|
+
index = RubyIndexer::Index.new
|
|
62
|
+
uris = config.indexable_uris
|
|
63
|
+
warn "[type-guessr] Indexing #{uris.size} files with RubyIndexer..."
|
|
64
|
+
index.index_all(uris: uris)
|
|
65
|
+
index
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def build_runtime(ruby_index)
|
|
70
|
+
code_index = RubyLsp::TypeGuessr::CodeIndexAdapter.new(ruby_index)
|
|
71
|
+
signature_registry = Core::Registry::SignatureRegistry.new(code_index: code_index)
|
|
72
|
+
Core::Registry::SignatureRegistry.instance = signature_registry
|
|
73
|
+
|
|
74
|
+
method_registry = Core::Registry::MethodRegistry.new(code_index: code_index)
|
|
75
|
+
ivar_registry = Core::Registry::InstanceVariableRegistry.new(code_index: code_index)
|
|
76
|
+
cvar_registry = Core::Registry::ClassVariableRegistry.new
|
|
77
|
+
type_simplifier = Core::TypeSimplifier.new(code_index: code_index)
|
|
78
|
+
|
|
79
|
+
resolver = Core::Inference::Resolver.new(
|
|
80
|
+
signature_registry,
|
|
81
|
+
code_index: code_index,
|
|
82
|
+
method_registry: method_registry,
|
|
83
|
+
ivar_registry: ivar_registry,
|
|
84
|
+
cvar_registry: cvar_registry,
|
|
85
|
+
type_simplifier: type_simplifier
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
StandaloneRuntime.new(
|
|
89
|
+
converter: Core::Converter::PrismConverter.new,
|
|
90
|
+
location_index: Core::Index::LocationIndex.new,
|
|
91
|
+
signature_registry: signature_registry,
|
|
92
|
+
method_registry: method_registry,
|
|
93
|
+
ivar_registry: ivar_registry,
|
|
94
|
+
cvar_registry: cvar_registry,
|
|
95
|
+
resolver: resolver,
|
|
96
|
+
signature_builder: Core::SignatureBuilder.new(resolver),
|
|
97
|
+
code_index: code_index
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private def index_project_files
|
|
102
|
+
config = Dir.chdir(@project_path) { RubyIndexer::Configuration.new }
|
|
103
|
+
uris = Dir.chdir(@project_path) { config.indexable_uris }
|
|
104
|
+
file_paths = uris.filter_map do |uri|
|
|
105
|
+
uri.respond_to?(:to_standardized_path) ? uri.to_standardized_path : uri.path
|
|
106
|
+
end
|
|
107
|
+
total = file_paths.size
|
|
108
|
+
|
|
109
|
+
# Preload RBS signatures first (needed for gem inference)
|
|
110
|
+
@runtime.preload_signatures!
|
|
111
|
+
|
|
112
|
+
# Build member_index BEFORE gem inference (duck type resolution needs it)
|
|
113
|
+
@runtime.build_member_index!
|
|
114
|
+
|
|
115
|
+
# Try cache-first flow if Gemfile.lock exists
|
|
116
|
+
lockfile_path = File.join(@project_path, "Gemfile.lock")
|
|
117
|
+
if File.exist?(lockfile_path)
|
|
118
|
+
index_with_gem_cache(file_paths, lockfile_path)
|
|
119
|
+
else
|
|
120
|
+
index_all_files(file_paths)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@runtime.finalize_index!
|
|
124
|
+
warn "[type-guessr] Indexed #{total} files"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Cache-first indexing: process gems with cache, then project files
|
|
128
|
+
private def index_with_gem_cache(file_paths, lockfile_path)
|
|
129
|
+
dep_resolver = Core::Cache::GemDependencyResolver.new(lockfile_path)
|
|
130
|
+
partitioned = dep_resolver.partition(file_paths)
|
|
131
|
+
gems = partitioned[:gems]
|
|
132
|
+
project_files = partitioned[:project_files]
|
|
133
|
+
|
|
134
|
+
warn "[type-guessr] Partitioned: #{gems.size} gems, #{project_files.size} project files"
|
|
135
|
+
|
|
136
|
+
cache = Core::Cache::GemSignatureCache.new
|
|
137
|
+
ordered = dep_resolver.topological_order(gems.keys)
|
|
138
|
+
|
|
139
|
+
ordered.each do |gem_name|
|
|
140
|
+
gem_info = gems[gem_name]
|
|
141
|
+
process_gem(gem_name, gem_info, cache)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Index project files only
|
|
145
|
+
index_all_files(project_files)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Process a single gem: cache hit → load, cache miss → save unguessed cache
|
|
149
|
+
private def process_gem(gem_name, gem_info, cache)
|
|
150
|
+
version = gem_info[:version]
|
|
151
|
+
deps = gem_info[:transitive_deps]
|
|
152
|
+
signature_registry = @runtime.instance_variable_get(:@signature_registry)
|
|
153
|
+
warn "[type-guessr] Processing #{gem_name}-#{version} (#{gem_info[:files].size} files)..."
|
|
154
|
+
|
|
155
|
+
if cache.cached?(gem_name, version, deps)
|
|
156
|
+
data = cache.load(gem_name, version, deps)
|
|
157
|
+
if data
|
|
158
|
+
signature_registry.load_gem_cache(data["instance_methods"], kind: :instance)
|
|
159
|
+
signature_registry.load_gem_cache(data["class_methods"], kind: :class)
|
|
160
|
+
fully_inferred = data.fetch("fully_inferred", true)
|
|
161
|
+
warn "[type-guessr] Loaded cached: #{gem_name}-#{version} (fully_inferred=#{fully_inferred})"
|
|
162
|
+
else
|
|
163
|
+
warn "[type-guessr] Cache corrupt for #{gem_name}-#{version}, skipping"
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
save_unguessed_cache(gem_name, gem_info, cache, signature_registry)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Infer gem signatures using temporary registries
|
|
171
|
+
private def infer_and_cache_gem(gem_name, gem_info, cache, signature_registry)
|
|
172
|
+
version = gem_info[:version]
|
|
173
|
+
files = gem_info[:files]
|
|
174
|
+
deps = gem_info[:transitive_deps]
|
|
175
|
+
code_index = @runtime.instance_variable_get(:@code_index)
|
|
176
|
+
|
|
177
|
+
# Phase A: Parse + IR conversion
|
|
178
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
179
|
+
|
|
180
|
+
temp_location_index = Core::Index::LocationIndex.new
|
|
181
|
+
temp_method_registry = Core::Registry::MethodRegistry.new(code_index: code_index)
|
|
182
|
+
temp_ivar_registry = Core::Registry::InstanceVariableRegistry.new(code_index: code_index)
|
|
183
|
+
temp_cvar_registry = Core::Registry::ClassVariableRegistry.new
|
|
184
|
+
converter = Core::Converter::PrismConverter.new
|
|
185
|
+
|
|
186
|
+
files.each do |file_path|
|
|
187
|
+
next unless File.exist?(file_path)
|
|
188
|
+
|
|
189
|
+
parsed = Prism.parse(File.read(file_path))
|
|
190
|
+
next unless parsed.value
|
|
191
|
+
|
|
192
|
+
context = Core::Converter::PrismConverter::Context.new(
|
|
193
|
+
file_path: file_path,
|
|
194
|
+
location_index: temp_location_index,
|
|
195
|
+
method_registry: temp_method_registry,
|
|
196
|
+
ivar_registry: temp_ivar_registry,
|
|
197
|
+
cvar_registry: temp_cvar_registry
|
|
198
|
+
)
|
|
199
|
+
parsed.value.statements&.body&.each { |stmt| converter.convert(stmt, context) }
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
warn "[type-guessr] Error indexing gem file #{file_path}: #{e.message}"
|
|
202
|
+
end
|
|
203
|
+
temp_location_index.finalize!
|
|
204
|
+
|
|
205
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
|
|
207
|
+
# Phase B: Inference (extract signatures)
|
|
208
|
+
type_simplifier = Core::TypeSimplifier.new(code_index: code_index)
|
|
209
|
+
temp_resolver = Core::Inference::Resolver.new(
|
|
210
|
+
signature_registry,
|
|
211
|
+
code_index: code_index,
|
|
212
|
+
method_registry: temp_method_registry,
|
|
213
|
+
ivar_registry: temp_ivar_registry,
|
|
214
|
+
cvar_registry: temp_cvar_registry,
|
|
215
|
+
type_simplifier: type_simplifier
|
|
216
|
+
)
|
|
217
|
+
temp_builder = Core::SignatureBuilder.new(temp_resolver)
|
|
218
|
+
|
|
219
|
+
extractor = Core::Cache::GemSignatureExtractor.new(
|
|
220
|
+
signature_builder: temp_builder,
|
|
221
|
+
method_registry: temp_method_registry,
|
|
222
|
+
location_index: temp_location_index
|
|
223
|
+
)
|
|
224
|
+
signatures = extractor.extract(files)
|
|
225
|
+
|
|
226
|
+
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
227
|
+
|
|
228
|
+
# Phase C: Disk save
|
|
229
|
+
cache.save(gem_name, version, deps,
|
|
230
|
+
instance_methods: signatures[:instance_methods],
|
|
231
|
+
class_methods: signatures[:class_methods])
|
|
232
|
+
|
|
233
|
+
t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
234
|
+
|
|
235
|
+
# Phase D: Registry load
|
|
236
|
+
signature_registry.load_gem_cache(signatures[:instance_methods], kind: :instance)
|
|
237
|
+
signature_registry.load_gem_cache(signatures[:class_methods], kind: :class)
|
|
238
|
+
|
|
239
|
+
t4 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
240
|
+
|
|
241
|
+
warn "[type-guessr] Cached #{gem_name}-#{version} (#{files.size} files, " \
|
|
242
|
+
"#{signatures[:instance_methods].size} classes) " \
|
|
243
|
+
"[parse=#{(t1 - t0).round(2)}s infer=#{(t2 - t1).round(2)}s " \
|
|
244
|
+
"save=#{(t3 - t2).round(2)}s load=#{(t4 - t3).round(2)}s]"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Generate an Unguessed cache from member_index entries (no parse/infer needed).
|
|
248
|
+
private def save_unguessed_cache(gem_name, gem_info, cache, signature_registry)
|
|
249
|
+
version = gem_info[:version]
|
|
250
|
+
files = gem_info[:files]
|
|
251
|
+
deps = gem_info[:transitive_deps]
|
|
252
|
+
code_index = @runtime.instance_variable_get(:@code_index)
|
|
253
|
+
|
|
254
|
+
instance_methods = {}
|
|
255
|
+
class_methods = {}
|
|
256
|
+
|
|
257
|
+
files.each do |file_path|
|
|
258
|
+
code_index.member_entries_for_file(file_path).each do |entry|
|
|
259
|
+
owner_name = entry.owner.name
|
|
260
|
+
|
|
261
|
+
if owner_name.match?(/::<Class:[^>]+>\z/)
|
|
262
|
+
class_name = extract_class_from_singleton(owner_name)
|
|
263
|
+
target = class_methods
|
|
264
|
+
else
|
|
265
|
+
class_name = owner_name
|
|
266
|
+
target = instance_methods
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
target[class_name] ||= {}
|
|
270
|
+
target[class_name][entry.name] = {
|
|
271
|
+
"return_type" => { "_type" => "Unguessed" },
|
|
272
|
+
"params" => build_params_from_entry(entry)
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
cache.save(gem_name, version, deps,
|
|
278
|
+
instance_methods: instance_methods,
|
|
279
|
+
class_methods: class_methods,
|
|
280
|
+
fully_inferred: false)
|
|
281
|
+
|
|
282
|
+
signature_registry.load_gem_cache(instance_methods, kind: :instance)
|
|
283
|
+
signature_registry.load_gem_cache(class_methods, kind: :class)
|
|
284
|
+
|
|
285
|
+
warn "[type-guessr] Saved unguessed cache for #{gem_name}-#{version} " \
|
|
286
|
+
"(#{instance_methods.size} instance classes, #{class_methods.size} class method classes)"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Extract simple class name from singleton format
|
|
290
|
+
# "File::<Class:File>" -> "File"
|
|
291
|
+
private def extract_class_from_singleton(owner_class)
|
|
292
|
+
if owner_class.match?(/::<Class:[^>]+>\z/)
|
|
293
|
+
owner_class.sub(/::<Class:[^>]+>\z/, "")
|
|
294
|
+
else
|
|
295
|
+
owner_class
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Build serialized params array from a RubyIndexer::Entry::Member
|
|
300
|
+
private def build_params_from_entry(entry)
|
|
301
|
+
sigs = entry.signatures
|
|
302
|
+
return [] if sigs.empty?
|
|
303
|
+
|
|
304
|
+
sigs.first.parameters.filter_map do |p|
|
|
305
|
+
kind = case p
|
|
306
|
+
when RubyIndexer::Entry::RequiredParameter then "required"
|
|
307
|
+
when RubyIndexer::Entry::OptionalParameter then "optional"
|
|
308
|
+
when RubyIndexer::Entry::RestParameter then "rest"
|
|
309
|
+
when RubyIndexer::Entry::KeywordParameter then "keyword_required"
|
|
310
|
+
when RubyIndexer::Entry::OptionalKeywordParameter then "keyword_optional"
|
|
311
|
+
when RubyIndexer::Entry::KeywordRestParameter then "keyword_rest"
|
|
312
|
+
when RubyIndexer::Entry::BlockParameter then "block"
|
|
313
|
+
else next
|
|
314
|
+
end
|
|
315
|
+
{ "name" => p.name.to_s, "kind" => kind, "type" => { "_type" => "Unguessed" } }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Index files into the main runtime (project files only)
|
|
320
|
+
private def index_all_files(file_paths)
|
|
321
|
+
file_paths.each do |file_path|
|
|
322
|
+
next unless File.exist?(file_path)
|
|
323
|
+
|
|
324
|
+
parsed = Prism.parse(File.read(file_path))
|
|
325
|
+
next unless parsed.value
|
|
326
|
+
|
|
327
|
+
@runtime.index_parsed_file(file_path, parsed)
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
warn "[type-guessr] Error indexing #{file_path}: #{e.message}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
private def start_file_watcher
|
|
334
|
+
@file_watcher = FileWatcher.new(
|
|
335
|
+
project_path: @project_path,
|
|
336
|
+
on_change: method(:handle_file_changes)
|
|
337
|
+
)
|
|
338
|
+
@file_watcher.start
|
|
339
|
+
warn "[type-guessr] File watcher started (polling every 2s)"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
private def handle_file_changes(modified, added, removed)
|
|
343
|
+
(modified + added).each do |file_path|
|
|
344
|
+
source = File.read(file_path)
|
|
345
|
+
uri = URI::Generic.from_path(path: file_path)
|
|
346
|
+
parsed = Prism.parse(source)
|
|
347
|
+
@ruby_index.handle_change(uri, source)
|
|
348
|
+
@runtime.index_parsed_file(file_path, parsed)
|
|
349
|
+
@runtime.refresh_member_index!(uri)
|
|
350
|
+
warn "[type-guessr] Re-indexed: #{file_path}"
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
warn "[type-guessr] Error re-indexing #{file_path}: #{e.message}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
removed.each do |file_path|
|
|
356
|
+
uri = URI::Generic.from_path(path: file_path)
|
|
357
|
+
@ruby_index.delete(uri)
|
|
358
|
+
@runtime.remove_indexed_file(file_path)
|
|
359
|
+
@runtime.refresh_member_index!(uri)
|
|
360
|
+
warn "[type-guessr] Removed from index: #{file_path}"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
private def build_tools
|
|
365
|
+
[build_get_method_sources_tool, build_get_method_signatures_tool, build_search_methods_tool]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
private def build_get_method_sources_tool
|
|
369
|
+
runtime = @runtime
|
|
370
|
+
to_response = method(:json_response)
|
|
371
|
+
|
|
372
|
+
::MCP::Tool.define(
|
|
373
|
+
name: "get_method_sources",
|
|
374
|
+
description: "Get source code of methods by class and method name. " \
|
|
375
|
+
"Returns the full method definition with file path and line number. " \
|
|
376
|
+
"Use when you need to read a method's implementation without knowing " \
|
|
377
|
+
"the exact file location.",
|
|
378
|
+
input_schema: {
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: {
|
|
381
|
+
methods: {
|
|
382
|
+
type: "array",
|
|
383
|
+
items: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
class_name: { type: "string", description: "Fully qualified class name (e.g., 'User', 'Admin::User')" },
|
|
387
|
+
method_name: { type: "string", description: "Method name (e.g., 'save', 'initialize')" }
|
|
388
|
+
},
|
|
389
|
+
required: %w[class_name method_name]
|
|
390
|
+
},
|
|
391
|
+
description: "Array of {class_name, method_name} to look up"
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
required: %w[methods]
|
|
395
|
+
}
|
|
396
|
+
) do |methods:, **|
|
|
397
|
+
to_response.call(runtime.method_sources(methods))
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private def build_get_method_signatures_tool
|
|
402
|
+
runtime = @runtime
|
|
403
|
+
to_response = method(:json_response)
|
|
404
|
+
|
|
405
|
+
::MCP::Tool.define(
|
|
406
|
+
name: "get_method_signatures",
|
|
407
|
+
description: "Get inferred signatures for multiple methods in one call. " \
|
|
408
|
+
"More efficient than calling get_method_signature repeatedly " \
|
|
409
|
+
"when investigating a class or module.",
|
|
410
|
+
input_schema: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: {
|
|
413
|
+
methods: {
|
|
414
|
+
type: "array",
|
|
415
|
+
items: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
class_name: { type: "string", description: "Fully qualified class name" },
|
|
419
|
+
method_name: { type: "string", description: "Method name" }
|
|
420
|
+
},
|
|
421
|
+
required: %w[class_name method_name]
|
|
422
|
+
},
|
|
423
|
+
description: "Array of {class_name, method_name} to look up"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
required: %w[methods]
|
|
427
|
+
}
|
|
428
|
+
) do |methods:, **|
|
|
429
|
+
to_response.call(runtime.method_signatures(methods))
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
private def build_search_methods_tool
|
|
434
|
+
runtime = @runtime
|
|
435
|
+
to_response = method(:json_response)
|
|
436
|
+
|
|
437
|
+
::MCP::Tool.define(
|
|
438
|
+
name: "search_methods",
|
|
439
|
+
description: "Search for method definitions across the project with type-aware results. " \
|
|
440
|
+
"Returns matching methods with their inferred signatures. Unlike grep, results " \
|
|
441
|
+
"include class hierarchy context and inferred types. Supports patterns: " \
|
|
442
|
+
"'User#save' (specific), 'save' (all classes), 'Admin::*' (namespace). " \
|
|
443
|
+
"Use when exploring an unfamiliar codebase or finding which classes implement " \
|
|
444
|
+
"a given method.",
|
|
445
|
+
input_schema: {
|
|
446
|
+
type: "object",
|
|
447
|
+
properties: {
|
|
448
|
+
query: { type: "string", description: "Search query (e.g., 'User#save', 'save', 'initialize')" },
|
|
449
|
+
include_signatures: { type: "boolean", description: "Include inferred method signatures in results (default: false)" }
|
|
450
|
+
},
|
|
451
|
+
required: %w[query]
|
|
452
|
+
}
|
|
453
|
+
) do |query:, include_signatures: false, **|
|
|
454
|
+
to_response.call(runtime.search_methods(query, include_signatures: include_signatures))
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
private def json_response(data)
|
|
459
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(data) }])
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeGuessr
|
|
4
|
+
module MCP
|
|
5
|
+
# Standalone runtime that mirrors RuntimeAdapter's query interface
|
|
6
|
+
# without depending on ruby-lsp's GlobalState.
|
|
7
|
+
#
|
|
8
|
+
# Provides type inference, method signature lookup, and method search
|
|
9
|
+
# for use by the MCP server. All public query methods are thread-safe.
|
|
10
|
+
class StandaloneRuntime
|
|
11
|
+
# @param converter [Core::Converter::PrismConverter]
|
|
12
|
+
# @param location_index [Core::Index::LocationIndex]
|
|
13
|
+
# @param signature_registry [Core::Registry::SignatureRegistry]
|
|
14
|
+
# @param method_registry [Core::Registry::MethodRegistry]
|
|
15
|
+
# @param ivar_registry [Core::Registry::InstanceVariableRegistry]
|
|
16
|
+
# @param cvar_registry [Core::Registry::ClassVariableRegistry]
|
|
17
|
+
# @param resolver [Core::Inference::Resolver]
|
|
18
|
+
# @param signature_builder [Core::SignatureBuilder]
|
|
19
|
+
# @param code_index [RubyLsp::TypeGuessr::CodeIndexAdapter]
|
|
20
|
+
def initialize(converter:, location_index:, signature_registry:, method_registry:,
|
|
21
|
+
ivar_registry:, cvar_registry:, resolver:, signature_builder:, code_index:)
|
|
22
|
+
@converter = converter
|
|
23
|
+
@location_index = location_index
|
|
24
|
+
@signature_registry = signature_registry
|
|
25
|
+
@method_registry = method_registry
|
|
26
|
+
@ivar_registry = ivar_registry
|
|
27
|
+
@cvar_registry = cvar_registry
|
|
28
|
+
@resolver = resolver
|
|
29
|
+
@signature_builder = signature_builder
|
|
30
|
+
@code_index = code_index
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Index a pre-parsed file into the IR graph
|
|
35
|
+
# @param file_path [String] Absolute path to the file
|
|
36
|
+
# @param prism_result [Prism::ParseResult] Parsed AST
|
|
37
|
+
def index_parsed_file(file_path, prism_result)
|
|
38
|
+
return unless prism_result.value
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@location_index.remove_file(file_path)
|
|
42
|
+
@method_registry.remove_file(file_path)
|
|
43
|
+
@resolver.clear_cache
|
|
44
|
+
|
|
45
|
+
context = Core::Converter::PrismConverter::Context.new(
|
|
46
|
+
file_path: file_path,
|
|
47
|
+
location_index: @location_index,
|
|
48
|
+
method_registry: @method_registry,
|
|
49
|
+
ivar_registry: @ivar_registry,
|
|
50
|
+
cvar_registry: @cvar_registry
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
prism_result.value.statements&.body&.each do |stmt|
|
|
54
|
+
@converter.convert(stmt, context)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Remove all indexed data for a file
|
|
60
|
+
# @param file_path [String] Absolute path to the file
|
|
61
|
+
def remove_indexed_file(file_path)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@location_index.remove_file(file_path)
|
|
64
|
+
@method_registry.remove_file(file_path)
|
|
65
|
+
@resolver.clear_cache
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Delegate member_index build to code_index
|
|
70
|
+
def build_member_index!
|
|
71
|
+
@code_index.build_member_index!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Delegate member_index refresh to code_index
|
|
75
|
+
# @param file_uri [URI::Generic] File URI
|
|
76
|
+
def refresh_member_index!(file_uri)
|
|
77
|
+
@code_index.refresh_member_index!(file_uri)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Finalize location index after all files are indexed
|
|
81
|
+
def finalize_index!
|
|
82
|
+
@mutex.synchronize { @location_index.finalize! }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Preload RBS signatures for inference
|
|
86
|
+
def preload_signatures!
|
|
87
|
+
@signature_registry.preload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get method signature for a class#method
|
|
91
|
+
# @param class_name [String] Fully qualified class name (e.g., "User", "Admin::User")
|
|
92
|
+
# @param method_name [String] Method name (e.g., "save", "initialize")
|
|
93
|
+
# @return [Hash] Signature result with :source and :signature keys, or :error on failure
|
|
94
|
+
def method_signature(class_name, method_name)
|
|
95
|
+
def_node = @mutex.synchronize { @method_registry.lookup(class_name, method_name) }
|
|
96
|
+
|
|
97
|
+
unless def_node
|
|
98
|
+
entry = @signature_registry.lookup(class_name, method_name)
|
|
99
|
+
|
|
100
|
+
if entry.is_a?(Core::Registry::SignatureRegistry::MethodEntry)
|
|
101
|
+
return {
|
|
102
|
+
source: "rbs",
|
|
103
|
+
signatures: entry.signature_strings,
|
|
104
|
+
class_name: class_name,
|
|
105
|
+
method_name: method_name
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if entry.is_a?(Core::Registry::SignatureRegistry::GemMethodEntry)
|
|
110
|
+
return {
|
|
111
|
+
source: "gem_cache",
|
|
112
|
+
signatures: entry.signature_strings,
|
|
113
|
+
class_name: class_name,
|
|
114
|
+
method_name: method_name
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return { error: "Method not found: #{class_name}##{method_name}", class_name: class_name,
|
|
119
|
+
method_name: method_name }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
sig = @mutex.synchronize { @signature_builder.build_from_def_node(def_node) }
|
|
123
|
+
{
|
|
124
|
+
source: "project",
|
|
125
|
+
signature: sig.to_s,
|
|
126
|
+
class_name: class_name,
|
|
127
|
+
method_name: method_name
|
|
128
|
+
}
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
{ error: e.message, class_name: class_name, method_name: method_name }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Get signatures for multiple methods in one call
|
|
134
|
+
# @param methods [Array<Hash>] Array of { class_name:, method_name: } hashes
|
|
135
|
+
# @return [Array<Hash>] Array of signature results (same format as method_signature)
|
|
136
|
+
def method_signatures(methods)
|
|
137
|
+
methods.map do |entry|
|
|
138
|
+
method_signature(entry[:class_name], entry[:method_name])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get source code for a single method
|
|
143
|
+
# @param class_name [String] Fully qualified class name
|
|
144
|
+
# @param method_name [String] Method name
|
|
145
|
+
# @return [Hash] Source result with :source, :file_path, :line keys, or :error on failure
|
|
146
|
+
def method_source(class_name, method_name)
|
|
147
|
+
def_node = @mutex.synchronize { @method_registry.lookup(class_name, method_name) }
|
|
148
|
+
unless def_node
|
|
149
|
+
return { error: "Method not found: #{class_name}##{method_name}",
|
|
150
|
+
class_name: class_name, method_name: method_name }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
file_path = @mutex.synchronize { @method_registry.source_file_for(class_name, method_name) }
|
|
154
|
+
unless file_path
|
|
155
|
+
return { error: "Source file not found: #{class_name}##{method_name}",
|
|
156
|
+
class_name: class_name, method_name: method_name }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
source = File.read(file_path)
|
|
160
|
+
prism_result = Prism.parse(source)
|
|
161
|
+
node_context = RubyLsp::RubyDocument.locate(
|
|
162
|
+
prism_result.value, def_node.loc,
|
|
163
|
+
code_units_cache: prism_result.code_units_cache(Encoding::UTF_8)
|
|
164
|
+
)
|
|
165
|
+
prism_def = node_context.node.is_a?(Prism::DefNode) ? node_context.node : node_context.parent
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
class_name: class_name,
|
|
169
|
+
method_name: method_name,
|
|
170
|
+
source: prism_def.slice,
|
|
171
|
+
file_path: file_path,
|
|
172
|
+
line: prism_def.location.start_line
|
|
173
|
+
}
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
{ error: e.message, class_name: class_name, method_name: method_name }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Get source code for multiple methods in one call
|
|
179
|
+
# @param methods [Array<Hash>] Array of { class_name:, method_name: } hashes
|
|
180
|
+
# @return [Array<Hash>] Array of source results (same format as method_source)
|
|
181
|
+
def method_sources(methods)
|
|
182
|
+
methods.map do |entry|
|
|
183
|
+
method_source(entry[:class_name], entry[:method_name])
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Search for methods matching a query pattern
|
|
188
|
+
# @param query [String] Search query (e.g., "User#save", "save", "initialize")
|
|
189
|
+
# @param include_signatures [Boolean] When true, include inferred signature for each result
|
|
190
|
+
# @return [Array<Hash>] Array of matching methods with :class_name, :method_name, :full_name, :location
|
|
191
|
+
def search_methods(query, include_signatures: false)
|
|
192
|
+
@mutex.synchronize do
|
|
193
|
+
results = @method_registry.search(query)
|
|
194
|
+
results.map do |class_name, method_name, def_node|
|
|
195
|
+
entry = {
|
|
196
|
+
class_name: class_name,
|
|
197
|
+
method_name: method_name,
|
|
198
|
+
full_name: "#{class_name}##{method_name}",
|
|
199
|
+
location: def_node.loc ? { offset: def_node.loc } : nil
|
|
200
|
+
}
|
|
201
|
+
if include_signatures
|
|
202
|
+
sig = @signature_builder.build_from_def_node(def_node)
|
|
203
|
+
entry[:signature] = sig.to_s
|
|
204
|
+
end
|
|
205
|
+
entry
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
{ error: e.message }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
data/lib/type_guessr/version.rb
CHANGED