type-guessr 0.0.2 → 0.0.4
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 +3 -3
- data/lib/ruby_lsp/type_guessr/addon.rb +4 -5
- data/lib/ruby_lsp/type_guessr/code_index_adapter.rb +18 -1
- data/lib/ruby_lsp/type_guessr/{graph_builder.rb → debug_graph_builder.rb} +3 -3
- data/lib/ruby_lsp/type_guessr/debug_server.rb +2 -2
- 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 +46 -40
- data/lib/ruby_lsp/type_guessr/rails_server_addon.rb +83 -0
- data/lib/ruby_lsp/type_guessr/runtime_adapter.rb +90 -16
- data/lib/type-guessr.rb +2 -13
- data/lib/type_guessr/core/cache/gem_signature_cache.rb +3 -2
- data/lib/type_guessr/core/cache.rb +5 -0
- data/lib/{ruby_lsp/type_guessr → type_guessr/core}/config.rb +2 -2
- 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 +312 -0
- data/lib/type_guessr/core/converter/literal_converter.rb +217 -0
- data/lib/type_guessr/core/converter/prism_converter.rb +9 -1682
- data/lib/type_guessr/core/converter/rbs_converter.rb +15 -1
- 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.rb +3 -0
- data/lib/type_guessr/core/inference/resolver.rb +206 -208
- data/lib/type_guessr/core/inference.rb +4 -0
- data/lib/type_guessr/core/ir.rb +3 -0
- data/lib/type_guessr/core/logger.rb +3 -5
- data/lib/type_guessr/core/registry/method_registry.rb +9 -0
- data/lib/type_guessr/core/registry/signature_registry.rb +73 -16
- data/lib/type_guessr/core/registry.rb +6 -0
- data/lib/type_guessr/core/type_serializer.rb +18 -14
- data/lib/type_guessr/core/type_simplifier.rb +5 -5
- data/lib/type_guessr/core/types.rb +64 -22
- data/lib/type_guessr/core.rb +29 -0
- data/lib/type_guessr/mcp/server.rb +55 -46
- data/lib/type_guessr/mcp/standalone_runtime.rb +70 -110
- data/lib/type_guessr/version.rb +1 -1
- metadata +24 -4
- data/.mcp.json +0 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 189f36a0b1f14badab35c3ea71b7a93d7de66606378533f087dff92e3d0d3f3d
|
|
4
|
+
data.tar.gz: b68f73382fb0a2e171f19254c101125c10b83163555c14d8fb4b920c61288555
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14029ca04179fed4e548152d6a06303d70069ef27d097ec49f17d969b58c520175de6b3a1130facefc346a9fe302d0897fcdbada1d5087f08e3dfe601cbd88a8
|
|
7
|
+
data.tar.gz: c11ad3e708e2e7ec2aa0ed7813d18967af11a07462964f5fb8426bd6c12c871a1dbd03069ba2feef6dffe517e0dd5b5c1135080ba8bbe90910533c39ffe19467
|
data/README.md
CHANGED
|
@@ -89,7 +89,7 @@ TypeGuessr can run as a standalone [MCP](https://modelcontextprotocol.io/) serve
|
|
|
89
89
|
Using Claude Code CLI:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
claude mcp add type-guessr -- bundle exec
|
|
92
|
+
claude mcp add type-guessr -- bundle exec type-guessr mcp
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Or add to your project's `.mcp.json` manually:
|
|
@@ -99,7 +99,7 @@ Or add to your project's `.mcp.json` manually:
|
|
|
99
99
|
"mcpServers": {
|
|
100
100
|
"type-guessr": {
|
|
101
101
|
"command": "bundle",
|
|
102
|
-
"args": ["exec", "
|
|
102
|
+
"args": ["exec", "type-guessr", "mcp"]
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -108,7 +108,7 @@ Or add to your project's `.mcp.json` manually:
|
|
|
108
108
|
Or run directly:
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
bundle exec
|
|
111
|
+
bundle exec type-guessr mcp [project_path]
|
|
112
112
|
```
|
|
113
113
|
|
|
114
114
|
If `project_path` is omitted, the current directory is used.
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ruby_lsp/addon"
|
|
4
|
-
require_relative "config"
|
|
5
4
|
require_relative "constants"
|
|
6
5
|
require_relative "runtime_adapter"
|
|
7
6
|
require_relative "hover"
|
|
@@ -19,7 +18,7 @@ module RubyLsp
|
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
def activate(global_state, message_queue)
|
|
22
|
-
unless Config.enabled?
|
|
21
|
+
unless ::TypeGuessr::Core::Config.enabled?
|
|
23
22
|
message_queue << RubyLsp::Notification.window_log_message(
|
|
24
23
|
"[TypeGuessr] Disabled via config",
|
|
25
24
|
type: RubyLsp::Constant::MessageType::LOG
|
|
@@ -42,9 +41,9 @@ module RubyLsp
|
|
|
42
41
|
@runtime_adapter.swap_type_inferrer
|
|
43
42
|
|
|
44
43
|
# Start debug server if enabled
|
|
45
|
-
start_debug_server if Config.debug_server_enabled?
|
|
44
|
+
start_debug_server if ::TypeGuessr::Core::Config.debug_server_enabled?
|
|
46
45
|
|
|
47
|
-
debug_status = Config.debug? ? " (debug mode enabled)" : ""
|
|
46
|
+
debug_status = ::TypeGuessr::Core::Config.debug? ? " (debug mode enabled)" : ""
|
|
48
47
|
message_queue << RubyLsp::Notification.window_log_message(
|
|
49
48
|
"[TypeGuessr] Activated#{debug_status}",
|
|
50
49
|
type: RubyLsp::Constant::MessageType::LOG
|
|
@@ -100,7 +99,7 @@ module RubyLsp
|
|
|
100
99
|
end
|
|
101
100
|
|
|
102
101
|
private def start_debug_server
|
|
103
|
-
port = Config.debug_server_port
|
|
102
|
+
port = ::TypeGuessr::Core::Config.debug_server_port
|
|
104
103
|
warn("[TypeGuessr] Starting debug server on port #{port}...")
|
|
105
104
|
@debug_server = DebugServer.new(@global_state, @runtime_adapter, port: port)
|
|
106
105
|
@debug_server.start
|
|
@@ -119,7 +119,7 @@ module RubyLsp
|
|
|
119
119
|
entries = if @member_index
|
|
120
120
|
@member_index[pivot.name.to_s] || []
|
|
121
121
|
else
|
|
122
|
-
@index.fuzzy_search(pivot.name.to_s) do |entry|
|
|
122
|
+
@index.fuzzy_search(pivot.name.to_s).select do |entry|
|
|
123
123
|
entry.is_a?(RubyIndexer::Entry::Member) && entry.name == pivot.name.to_s
|
|
124
124
|
end
|
|
125
125
|
end
|
|
@@ -247,6 +247,23 @@ module RubyLsp
|
|
|
247
247
|
nil
|
|
248
248
|
end
|
|
249
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
|
+
|
|
250
267
|
# Rebuild @method_classes from current @member_index state.
|
|
251
268
|
private def rebuild_method_classes!
|
|
252
269
|
owner_methods = {}
|
|
@@ -4,7 +4,7 @@ module RubyLsp
|
|
|
4
4
|
module TypeGuessr
|
|
5
5
|
# Builds graph data from IR nodes for visualization
|
|
6
6
|
# Uses body_nodes structure with value/receiver/args edges
|
|
7
|
-
class
|
|
7
|
+
class DebugGraphBuilder
|
|
8
8
|
def initialize(runtime_adapter)
|
|
9
9
|
@runtime_adapter = runtime_adapter
|
|
10
10
|
end
|
|
@@ -230,9 +230,9 @@ module RubyLsp
|
|
|
230
230
|
|
|
231
231
|
# Serialize a node to hash format
|
|
232
232
|
private def serialize_node(node, node_key)
|
|
233
|
-
warn("[
|
|
233
|
+
warn("[DebugGraphBuilder] serialize_node: #{node_key}") if ::TypeGuessr::Core::Config.debug?
|
|
234
234
|
result = @runtime_adapter.infer_type(node)
|
|
235
|
-
warn("[
|
|
235
|
+
warn("[DebugGraphBuilder] infer_type done for: #{node_key}") if ::TypeGuessr::Core::Config.debug?
|
|
236
236
|
|
|
237
237
|
{
|
|
238
238
|
key: node_key,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "json"
|
|
5
5
|
require "cgi"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "debug_graph_builder"
|
|
7
7
|
|
|
8
8
|
module RubyLsp
|
|
9
9
|
module TypeGuessr
|
|
@@ -16,7 +16,7 @@ module RubyLsp
|
|
|
16
16
|
def initialize(global_state, runtime_adapter, port: DEFAULT_PORT)
|
|
17
17
|
@global_state = global_state
|
|
18
18
|
@runtime_adapter = runtime_adapter
|
|
19
|
-
@graph_builder =
|
|
19
|
+
@graph_builder = DebugGraphBuilder.new(runtime_adapter)
|
|
20
20
|
@port = port
|
|
21
21
|
@server = nil
|
|
22
22
|
@thread = nil
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ar_type_mapper"
|
|
4
|
+
require_relative "ar_schema_watcher"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module TypeGuessr
|
|
8
|
+
module Dsl
|
|
9
|
+
# ActiveRecord DSL adapter.
|
|
10
|
+
# Discovers AR models, fetches metadata via ServerAddon,
|
|
11
|
+
# and registers column/enum/association/scope types.
|
|
12
|
+
class ActiveRecordAdapter
|
|
13
|
+
BOOL_TYPE = ::TypeGuessr::Core::Types::Union.new([
|
|
14
|
+
::TypeGuessr::Core::Types::ClassInstance.for("TrueClass"),
|
|
15
|
+
::TypeGuessr::Core::Types::ClassInstance.for("FalseClass"),
|
|
16
|
+
]).freeze
|
|
17
|
+
|
|
18
|
+
SERVER_ADDON_NAME = "TypeGuessr"
|
|
19
|
+
|
|
20
|
+
def initialize(project_root:, cache_dir: nil)
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@schema_watcher = ArSchemaWatcher.new(project_root, cache_dir: cache_dir)
|
|
23
|
+
@registered_classes = []
|
|
24
|
+
@log_callback = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def on_log(&block)
|
|
28
|
+
@log_callback = block
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def applicable?
|
|
32
|
+
File.directory?(File.join(@project_root, "app", "models"))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Register AR::Base common class methods with SelfType.
|
|
36
|
+
# Called once. User.where → Relation[User] via SelfType substitution.
|
|
37
|
+
def register_base_methods(signature_registry:)
|
|
38
|
+
base = "ActiveRecord::Base"
|
|
39
|
+
self_type = ::TypeGuessr::Core::Types::SelfType.instance
|
|
40
|
+
relation_of_self = ::TypeGuessr::Core::Types::ClassInstance.for(
|
|
41
|
+
"ActiveRecord::Relation", { Elem: self_type }
|
|
42
|
+
)
|
|
43
|
+
nullable_self = ::TypeGuessr::Core::Types::Union.new([
|
|
44
|
+
self_type,
|
|
45
|
+
::TypeGuessr::Core::Types::ClassInstance.for("NilClass"),
|
|
46
|
+
])
|
|
47
|
+
int_type = ::TypeGuessr::Core::Types::ClassInstance.for("Integer")
|
|
48
|
+
bool_type = BOOL_TYPE
|
|
49
|
+
array_of_self = ::TypeGuessr::Core::Types::ClassInstance.for("Array")
|
|
50
|
+
|
|
51
|
+
# --- Class methods ---
|
|
52
|
+
|
|
53
|
+
# Returning Relation[self]
|
|
54
|
+
%w[
|
|
55
|
+
where all order limit offset select distinct group having reorder reverse_order
|
|
56
|
+
none unscoped reselect extending joins left_joins left_outer_joins
|
|
57
|
+
includes eager_load preload references readonly lock create_with rewhere
|
|
58
|
+
or and not invert_where merge
|
|
59
|
+
].each do |m|
|
|
60
|
+
signature_registry.register_gem_class_method(base, m, relation_of_self, force: true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returning self?
|
|
64
|
+
%w[first last second third forty_two take find_by find_sole_by].each do |m|
|
|
65
|
+
signature_registry.register_gem_class_method(base, m, nullable_self, force: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returning self
|
|
69
|
+
%w[
|
|
70
|
+
first! last! take! sole find_by! find
|
|
71
|
+
create create! new
|
|
72
|
+
find_or_create_by find_or_create_by! find_or_initialize_by
|
|
73
|
+
create_or_find_by create_or_find_by!
|
|
74
|
+
].each do |m|
|
|
75
|
+
signature_registry.register_gem_class_method(base, m, self_type, force: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returning Integer
|
|
79
|
+
%w[count update_all delete_all].each do |m|
|
|
80
|
+
signature_registry.register_gem_class_method(base, m, int_type, force: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returning Array
|
|
84
|
+
%w[ids pluck destroy_all].each do |m|
|
|
85
|
+
signature_registry.register_gem_class_method(base, m, array_of_self, force: true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returning bool
|
|
89
|
+
%w[exists? any? many? none? empty?].each do |m|
|
|
90
|
+
signature_registry.register_gem_class_method(base, m, bool_type, force: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Instance methods ---
|
|
94
|
+
|
|
95
|
+
# Returning bool
|
|
96
|
+
%w[
|
|
97
|
+
save save! update update! toggle! touch
|
|
98
|
+
new_record? persisted? destroyed? changed? previously_new_record? frozen?
|
|
99
|
+
has_changes_to_save? saved_changes? valid? invalid?
|
|
100
|
+
].each do |m|
|
|
101
|
+
signature_registry.register_gem_method(base, m, bool_type, force: true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returning self
|
|
105
|
+
%w[destroy destroy! reload toggle increment decrement increment! decrement!].each do |m|
|
|
106
|
+
signature_registry.register_gem_method(base, m, self_type, force: true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- Relation instance methods ---
|
|
110
|
+
rel = "ActiveRecord::Relation"
|
|
111
|
+
elem = ::TypeGuessr::Core::Types::TypeVariable.new(:Elem)
|
|
112
|
+
nullable_elem = ::TypeGuessr::Core::Types::Union.new([
|
|
113
|
+
elem,
|
|
114
|
+
::TypeGuessr::Core::Types::ClassInstance.for("NilClass"),
|
|
115
|
+
])
|
|
116
|
+
relation_of_elem = ::TypeGuessr::Core::Types::ClassInstance.for(
|
|
117
|
+
"ActiveRecord::Relation", { Elem: elem }
|
|
118
|
+
)
|
|
119
|
+
array_of_elem = ::TypeGuessr::Core::Types::ArrayType.new(elem)
|
|
120
|
+
|
|
121
|
+
# Returning Relation[Elem]
|
|
122
|
+
%w[
|
|
123
|
+
where or and not invert_where merge rewhere readonly lock create_with load reload
|
|
124
|
+
].each do |m|
|
|
125
|
+
signature_registry.register_gem_method(rel, m, relation_of_elem, force: true)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returning Elem?
|
|
129
|
+
%w[first last second take].each do |m|
|
|
130
|
+
signature_registry.register_gem_method(rel, m, nullable_elem, force: true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returning Integer
|
|
134
|
+
%w[size length count].each do |m|
|
|
135
|
+
signature_registry.register_gem_method(rel, m, int_type, force: true)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returning bool
|
|
139
|
+
%w[empty? any? many? none?].each do |m|
|
|
140
|
+
signature_registry.register_gem_method(rel, m, bool_type, force: true)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returning Array[Elem]
|
|
144
|
+
%w[to_a to_ary].each do |m|
|
|
145
|
+
signature_registry.register_gem_method(rel, m, array_of_elem, force: true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# --- CollectionProxy instance methods ---
|
|
149
|
+
cp = "ActiveRecord::Associations::CollectionProxy"
|
|
150
|
+
elem_type = ::TypeGuessr::Core::Types::TypeVariable.new(:Elem)
|
|
151
|
+
|
|
152
|
+
%w[build create create! new].each do |m|
|
|
153
|
+
signature_registry.register_gem_method(cp, m, elem_type, force: true)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
log("Registered AR::Base common methods (SelfType)")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Cache check → cache hit: restore, cache miss + runner_client: fetch → register → save.
|
|
160
|
+
def register_models(runner_client:, signature_registry:, code_index:)
|
|
161
|
+
cached = @schema_watcher.load_cache
|
|
162
|
+
if cached
|
|
163
|
+
apply_model_data(cached, signature_registry, code_index)
|
|
164
|
+
log("Loaded #{@registered_classes.size} models from DSL cache (fast path)")
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return unless runner_client
|
|
169
|
+
|
|
170
|
+
register_server_addon(runner_client)
|
|
171
|
+
models = discover_models
|
|
172
|
+
return if models.empty?
|
|
173
|
+
|
|
174
|
+
data = fetch_all_metadata(runner_client, models)
|
|
175
|
+
apply_model_data(data, signature_registry, code_index)
|
|
176
|
+
@schema_watcher.save_cache(data) unless data.empty?
|
|
177
|
+
log("Registered #{@registered_classes.size} models via ServerAddon")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def changed?
|
|
181
|
+
@schema_watcher.changed?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Build-then-swap: fetch new data first, then purge old + apply new.
|
|
185
|
+
def refresh(runner_client:, signature_registry:, code_index:)
|
|
186
|
+
return unless runner_client
|
|
187
|
+
|
|
188
|
+
register_server_addon(runner_client)
|
|
189
|
+
models = discover_models
|
|
190
|
+
new_data = fetch_all_metadata(runner_client, models)
|
|
191
|
+
|
|
192
|
+
# Swap: purge old, apply new
|
|
193
|
+
@registered_classes.each { |cn| code_index.unregister_method_classes(cn) }
|
|
194
|
+
@registered_classes.clear
|
|
195
|
+
apply_model_data(new_data, signature_registry, code_index)
|
|
196
|
+
@schema_watcher.save_cache(new_data) unless new_data.empty?
|
|
197
|
+
log("Refreshed #{@registered_classes.size} models via ServerAddon")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private def log(message)
|
|
201
|
+
@log_callback&.call(message)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private def register_server_addon(runner_client)
|
|
205
|
+
addon_path = File.expand_path("../rails_server_addon", __dir__)
|
|
206
|
+
runner_client.register_server_addon(addon_path)
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
log("Failed to register ServerAddon: #{e.message}")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private def discover_models
|
|
212
|
+
models_dir = File.join(@project_root, "app", "models")
|
|
213
|
+
return [] unless File.directory?(models_dir)
|
|
214
|
+
|
|
215
|
+
Dir.glob(File.join(models_dir, "**", "*.rb")).filter_map do |path|
|
|
216
|
+
relative = path.delete_prefix("#{models_dir}/").delete_suffix(".rb")
|
|
217
|
+
next if relative.start_with?("concerns/")
|
|
218
|
+
next if relative == "application_record"
|
|
219
|
+
|
|
220
|
+
relative.split("/").map { |part| camelize(part) }.join("::")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private def fetch_all_metadata(runner_client, models)
|
|
225
|
+
data = {}
|
|
226
|
+
|
|
227
|
+
models.each do |class_name|
|
|
228
|
+
result = runner_client.delegate_request(
|
|
229
|
+
server_addon_name: SERVER_ADDON_NAME,
|
|
230
|
+
request_name: "model_metadata",
|
|
231
|
+
name: class_name
|
|
232
|
+
)
|
|
233
|
+
next unless result.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
model_data = build_model_data(class_name, result)
|
|
236
|
+
data[class_name] = model_data unless model_data.empty?
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
log("Failed to fetch metadata for #{class_name}: #{e.message}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
data
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private def build_model_data(class_name, result)
|
|
245
|
+
methods = {}
|
|
246
|
+
build_column_methods(methods, result[:columns] || result["columns"] || [])
|
|
247
|
+
build_enum_methods(methods, class_name, result[:enums] || result["enums"] || {})
|
|
248
|
+
build_association_methods(methods, result[:associations] || result["associations"] || [])
|
|
249
|
+
build_scope_methods(methods, class_name, result[:scopes] || result["scopes"] || [])
|
|
250
|
+
methods
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private def build_column_methods(methods, columns)
|
|
254
|
+
columns.each do |col|
|
|
255
|
+
col_name, col_type, nullable = col
|
|
256
|
+
col_name = col_name.to_s
|
|
257
|
+
nullable = true if nullable.nil?
|
|
258
|
+
|
|
259
|
+
# Reader: user.name → String?
|
|
260
|
+
methods[col_name] = { "kind" => "column", "type" => col_type.to_s, "nullable" => nullable }
|
|
261
|
+
|
|
262
|
+
# Predicate: user.name? → bool
|
|
263
|
+
methods["#{col_name}?"] = { "kind" => "column_predicate" }
|
|
264
|
+
|
|
265
|
+
# Dirty tracking
|
|
266
|
+
methods["#{col_name}_changed?"] = { "kind" => "column_predicate" }
|
|
267
|
+
methods["#{col_name}_previously_changed?"] = { "kind" => "column_predicate" }
|
|
268
|
+
methods["saved_change_to_#{col_name}?"] = { "kind" => "column_predicate" }
|
|
269
|
+
methods["will_save_change_to_#{col_name}?"] = { "kind" => "column_predicate" }
|
|
270
|
+
methods["#{col_name}_was"] = { "kind" => "column", "type" => col_type.to_s, "nullable" => true }
|
|
271
|
+
methods["#{col_name}_in_database"] = { "kind" => "column", "type" => col_type.to_s, "nullable" => true }
|
|
272
|
+
methods["#{col_name}_before_last_save"] = { "kind" => "column", "type" => col_type.to_s, "nullable" => true }
|
|
273
|
+
methods["#{col_name}_will_change!"] = { "kind" => "column_predicate" }
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private def build_enum_methods(methods, class_name, enums)
|
|
278
|
+
enums.each do |attr_name, values|
|
|
279
|
+
methods[attr_name.to_s] = { "kind" => "enum_reader", "type" => "string", "nullable" => true }
|
|
280
|
+
|
|
281
|
+
values.each_key do |value_name|
|
|
282
|
+
methods["#{value_name}?"] = { "kind" => "enum_predicate" }
|
|
283
|
+
methods["#{value_name}!"] = { "kind" => "enum_bang" }
|
|
284
|
+
methods["scope:#{value_name}"] = { "kind" => "enum_scope", "class_name" => class_name }
|
|
285
|
+
methods["scope:not_#{value_name}"] = { "kind" => "enum_scope", "class_name" => class_name }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private def build_association_methods(methods, associations)
|
|
291
|
+
associations.each do |assoc|
|
|
292
|
+
name = (assoc[:name] || assoc["name"]).to_s
|
|
293
|
+
macro = (assoc[:macro] || assoc["macro"]).to_s
|
|
294
|
+
target = assoc[:class_name] || assoc["class_name"]
|
|
295
|
+
|
|
296
|
+
# Reader: user.posts / user.profile
|
|
297
|
+
methods[name] = { "kind" => "association", "macro" => macro, "target" => target }
|
|
298
|
+
|
|
299
|
+
# has_many: post_ids → Array[Integer]
|
|
300
|
+
if %w[has_many has_and_belongs_to_many].include?(macro)
|
|
301
|
+
singular = name.end_with?("s") ? name[0..-2] : name
|
|
302
|
+
methods["#{singular}_ids"] = { "kind" => "association_ids" }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Derived methods for singular associations (has_one, belongs_to)
|
|
306
|
+
next unless %w[has_one belongs_to].include?(macro)
|
|
307
|
+
|
|
308
|
+
methods["build_#{name}"] = { "kind" => "association_builder", "target" => target }
|
|
309
|
+
methods["create_#{name}"] = { "kind" => "association_builder", "target" => target }
|
|
310
|
+
methods["create_#{name}!"] = { "kind" => "association_builder", "target" => target }
|
|
311
|
+
methods["reload_#{name}"] = { "kind" => "association", "macro" => macro, "target" => target }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private def build_scope_methods(methods, class_name, scopes)
|
|
316
|
+
scopes.each do |scope_name|
|
|
317
|
+
methods["scope:#{scope_name}"] = { "kind" => "scope", "class_name" => class_name }
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private def apply_model_data(data, signature_registry, code_index)
|
|
322
|
+
data.each do |class_name, methods|
|
|
323
|
+
methods.each do |method_name, info|
|
|
324
|
+
case info["kind"]
|
|
325
|
+
when "column"
|
|
326
|
+
return_type = ArTypeMapper.map(info["type"], nullable: info.fetch("nullable", true))
|
|
327
|
+
register_instance_method(class_name, method_name, return_type, signature_registry, code_index)
|
|
328
|
+
when "enum_reader"
|
|
329
|
+
return_type = ArTypeMapper.map("string", nullable: true)
|
|
330
|
+
register_instance_method(class_name, method_name, return_type, signature_registry, code_index)
|
|
331
|
+
when "column_predicate"
|
|
332
|
+
register_instance_method(class_name, method_name, BOOL_TYPE, signature_registry, code_index)
|
|
333
|
+
when "enum_predicate"
|
|
334
|
+
register_instance_method(class_name, method_name, BOOL_TYPE, signature_registry, code_index)
|
|
335
|
+
when "enum_bang"
|
|
336
|
+
register_instance_method(
|
|
337
|
+
class_name, method_name,
|
|
338
|
+
::TypeGuessr::Core::Types::Unknown.instance,
|
|
339
|
+
signature_registry, code_index
|
|
340
|
+
)
|
|
341
|
+
when "enum_scope"
|
|
342
|
+
scope_name = method_name.delete_prefix("scope:")
|
|
343
|
+
register_class_scope(info["class_name"], scope_name, signature_registry)
|
|
344
|
+
when "association"
|
|
345
|
+
register_association(class_name, method_name, info["macro"], info["target"],
|
|
346
|
+
signature_registry, code_index)
|
|
347
|
+
when "association_builder"
|
|
348
|
+
target_type = ::TypeGuessr::Core::Types::ClassInstance.for(info["target"])
|
|
349
|
+
register_instance_method(class_name, method_name, target_type, signature_registry, code_index)
|
|
350
|
+
when "association_ids"
|
|
351
|
+
array_type = ::TypeGuessr::Core::Types::ArrayType.new(
|
|
352
|
+
::TypeGuessr::Core::Types::ClassInstance.for("Integer")
|
|
353
|
+
)
|
|
354
|
+
register_instance_method(class_name, method_name, array_type, signature_registry, code_index)
|
|
355
|
+
when "scope"
|
|
356
|
+
scope_name = method_name.delete_prefix("scope:")
|
|
357
|
+
register_class_scope(info["class_name"], scope_name, signature_registry)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
@registered_classes << class_name
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private def register_instance_method(class_name, method_name, return_type, signature_registry, code_index)
|
|
366
|
+
signature_registry.register_gem_method(class_name, method_name, return_type, force: true)
|
|
367
|
+
code_index.register_method_class(class_name, method_name)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
private def register_class_scope(class_name, scope_name, signature_registry)
|
|
371
|
+
return_type = ::TypeGuessr::Core::Types::ClassInstance.for(
|
|
372
|
+
"ActiveRecord::Relation",
|
|
373
|
+
{ Elem: ::TypeGuessr::Core::Types::ClassInstance.for(class_name) }
|
|
374
|
+
)
|
|
375
|
+
signature_registry.register_gem_class_method(class_name, scope_name, return_type, force: true)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private def register_association(class_name, assoc_name, macro, target, signature_registry, code_index)
|
|
379
|
+
return_type = case macro
|
|
380
|
+
when "has_many", "has_and_belongs_to_many"
|
|
381
|
+
::TypeGuessr::Core::Types::ClassInstance.for(
|
|
382
|
+
"ActiveRecord::Associations::CollectionProxy",
|
|
383
|
+
{ Elem: ::TypeGuessr::Core::Types::ClassInstance.for(target) }
|
|
384
|
+
)
|
|
385
|
+
when "has_one", "belongs_to"
|
|
386
|
+
::TypeGuessr::Core::Types::Union.new([
|
|
387
|
+
::TypeGuessr::Core::Types::ClassInstance.for(target),
|
|
388
|
+
::TypeGuessr::Core::Types::ClassInstance.for("NilClass"),
|
|
389
|
+
])
|
|
390
|
+
else
|
|
391
|
+
::TypeGuessr::Core::Types::Unknown.instance
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
register_instance_method(class_name, assoc_name, return_type, signature_registry, code_index)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
private def camelize(str)
|
|
398
|
+
str.split("_").map(&:capitalize).join
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module RubyLsp
|
|
8
|
+
module TypeGuessr
|
|
9
|
+
module Dsl
|
|
10
|
+
# Watches AR schema files (db/schema.rb, db/structure.sql) for changes
|
|
11
|
+
# and manages a disk cache for DSL-generated type data.
|
|
12
|
+
#
|
|
13
|
+
# AR-specific: Mongoid has no schema file concept and needs a different strategy.
|
|
14
|
+
class ArSchemaWatcher
|
|
15
|
+
CACHE_VERSION = 1
|
|
16
|
+
|
|
17
|
+
def initialize(project_root, cache_dir: nil)
|
|
18
|
+
@project_root = project_root
|
|
19
|
+
@cache_dir = cache_dir || default_cache_dir
|
|
20
|
+
@last_hash = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schema_files
|
|
24
|
+
patterns = [
|
|
25
|
+
File.join(@project_root, "db", "**", "schema.rb"),
|
|
26
|
+
File.join(@project_root, "db", "**", "structure.sql"),
|
|
27
|
+
]
|
|
28
|
+
patterns.flat_map { |p| Dir.glob(p) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def current_hash
|
|
32
|
+
files = schema_files
|
|
33
|
+
return "empty" if files.empty?
|
|
34
|
+
|
|
35
|
+
digest = Digest::SHA256.new
|
|
36
|
+
files.each do |f|
|
|
37
|
+
digest.update(f)
|
|
38
|
+
digest.update(File.read(f))
|
|
39
|
+
end
|
|
40
|
+
digest.hexdigest
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def changed?
|
|
44
|
+
hash = current_hash
|
|
45
|
+
changed = @last_hash.nil? || @last_hash != hash
|
|
46
|
+
@last_hash = hash
|
|
47
|
+
changed
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def load_cache
|
|
51
|
+
path = cache_path
|
|
52
|
+
return nil unless File.exist?(path)
|
|
53
|
+
|
|
54
|
+
data = JSON.parse(File.read(path))
|
|
55
|
+
return nil unless data["version"] == CACHE_VERSION
|
|
56
|
+
|
|
57
|
+
hash = current_hash
|
|
58
|
+
return nil unless data["schema_hash"] == hash
|
|
59
|
+
|
|
60
|
+
@last_hash = hash
|
|
61
|
+
data["models"]
|
|
62
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save_cache(models_data)
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(cache_path))
|
|
68
|
+
|
|
69
|
+
hash = current_hash
|
|
70
|
+
data = {
|
|
71
|
+
"version" => CACHE_VERSION,
|
|
72
|
+
"schema_hash" => hash,
|
|
73
|
+
"models" => models_data
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
File.write(cache_path, JSON.generate(data))
|
|
77
|
+
@last_hash = hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clear_cache
|
|
81
|
+
FileUtils.rm_f(cache_path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def cache_path
|
|
85
|
+
project_dir = @project_root.gsub(%r{[/\\]}, "-").gsub(/\A-/, "")
|
|
86
|
+
File.join(@cache_dir, project_dir, "cache.json")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def default_cache_dir
|
|
90
|
+
xdg_cache = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
|
|
91
|
+
File.join(xdg_cache, "type-guessr", "dsl-cache")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|