rigortype 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -7
  3. data/data/builtins/ruby_core/array.yml +1470 -0
  4. data/data/builtins/ruby_core/file.yml +501 -0
  5. data/data/builtins/ruby_core/hash.yml +936 -0
  6. data/data/builtins/ruby_core/io.yml +1594 -0
  7. data/data/builtins/ruby_core/numeric.yml +1809 -0
  8. data/data/builtins/ruby_core/range.yml +389 -0
  9. data/data/builtins/ruby_core/set.yml +594 -0
  10. data/data/builtins/ruby_core/string.yml +1850 -0
  11. data/data/builtins/ruby_core/time.yml +750 -0
  12. data/lib/rigor/analysis/check_rules.rb +97 -4
  13. data/lib/rigor/analysis/runner.rb +4 -0
  14. data/lib/rigor/builtins/imported_refinements.rb +251 -0
  15. data/lib/rigor/configuration.rb +6 -1
  16. data/lib/rigor/inference/acceptance.rb +324 -6
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  19. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  20. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  21. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  22. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  23. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  24. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  25. data/lib/rigor/inference/expression_typer.rb +48 -1
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
  27. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  30. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
  32. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +376 -4
  35. data/lib/rigor/inference/scope_indexer.rb +10 -2
  36. data/lib/rigor/inference/statement_evaluator.rb +213 -2
  37. data/lib/rigor/rbs_extended.rb +230 -15
  38. data/lib/rigor/scope.rb +14 -0
  39. data/lib/rigor/type/combinator.rb +159 -1
  40. data/lib/rigor/type/difference.rb +155 -0
  41. data/lib/rigor/type/integer_range.rb +137 -0
  42. data/lib/rigor/type/intersection.rb +135 -0
  43. data/lib/rigor/type/refined.rb +174 -0
  44. data/lib/rigor/type.rb +4 -0
  45. data/lib/rigor/version.rb +1 -1
  46. data/sig/rigor/rbs_extended.rbs +14 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +91 -1
  49. metadata +25 -1
@@ -88,14 +88,51 @@ module Rigor
88
88
 
89
89
  # @return [Rigor::Type, nil] the precise element/value type, or
90
90
  # `nil` to defer to the next dispatcher tier.
91
+ # Per-carrier dispatch table. Adding a new carrier here
92
+ # is a one-row change; the helper methods stay private.
93
+ # Anonymous Type subclasses are not expected.
94
+ RECEIVER_HANDLERS = {
95
+ Type::Tuple => :dispatch_tuple,
96
+ Type::HashShape => :dispatch_hash_shape,
97
+ Type::Nominal => :dispatch_nominal_size,
98
+ Type::Difference => :dispatch_difference,
99
+ Type::Refined => :dispatch_refined,
100
+ Type::Intersection => :dispatch_intersection
101
+ }.freeze
102
+ private_constant :RECEIVER_HANDLERS
103
+
91
104
  def try_dispatch(receiver:, method_name:, args:)
92
105
  args ||= []
93
- case receiver
94
- when Type::Tuple then dispatch_tuple(receiver, method_name, args)
95
- when Type::HashShape then dispatch_hash_shape(receiver, method_name, args)
96
- end
106
+ handler = RECEIVER_HANDLERS[receiver.class]
107
+ return nil unless handler
108
+
109
+ send(handler, receiver, method_name, args)
97
110
  end
98
111
 
