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
@@ -7,9 +7,12 @@ module TypeGuessr
7
7
  # Simplifies types by unwrapping single-element unions and
8
8
  # unifying parent/child class relationships
9
9
  class TypeSimplifier
10
- # @param ancestry_provider [Proc, nil] A proc that takes class_name and returns array of ancestor names
11
- def initialize(ancestry_provider: nil)
12
- @ancestry_provider = ancestry_provider
10
+ # Maximum number of types in a Union before considering it too ambiguous
11
+ MAX_ELEMENT_IN_UNION = 3
12
+
13
+ # @param code_index [#ancestors_of, nil] Adapter for inheritance lookup
14
+ def initialize(code_index: nil)
15
+ @code_index = code_index
13
16
  end
14
17
 
15
18
  # Simplify a type
@@ -24,28 +27,28 @@ module TypeGuessr
24
27
  end
25
28
  end
26
29
 
27
- private
28
-
29
- def simplify_union(union)
30
+ private def simplify_union(union)
30
31
  types = union.types
31
32
 
32
33
  # 1. Single element: unwrap
33
34
  return types.first if types.size == 1
34
35
 
35
- # 2. Filter to most general types (remove children when parent is present)
36
- types = filter_to_most_general_types(types) if @ancestry_provider
36
+ # 2. Remove subclass types when parent class is also present
37
+ types = remove_subclass_types(types) if @code_index
37
38
 
38
39
  # 3. Check again after filtering
39
40
  return types.first if types.size == 1
40
41
 
42
+ return Types::Unknown.instance if types.size > MAX_ELEMENT_IN_UNION
43
+
41
44
  # 4. Multiple elements remain: create new Union
42
45
  Types::Union.new(types)
43
46
  end
44
47
 
45
- # Filter out types whose ancestor is also in the list
48
+ # Remove types whose ancestor is also in the list (e.g., remove Integer when Numeric is present)
46
49
  # @param types [Array<Types::Type>] List of types
47
- # @return [Array<Types::Type>] Filtered list with only the most general types
48
- def filter_to_most_general_types(types)
50
+ # @return [Array<Types::Type>] Filtered list with subclass types removed
51
+ private def remove_subclass_types(types)
49
52
  # Extract class names from ClassInstance types
50
53
  class_names = types.filter_map do |t|
51
54
  t.name if t.is_a?(Types::ClassInstance)
@@ -54,7 +57,7 @@ module TypeGuessr
54
57
  types.reject do |type|
55
58
  next false unless type.is_a?(Types::ClassInstance)
56
59
 
57
- ancestors = @ancestry_provider.call(type.name)
60
+ ancestors = @code_index.ancestors_of(type.name)
58
61
  # Check if any ancestor (excluding self) is also in the list
59
62
  ancestors.any? { |ancestor| ancestor != type.name && class_names.include?(ancestor) }
60
63
  end
@@ -36,9 +36,15 @@ module TypeGuessr
36
36
  # Get type variable substitutions for this type
37
37
  # Used for substituting type variables in block parameters
38
38
  # @return [Hash{Symbol => Type}] type variable substitutions (e.g., { Elem: Integer, K: Symbol, V: String })
39
- def type_variable_substitutions
39
+ def type_parameter_bindings
40
40
  {}
41
41
  end
42
+
43
+ # Readable inspect output for debugging
44
+ # @return [String]
45
+ def inspect
46
+ "#<#{self.class.name.split("::").last}>"
47
+ end
42
48
  end
43
49
 
44
50
  # Unknown type - no information available
@@ -50,32 +56,87 @@ module TypeGuessr
50
56
  end
51
57
  end
52
58
 
59
+ # Unguessed type - type exists but has not been inferred yet
60
+ # Used for lazy gem inference: method signatures are cached with
61
+ # Unguessed return/param types until background inference completes.
62
+ class Unguessed < Type
63
+ include Singleton
64
+
65
+ def to_s
66
+ "unguessed"
67
+ end
68
+ end
69
+
53
70
  # ClassInstance - instance of a class
71
+ # @param type_params [Hash{Symbol => Type}, nil] type parameters (e.g., { A: String } for Set[String])
54
72
  class ClassInstance < Type
