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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/ruby_lsp/type_guessr/addon.rb +4 -5
  4. data/lib/ruby_lsp/type_guessr/code_index_adapter.rb +18 -1
  5. data/lib/ruby_lsp/type_guessr/{graph_builder.rb → debug_graph_builder.rb} +3 -3
  6. data/lib/ruby_lsp/type_guessr/debug_server.rb +2 -2
  7. data/lib/ruby_lsp/type_guessr/dsl/activerecord_adapter.rb +404 -0
  8. data/lib/ruby_lsp/type_guessr/dsl/ar_schema_watcher.rb +96 -0
  9. data/lib/ruby_lsp/type_guessr/dsl/ar_type_mapper.rb +51 -0
  10. data/lib/ruby_lsp/type_guessr/dsl.rb +3 -0
  11. data/lib/ruby_lsp/type_guessr/dsl_type_registrar.rb +60 -0
  12. data/lib/ruby_lsp/type_guessr/hover.rb +46 -40
  13. data/lib/ruby_lsp/type_guessr/rails_server_addon.rb +83 -0
  14. data/lib/ruby_lsp/type_guessr/runtime_adapter.rb +90 -16
  15. data/lib/type-guessr.rb +2 -13
  16. data/lib/type_guessr/core/cache/gem_signature_cache.rb +3 -2
  17. data/lib/type_guessr/core/cache.rb +5 -0
  18. data/lib/{ruby_lsp/type_guessr → type_guessr/core}/config.rb +2 -2
  19. data/lib/type_guessr/core/converter/call_converter.rb +161 -0
  20. data/lib/type_guessr/core/converter/container_mutation_converter.rb +241 -0
  21. data/lib/type_guessr/core/converter/context.rb +144 -0
  22. data/lib/type_guessr/core/converter/control_flow_converter.rb +425 -0
  23. data/lib/type_guessr/core/converter/definition_converter.rb +312 -0
  24. data/lib/type_guessr/core/converter/literal_converter.rb +217 -0
  25. data/lib/type_guessr/core/converter/prism_converter.rb +9 -1682
  26. data/lib/type_guessr/core/converter/rbs_converter.rb +15 -1
  27. data/lib/type_guessr/core/converter/registration.rb +100 -0
  28. data/lib/type_guessr/core/converter/variable_converter.rb +225 -0
  29. data/lib/type_guessr/core/converter.rb +4 -0
  30. data/lib/type_guessr/core/index.rb +3 -0
  31. data/lib/type_guessr/core/inference/resolver.rb +206 -208
  32. data/lib/type_guessr/core/inference.rb +4 -0
  33. data/lib/type_guessr/core/ir.rb +3 -0
  34. data/lib/type_guessr/core/logger.rb +3 -5
  35. data/lib/type_guessr/core/registry/method_registry.rb +9 -0
  36. data/lib/type_guessr/core/registry/signature_registry.rb +73 -16
  37. data/lib/type_guessr/core/registry.rb +6 -0
  38. data/lib/type_guessr/core/type_serializer.rb +18 -14
  39. data/lib/type_guessr/core/type_simplifier.rb +5 -5
  40. data/lib/type_guessr/core/types.rb +64 -22
  41. data/lib/type_guessr/core.rb +29 -0
  42. data/lib/type_guessr/mcp/server.rb +55 -46
  43. data/lib/type_guessr/mcp/standalone_runtime.rb +70 -110
  44. data/lib/type_guessr/version.rb +1 -1
  45. metadata +24 -4
  46. data/.mcp.json +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fde9ec92176bfee761b076af417d5bd417720c0535f1171ed39005a4dfb7ff5f
4
- data.tar.gz: 6c99963862f3304fc4e616c8d823a6df9defd0d5d4a71176d2b5fecee696d14c
3
+ metadata.gz: 189f36a0b1f14badab35c3ea71b7a93d7de66606378533f087dff92e3d0d3f3d
4
+ data.tar.gz: b68f73382fb0a2e171f19254c101125c10b83163555c14d8fb4b920c61288555
5
5
  SHA512:
6
- metadata.gz: bbc08313fbdf2e00d4bcf07bc7208534db02ee4500d3af7ca0fc9f2cb2d501f8365944d8aba549cb4a96112a3217a7d145ee6a6c7e8f21fd2c5b0cde7b5c1902
7
- data.tar.gz: 2b0cff3ce05f061d4ff6c6d48da8e34e6ddc5cc60fd7ecfbfc8a455f7ff83551ece15814010329361a7ef13dd4433526ecda84e750db9907f3b36cc21d99ead1
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 ruby /path/to/type-guessr/exe/type-guessr mcp
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", "ruby", "/path/to/type-guessr/exe/type-guessr", "mcp"]
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 ruby exe/type-guessr mcp [project_path]
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 GraphBuilder
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("[GraphBuilder] serialize_node: #{node_key}") if Config.debug?
233
+ warn("[DebugGraphBuilder] serialize_node: #{node_key}") if ::TypeGuessr::Core::Config.debug?
234
234
  result = @runtime_adapter.infer_type(node)
235
- warn("[GraphBuilder] infer_type done for: #{node_key}") if Config.debug?
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 "graph_builder"
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 = GraphBuilder.new(runtime_adapter)
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