112
+ # Tightens `Array#size` / `Array#length` / `String#length` /
113
+ # `String#bytesize` / `Hash#size` etc. on a `Nominal` receiver
114
+ # from the RBS-declared `Integer` to `non_negative_int`. The
115
+ # tier ahead of RBS sees the more precise carrier so
116
+ # downstream narrowing (`if size > 0; …`) actually has a
117
+ # range to intersect with.
118
+ SIZE_RETURNING_NOMINALS = {
119
+ "Array" => %i[size length count],
120
+ "String" => %i[length size bytesize],
121
+ "Hash" => %i[size length count],
122
+ "Set" => %i[size length count],
123
+ "Range" => %i[size length count]
124
+ }.freeze
125
+ private_constant :SIZE_RETURNING_NOMINALS
126
+
127
+ # When the difference removes the empty value of the
128
+ # base type (`Constant[""]`, `Constant[0]`, an empty
129
+ # Tuple, an empty HashShape), `size` / `length` /
130
+ # `count` MUST be `positive-int` (the base's
131
+ # non-negative range minus the removed point's `0`),
132
+ # and `empty?` / `zero?` MUST be `Constant[false]`.
133
+ EMPTY_REMOVAL_BASES = %w[String Array Hash Set].freeze
134
+ private_constant :EMPTY_REMOVAL_BASES
135
+
99
136
  class << self
100
137
  private
101
138
 
@@ -113,6 +150,205 @@ module Rigor
113
150
  send(handler, shape, method_name, args)
114
151
  end
115
152
 