55
- attr_reader :name
73
+ CACHE = {} # rubocop:disable Style/MutableConstant
74
+ GENERIC_CACHE = {} # rubocop:disable Style/MutableConstant
75
+
76
+ # Factory method that caches instances for reuse
77
+ # @param name [String] The class name
78
+ # @param type_params [Hash{Symbol => Type}, nil] type parameters
79
+ # @return [ClassInstance] Cached or new instance
80
+ def self.for(name, type_params = nil)
81
+ if type_params.nil?
82
+ CACHE[name] ||= new(name).freeze
83
+ else
84
+ key = [name, type_params]
85
+ GENERIC_CACHE[key] ||= new(name, type_params).freeze
86
+ end
87
+ end
56
88
 
57
- def initialize(name)
89
+ attr_reader :name, :type_params
90
+
91
+ def initialize(name, type_params = nil)
58
92
  super()
59
93
  @name = name
94
+ @type_params = type_params&.freeze
60
95
  end
61
96
 
62
97
  def eql?(other)
63
- super && @name == other.name
98
+ super && @name == other.name && @type_params == other.type_params
64
99
  end
65
100
 
66
101
  def hash
67
- [self.class, @name].hash
102
+ [self.class, @name, @type_params].hash
68
103
  end
69
104
 
70
105
  def to_s
71
- case @name
72
- when "NilClass" then "nil"
73
- when "TrueClass" then "true"
74
- when "FalseClass" then "false"
75
- else @name
106
+ base = case @name
107
+ when "NilClass" then "nil"
108
+ when "TrueClass" then "true"
109
+ when "FalseClass" then "false"
110
+ else @name
111
+ end
112
+ if @type_params&.any?
113
+ "#{base}[#{@type_params.values.join(", ")}]"
114
+ else
115
+ base
116
+ end
117
+ end
118
+
119
+ def inspect
120
+ if @type_params&.any?
121
+ "#<ClassInstance:#{@name}[#{@type_params.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")}]>"
122
+ else
123
+ "#<ClassInstance:#{@name}>"
76
124
  end
77
125
  end
78
126
 
127
+ def type_parameter_bindings
128
+ @type_params || {}
129
+ end
130
+
131
+ def substitute(substitutions)
132
+ return self if @type_params.nil? || @type_params.empty?
133
+
134
+ new_params = @type_params.transform_values { |t| t.substitute(substitutions) }
135
+ return self if new_params == @type_params
136
+
137
+ ClassInstance.new(@name, new_params)
138
+ end
139
+
79
140
  def rbs_class_name
80
141
  @name
81
142
  end
@@ -102,6 +163,10 @@ module TypeGuessr
102
163
  "singleton(#{@name})"
103
164
  end
104
165
 
166
+ def inspect
167
+ "#<SingletonType:#{@name}>"
168
+ end
169
+
105
170
  def rbs_class_name
106
171
  @name
107
172
  end
@@ -111,7 +176,7 @@ module TypeGuessr
111
176
  class Union < Type
112
177
  attr_reader :types
113
178
 
114
- def initialize(types, cutoff: RubyLsp::TypeGuessr::Config.union_cutoff)
179
+ def initialize(types, cutoff: 10)
115
180
  super()
116
181
  @types = normalize(types, cutoff)
117
182
  end
@@ -130,13 +195,19 @@ module TypeGuessr
130
195
  def to_s
131
196
  if bool_type?
132
197
  "bool"
198
+ elsif nullable_bool_type?
199
+ "bool?"
133
200
  elsif optional_type?
134
- "?#{non_nil_type}"
201
+ "#{non_nil_type}?"
135
202
  else
136
203
  @types.map(&:to_s).sort.join(" | ")
137
204
  end
138
205
  end
139
206
 
207
+ def inspect
208
+ "#<Union:#{@types.join("|")}>"
209
+ end
210
+
140
211
  def substitute(substitutions)
141
212
  new_types = @types.map { |t| t.substitute(substitutions) }
142
213
  return self if new_types.zip(@types).all? { |new_t, old_t| new_t.equal?(old_t) }
@@ -144,9 +215,7 @@ module TypeGuessr
144
215
  Union.new(new_types)
145
216
  end
146
217
 
147
- private
148
-
149
- def bool_type?
218
+ private def bool_type?
150
219
  return false unless @types.size == 2
151
220
 
152
221
  has_true = @types.any? { |t| t.is_a?(ClassInstance) && t.name == "TrueClass" }
@@ -154,19 +223,28 @@ module TypeGuessr
154
223
  has_true && has_false
155
224
  end
156
225
 
157
- def optional_type?
226
+ private def nullable_bool_type?
227
+ return false unless @types.size == 3
228
+
229
+ has_true = @types.any? { |t| t.is_a?(ClassInstance) && t.name == "TrueClass" }
230
+ has_false = @types.any? { |t| t.is_a?(ClassInstance) && t.name == "FalseClass" }
231
+ has_nil = @types.any? { |t| nil_type?(t) }
232
+ has_true && has_false && has_nil
233
+ end
234
+
235
+ private def optional_type?
158
236
  @types.size == 2 && @types.any? { |t| nil_type?(t) }
159
237
  end
160
238
 
161
- def nil_type?(type)
239
+ private def nil_type?(type)
162
240
  type.is_a?(ClassInstance) && type.name == "NilClass"
163
241
  end
164
242
 
165
- def non_nil_type
243
+ private def non_nil_type
166
244
  @types.find { |t| !nil_type?(t) }
167
245
  end
168
246
 
169
- def normalize(types, cutoff)
247
+ private def normalize(types, cutoff)
170
248
  # Flatten nested unions
171
249
  flattened = flatten_unions(types)
172
250
 
@@ -180,20 +258,20 @@ module TypeGuessr
180
258
  apply_cutoff(filtered, cutoff)
181
259
  end
182
260
 
183
- def flatten_unions(types)
261
+ private def flatten_unions(types)
184
262
  types.flat_map do |type|
185
263
  type.is_a?(Union) ? type.types : type
186
264
  end
187
265
  end
188
266
 
189
- def simplify_if_unknown_present(types)
267
+ private def simplify_if_unknown_present(types)
190
268
  return types if types.size <= 1
191
269
 
192
- has_unknown = types.any? { |t| t.is_a?(Unknown) }
270
+ has_unknown = types.any?(Unknown)
193
271
  has_unknown ? [Unknown.instance] : types
194
272
  end
195
273
 
196
- def apply_cutoff(types, cutoff)
274
+ private def apply_cutoff(types, cutoff)
197
275
  types.take(cutoff)
198
276
  end
199
277
  end
@@ -219,6 +297,10 @@ module TypeGuessr
219
297
  "Array[#{@element_type}]"
220
298
  end
221
299
 
300
+ def inspect
301
+ "#<ArrayType:#{@element_type}>"
302
+ end
303
+
222
304
  def substitute(substitutions)
223
305
  new_element = @element_type.substitute(substitutions)
224
306
  return self if new_element.equal?(@element_type)
@@ -230,11 +312,66 @@ module TypeGuessr
230
312
  "Array"
231
313
  end
232
314
 
233
- def type_variable_substitutions
315
+ def type_parameter_bindings
234
316
  { Elem: @element_type }
235
317
  end
236
318
  end
237
319
 
320
+ # TupleType - array with per-position element types
321
+ # Preserves positional type information for mixed-type array literals
322
+ # Falls back to ArrayType when element count exceeds MAX_ELEMENTS
323
+ class TupleType < Type
324
+ attr_reader :element_types
325
+
326
+ MAX_ELEMENTS = 8
327
+
328
+ def self.new(element_types)
329
+ return ArrayType.new(Union.new(element_types)) if element_types.size > MAX_ELEMENTS
330
+
331
+ super
332
+ end
333
+
334
+ def initialize(element_types)
335
+ super()
336
+ @element_types = element_types
337
+ end
338
+
339
+ def eql?(other)
340
+ super && @element_types == other.element_types
341
+ end
342
+
343
+ def hash
344
+ [self.class, @element_types].hash
345
+ end
346
+
347
+ def to_s
348
+ "[#{@element_types.join(", ")}]"
349
+ end
350
+
351
+ def inspect
352
+ "#<TupleType:#{self}>"
353
+ end
354
+
355
+ def substitute(substitutions)
356
+ new_types = @element_types.map { |t| t.substitute(substitutions) }
357
+ return self if new_types.zip(@element_types).all? { |n, o| n.equal?(o) }
358
+
359
+ TupleType.new(new_types)
360
+ end
361
+
362
+ def rbs_class_name
363
+ "Array"
364
+ end
365
+
366
+ def type_parameter_bindings
367
+ return { Elem: Unknown.instance } if @element_types.empty?
368
+
369
+ unique = @element_types.uniq
370
+ elem = unique.size == 1 ? unique.first : Union.new(unique)
371
+ { Elem: elem }
372
+ end
373
+ end
374
+
238
375
  # HashType - hash with key and value types
239
376
  class HashType < Type
240
377
  attr_reader :key_type, :value_type
@@ -257,6 +394,10 @@ module TypeGuessr
257
394
  "Hash[#{@key_type}, #{@value_type}]"
258
395
  end
259
396
 