153
+ def dispatch_nominal_size(nominal, method_name, args)
154
+ return nil unless args.empty?
155
+
156
+ selectors = SIZE_RETURNING_NOMINALS[nominal.class_name]
157
+ return nil unless selectors&.include?(method_name)
158
+
159
+ Type::Combinator.non_negative_int
160
+ end
161
+
162
+ # Refinement-aware projections over a `Difference[base,
163
+ # removed]` receiver. When the removed value is the
164
+ # empty witness of the base (`Constant[""]` for
165
+ # String, `Tuple[]` for Array, `HashShape{}` for Hash,
166
+ # `Constant[0]` for Integer), the catalog tier knows:
167
+ #
168
+ # ns.size # positive-int
169
+ # ns.size == 0 # Constant[false] (via narrowing tier)
170
+ # ns.empty? # Constant[false]
171
+ # nzi.zero? # Constant[false]
172
+ #
173
+ # For any other base method, the difference is opaque
174
+ # to ShapeDispatch — we delegate to the base nominal
175
+ # so the size/length tier still answers the broader
176
+ # `non_negative_int` envelope where applicable.
177
+ def dispatch_difference(difference, method_name, args)
178
+ base = difference.base
179
+ return nil unless base.is_a?(Type::Nominal)
180
+
181
+ if removes_empty_witness?(difference)
182
+ precise = empty_removal_projection(base, method_name, args)
183
+ return precise if precise
184
+ end
185
+
186
+ dispatch_nominal_size(base, method_name, args)
187
+ end
188
+
189
+ EMPTY_WITNESS_PREDICATES = {
190
+ "String" => ->(removed) { removed.is_a?(Type::Constant) && removed.value == "" },
191
+ "Integer" => lambda { |removed|
192
+ removed.is_a?(Type::Constant) && removed.value.is_a?(Integer) && removed.value.zero?
193
+ },
194
+ "Array" => ->(removed) { removed.is_a?(Type::Tuple) && removed.elements.empty? },
195
+ "Hash" => ->(removed) { removed.is_a?(Type::HashShape) && removed.pairs.empty? }
196
+ }.freeze
197
+ private_constant :EMPTY_WITNESS_PREDICATES
198
+
199
+ def removes_empty_witness?(difference)
200
+ return false unless difference.base.is_a?(Type::Nominal)
201
+
202
+ predicate = EMPTY_WITNESS_PREDICATES[difference.base.class_name]
203
+ !!(predicate && predicate.call(difference.removed))
204
+ end
205
+
206
+ def empty_removal_projection(base, method_name, args)
207
+ return nil unless args.empty?
208
+
209
+ if %i[size length count bytesize].include?(method_name)
210
+ return size_returning_for_empty_removal(base, method_name)
211
+ end
212
+
213
+ empty_predicate_projection(base, method_name)
214
+ end
215
+
216
+ def empty_predicate_projection(base, method_name)
217
+ case method_name
218
+ when :empty?
219
+ base.class_name == "Integer" ? nil : Type::Combinator.constant_of(false)
220
+ when :zero?
221
+ base.class_name == "Integer" ? Type::Combinator.constant_of(false) : nil
222
+ end
223
+ end
224
+
225
+ def size_returning_for_empty_removal(base, method_name)
226
+ return nil if base.class_name == "Integer" # Integer has no size method on Difference
227
+
228
+ selectors = SIZE_RETURNING_NOMINALS[base.class_name]
229
+ return nil unless selectors&.include?(method_name)
230
+
231
+ Type::Combinator.positive_int
232
+ end
233
+
234
+ # Predicate-subset projections over a `Refined[base,
235
+ # predicate]` receiver. Today the catalogue is the
236
+ # String case-normalisation pair: `s.downcase` over a
237
+ # `lowercase-string` receiver folds to the same
238
+ # carrier (already lowercase), and `s.upcase` lifts a
239
+ # `lowercase-string` to `uppercase-string`. Symmetric
240
+ # rules apply with the predicates swapped. Numeric-
241
+ # string idempotence over `#downcase` / `#upcase` is
242
+ # also recognised because a numeric string equals its
243
+ # own case-normalisation.
244
+ #
245
+ # For methods this tier does not have a refinement-
246
+ # specific rule for, projection delegates to
247
+ # `dispatch_nominal_size` so size-returning calls on
248
+ # a `Refined[String, *]` still tighten to
249
+ # `non_negative_int`.
250
+ REFINED_STRING_PROJECTIONS = {
251
+ %i[lowercase downcase] => :refined_self,
252
+ %i[lowercase upcase] => :uppercase_string,
253
+ %i[uppercase upcase] => :refined_self,
254
+ %i[uppercase downcase] => :lowercase_string,
255
+ %i[numeric downcase] => :refined_self,
256
+ %i[numeric upcase] => :refined_self,
257
+ # Digit-only strings are case-invariant; the prefix
258
+ # letters in `0o…` / `0x…` are accepted by the
259
+ # predicate in either case so the predicate-subset
260
+ # is preserved across `#downcase` / `#upcase` even
261
+ # though the value-set element changes.
262
+ %i[decimal_int downcase] => :refined_self,
263
+ %i[decimal_int upcase] => :refined_self,
264
+ %i[octal_int downcase] => :refined_self,
265
+ %i[octal_int upcase] => :refined_self,
266
+ %i[hex_int downcase] => :refined_self,
267
+ %i[hex_int upcase] => :refined_self
268
+ }.freeze
269
+ private_constant :REFINED_STRING_PROJECTIONS
270
+
271
+ def dispatch_refined(refined, method_name, args)
272
+ base = refined.base
273
+ return nil unless base.is_a?(Type::Nominal)
274
+
275
+ if base.class_name == "String" && args.empty?
276
+ precise = refined_string_projection(refined, method_name)
277
+ return precise if precise
278
+ end
279
+
280
+ dispatch_nominal_size(base, method_name, args)
281
+ end
282
+
283
+ def refined_string_projection(refined, method_name)
284
+ handler = REFINED_STRING_PROJECTIONS[[refined.predicate_id, method_name]]
285
+ return nil unless handler
286
+
287
+ case handler
288
+ when :refined_self then refined
289
+ when :uppercase_string then Type::Combinator.uppercase_string
290
+ when :lowercase_string then Type::Combinator.lowercase_string
291
+ end
292
+ end
293
+
294
+ # Projects a method call over an `Intersection[M1, …]`
295
+ # receiver by collecting each member's projection and
296
+ # combining the results. The set-theoretic identity is
297
+ # `M(A ∩ B) ⊆ M(A) ∩ M(B)`, so the meet of the per-member
298
+ # projections is sound. Combining is best-effort:
299
+ #
300
+ # - If every result is a `Type::IntegerRange`, return
301
+ # their bounded-integer meet (max of lower bounds, min
302
+ # of upper bounds). This catches the common
303
+ # `(non_empty_string ∩ lowercase_string).size`
304
+ # pattern where one member projects to `positive-int`
305
+ # and the other to `non-negative-int`; the meet is
306
+ # `positive-int`.
307
+ # - Otherwise return the first non-nil result. A richer
308
+ # meet (e.g. of Difference + Refined results when both
309
+ # project) is left for a future slice; the carrier
310
+ # stays sound because every member's projection is
311
+ # already a superset of the true intersection.
312
+ #
313
+ # Returns nil when no member projects, so the caller
314
+ # falls through to the next dispatcher tier.
315
+ def dispatch_intersection(intersection, method_name, args)
316
+ results = intersection.members.filter_map do |member|
317
+ ShapeDispatch.try_dispatch(receiver: member, method_name: method_name, args: args)
318
+ end
319
+
320
+ case results.size
321
+ when 0 then nil
322
+ when 1 then results.first
323
+ else combine_intersection_results(results)
324
+ end
325
+ end
326
+
327
+ def combine_intersection_results(results)
328
+ return narrow_integer_ranges(results) if results.all?(Type::IntegerRange)
329
+
330
+ results.first
331
+ end
332
+
333
+ # Compute the bounded-integer meet of two or more
334
+ # `IntegerRange` carriers. We compare via the numeric
335
+ # `lower` / `upper` accessors (`-Float::INFINITY` /
336
+ # `Float::INFINITY` for the symbolic ends), then map
337
+ # back to the symbolic-bound representation
338
+ # `IntegerRange.new` expects. The disjoint-meet case
339
+ # cannot arise from sound member-wise projections in
340
+ # v0.0.4 but is guarded defensively to keep the
341
+ # carrier total.
342
+ def narrow_integer_ranges(ranges)
343
+ numeric_low = ranges.map(&:lower).max
344
+ numeric_high = ranges.map(&:upper).min
345
+ return Type::Combinator.bot if numeric_low > numeric_high
346
+
347
+ min = numeric_low == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : numeric_low.to_i
348
+ max = numeric_high == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : numeric_high.to_i
349
+ Type::Combinator.integer_range(min, max)
350
+ end
351
+
116
352
  def tuple_first(tuple, _method_name, args)