397
+ def inspect
398
+ "#<HashType:#{@key_type},#{@value_type}>"
399
+ end
400
+
260
401
  def substitute(substitutions)
261
402
  new_key = @key_type.substitute(substitutions)
262
403
  new_value = @value_type.substitute(substitutions)
@@ -269,7 +410,7 @@ module TypeGuessr
269
410
  "Hash"
270
411
  end
271
412
 
272
- def type_variable_substitutions
413
+ def type_parameter_bindings
273
414
  { K: @key_type, V: @value_type }
274
415
  end
275
416
  end
@@ -295,6 +436,10 @@ module TypeGuessr
295
436
  "Range[#{@element_type}]"
296
437
  end
297
438
 
439
+ def inspect
440
+ "#<RangeType:#{@element_type}>"
441
+ end
442
+
298
443
  def substitute(substitutions)
299
444
  new_element = @element_type.substitute(substitutions)
300
445
  return self if new_element.equal?(@element_type)
@@ -306,7 +451,7 @@ module TypeGuessr
306
451
  "Range"
307
452
  end
308
453
 
309
- def type_variable_substitutions
454
+ def type_parameter_bindings
310
455
  { Elem: @element_type }
311
456
  end
312
457
  end
@@ -315,9 +460,13 @@ module TypeGuessr
315
460
  class HashShape < Type
316
461
  attr_reader :fields
317
462
 
318
- def self.new(fields, max_fields: RubyLsp::TypeGuessr::Config.hash_shape_max_fields)
319
- # Widen to generic Hash when too many fields
320
- return ClassInstance.new("Hash") if fields.size > max_fields
463
+ def self.new(fields, max_fields: 15)
464
+ # Widen to generic HashType when too many fields
465
+ if fields.size > max_fields
466
+ value_types = fields.values.uniq
467
+ value_type = value_types.size == 1 ? value_types.first : Union.new(value_types)
468
+ return HashType.new(ClassInstance.for("Symbol"), value_type)
469
+ end
321
470
 
322
471
  super(fields)
323
472
  end
@@ -342,7 +491,11 @@ module TypeGuessr
342
491
  "{ #{fields_str} }"
343
492
  end
344
493
 
345
- def merge_field(key, value_type, max_fields: RubyLsp::TypeGuessr::Config.hash_shape_max_fields)
494
+ def inspect
495
+ "#<HashShape:#{self}>"
496
+ end
497
+
498
+ def merge_field(key, value_type, max_fields: 15)
346
499
  new_fields = @fields.merge(key => value_type)
347
500
  HashShape.new(new_fields, max_fields: max_fields)
348
501
  end
@@ -358,8 +511,8 @@ module TypeGuessr
358
511
  "Hash"
359
512
  end
360
513
 
361
- def type_variable_substitutions
362
- key_type = ClassInstance.new("Symbol")
514
+ def type_parameter_bindings
515
+ key_type = ClassInstance.for("Symbol")
363
516
  value_types = @fields.values.uniq
364
517
  value_type = if value_types.empty?
365
518
  Unknown.instance
@@ -393,6 +546,10 @@ module TypeGuessr
393
546
  @name.to_s
394
547
  end
395
548
 
549
+ def inspect
550
+ "#<TypeVariable:#{@name}>"
551
+ end
552
+
396
553
  def substitute(substitutions)
397
554
  substitutions[@name] || self
398
555
  end
@@ -420,6 +577,67 @@ module TypeGuessr
420
577
  "..."
421
578
  end
422
579
  end
580
+
581
+ # ParamSignature - structural component of MethodSignature (not a Type)
582
+ # Represents a single parameter with its name, kind, and inferred type
583
+ ParamSignature = Data.define(:name, :kind, :type) do
584
+ def to_s
585
+ type_str = type.to_s
586
+ case kind
587
+ when :required then "#{type_str} #{name}"
588
+ when :optional then "?#{type_str} #{name}"
589
+ when :rest then "*#{type_str} #{name}"
590
+ when :keyword_required then "#{name}: #{type_str}"
591
+ when :keyword_optional then "#{name}: ?#{type_str}"
592
+ when :keyword_rest then "**#{type_str} #{name}"
593
+ when :block then "&#{type_str} #{name}"
594
+ when :forwarding then "..."
595
+ end
596
+ end
597
+ end
598
+
599
+ # MethodSignature - first-class type for Proc/Lambda/Method signatures
600
+ # Follows the same pattern as ArrayType: holds inner types and delegates substitute
601
+ class MethodSignature < Type
602
+ attr_reader :params, :return_type
603
+
604
+ def initialize(params, return_type)
605
+ super()
606
+ @params = params
607
+ @return_type = return_type
608
+ end
609
+
610
+ def eql?(other)
611
+ super && @params == other.params && @return_type == other.return_type
612
+ end
613
+
614
+ def hash
615
+ [self.class, @params, @return_type].hash
616
+ end
617
+
618
+ def to_s
619
+ params_str = @params.join(", ")
620
+ "(#{params_str}) -> #{@return_type}"
621
+ end
622
+
623
+ def inspect
624
+ "#<MethodSignature:#{self}>"
625
+ end
626
+
627
+ def substitute(substitutions)
628
+ new_params = @params.map do |p|
629
+ ParamSignature.new(name: p.name, kind: p.kind, type: p.type.substitute(substitutions))
630
+ end
631
+ new_return_type = @return_type.substitute(substitutions)
632
+ return self if new_params == @params && new_return_type.equal?(@return_type)
633
+
634
+ MethodSignature.new(new_params, new_return_type)
635
+ end
636
+
637
+ def rbs_class_name
638
+ "Proc"
639
+ end
640
+ end
423
641
  end
424
642
  end
425
643
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configuration
4
+ require_relative "core/config"
5
+
6
+ # Core type system
7
+ require_relative "core/types"
8
+
9
+ # Node infrastructure
10
+ require_relative "core/node_key_generator"
11
+ require_relative "core/node_context_helper"
12
+ require_relative "core/ir"
13
+ require_relative "core/index"
14
+
15
+ # Registries
16
+ require_relative "core/registry"
17
+
18
+ # Converters
19
+ require_relative "core/converter"
20
+
21
+ # Inference
22
+ require_relative "core/inference"
23
+
24
+ # Utilities
25
+ require_relative "core/signature_builder"
26
+ require_relative "core/type_simplifier"
27
+ require_relative "core/type_serializer"
28
+ require_relative "core/cache"
29
+ require_relative "core/logger"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeGuessr
4
+ module MCP
5
+ # Watches a project directory for .rb file changes using mtime polling.
6
+ # Detects modified, added, and deleted files and invokes a callback.
7
+ #
8
+ # Usage:
9
+ # watcher = FileWatcher.new(project_path: "/path/to/project", interval: 2) do |modified, added, removed|
10
+ # modified.each { |f| reindex(f) }
11
+ # removed.each { |f| remove(f) }
12
+ # end
13
+ # watcher.start
14
+ class FileWatcher
15
+ # @param project_path [String] Root directory to watch
16
+ # @param interval [Numeric] Polling interval in seconds (default: 2)
17
+ # @param on_change [Proc] Callback receiving (modified, added, removed) arrays
18
+ def initialize(project_path:, on_change:, interval: 2)
19
+ @project_path = project_path
20
+ @interval = interval
21
+ @on_change = on_change
22
+ @thread = nil
23
+ @running = false
24
+ end
25
+
26
+ def start
27
+ @running = true
28
+ @snapshot = take_snapshot
29
+ @thread = Thread.new { poll_loop }
30
+ @thread.abort_on_exception = true
31
+ end
32
+
33
+ def stop
34
+ @running = false
35
+ @thread&.join(5)
36
+ @thread = nil
37
+ end
38
+
39
+ def running?
40
+ @running && @thread&.alive?
41
+ end
42
+
43
+ private def poll_loop
44
+ while @running
45
+ sleep(@interval)
46
+ check_changes
47
+ end
48
+ end
49
+
50
+ private def check_changes
51
+ current = take_snapshot
52
+ previous = @snapshot
53
+
54
+ modified = []
55
+ added = []
56
+ removed = []
57
+
58
+ current.each do |path, mtime|
59
+ if previous.key?(path)
60
+ modified << path if mtime > previous[path]
61
+ else
62
+ added << path
63
+ end
64
+ end
65
+
66
+ previous.each_key do |path|
67
+ removed << path unless current.key?(path)
68
+ end
69
+
70
+ @snapshot = current
71
+
72
+ return if modified.empty? && added.empty? && removed.empty?
73
+
74
+ @on_change.call(modified, added, removed)
75
+ end
76
+
77
+ private def take_snapshot
78
+ pattern = File.join(@project_path, "**", "*.rb")
79
+ Dir.glob(pattern).each_with_object({}) do |path, hash|
80
+ hash[path] = File.mtime(path)
81
+ rescue Errno::ENOENT
82
+ # File deleted between glob and mtime - skip
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end