117
353
  return nil unless args.empty?
118
354
  return Type::Combinator.constant_of(nil) if tuple.elements.empty?
@@ -4,6 +4,8 @@ require_relative "../type"
4
4
  require_relative "method_dispatcher/constant_folding"
5
5
  require_relative "method_dispatcher/shape_dispatch"
6
6
  require_relative "method_dispatcher/rbs_dispatch"
7
+ require_relative "method_dispatcher/iterator_dispatch"
8
+ require_relative "method_dispatcher/file_folding"
7
9
 
8
10
  module Rigor
9
11
  module Inference
@@ -56,29 +58,12 @@ module Rigor
56
58
  def dispatch(receiver_type:, method_name:, arg_types:, block_type: nil, environment: nil)
57
59
  return nil if receiver_type.nil?
58
60
 
59
- meta_result = try_meta_introspection(receiver_type, method_name)
60
- return meta_result if meta_result
61
-
62
- constant_result = ConstantFolding.try_fold(
63
- receiver: receiver_type,
64
- method_name: method_name,
65
- args: arg_types
66
- )
67
- return constant_result if constant_result
68
-
69
- shape_result = ShapeDispatch.try_dispatch(
70
- receiver: receiver_type,
71
- method_name: method_name,
72
- args: arg_types
73
- )
74
- return shape_result if shape_result
61
+ precise = dispatch_precise_tiers(receiver_type, method_name, arg_types)
62
+ return precise if precise
75
63
 
76
64
  rbs_result = RbsDispatch.try_dispatch(
77
- receiver: receiver_type,
78
- method_name: method_name,
79
- args: arg_types,
80
- environment: environment,
81
- block_type: block_type
65
+ receiver: receiver_type, method_name: method_name, args: arg_types,
66
+ environment: environment, block_type: block_type
82
67
  )
83
68
  return rbs_result if rbs_result
84
69
 
@@ -96,6 +81,21 @@ module Rigor
96
81
  try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
97
82
  end
98
83
 
84
+ # Runs the precision tiers (constant fold, shape dispatch,
85
+ # file-path fold) in order and returns the first non-nil
86
+ # answer. Each tier owns its own receiver/argument shape
87
+ # checks; a tier that does not recognise the receiver returns
88
+ # nil so the next tier can try. The RBS tier sits below this
89
+ # chain and is invoked by the outer `dispatch` method.
90
+ def dispatch_precise_tiers(receiver_type, method_name, arg_types)
91
+ meta_result = try_meta_introspection(receiver_type, method_name)
92
+ return meta_result if meta_result
93
+
94
+ ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
95
+ ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
96
+ FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
97
+ end
98
+
99
99
  def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
100
100
  return nil if environment.nil?
101
101
 
@@ -201,6 +201,13 @@ module Rigor
201
201
  def expected_block_param_types(receiver_type:, method_name:, arg_types:, environment: nil)
202
202
  return [] if receiver_type.nil?
203
203
 
204
+ iterator_result = IteratorDispatch.block_param_types(
205
+ receiver: receiver_type,
206
+ method_name: method_name,
207
+ args: arg_types
208
+ )
209
+ return iterator_result if iterator_result
210
+
204
211
  RbsDispatch.block_param_types(
205
212
  receiver: receiver_type,
206
213
  method_name: method_name,
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../type"
6
+ require_relative "../rbs_extended"
6
7
  require_relative "rbs_type_translator"
7
8
 
8
9
  module Rigor
@@ -59,10 +60,13 @@ module Rigor
59
60
  rbs_method = lookup_rbs_method(def_node)
60
61
  return types unless rbs_method
61
62
 
62
- method_types = rbs_method.method_types
63
- return types if method_types.empty?
64
-
65
- apply_rbs_overloads(types, slots, method_types)
63
+ apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
64
+ # `rigor:v1:param: <name> <refinement>` annotations
65
+ # tighten the bound type for matching slots. Applied
66
+ # after the RBS-overload pass so the override is the
67
+ # authoritative answer regardless of what the RBS
68
+ # signature declared.
69
+ apply_param_overrides(types, slots, rbs_method)
66
70
  types
67
71
  end
68
72
 
@@ -165,6 +169,27 @@ module Rigor
165
169
  end
166
170
  end
167
171
 
172
+ # Reads the override map off the method's annotations and
173
+ # replaces the binding for any slot whose name appears in
174
+ # the map. Anonymous slots are skipped (no name to match).
175
+ # The override is used verbatim — no `:rest_*` re-wrapping —
176
+ # so authors who tighten a `*rest` parameter to e.g.
177
+ # `non-empty-array[Integer]` describe the parameter binding
178
+ # they actually want, not its element type.
179
+ def apply_param_overrides(types, slots, rbs_method)
180
+ override_map = RbsExtended.param_type_override_map(rbs_method)
181
+ return if override_map.empty?
182
+
183
+ slots.each do |slot|
184
+ next if slot.name.nil?
185
+
186
+ override = override_map[slot.name]
187
+ next if override.nil?
188
+
189
+ types[slot.name] = override
190
+ end
191
+ end
192
+
168
193
  def collect_translated_types(method_types, slot)
169
194
  rbs_types = method_types.flat_map do |mt|
170
195
  t = rbs_type_for_slot(mt.type, slot)