rigortype 0.0.6 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0af88abcc3dd912fd5c9d22440dc456b0418d6bd0de2cae58167f8538880c1a0
4
- data.tar.gz: 2ef580ca7c24007313fd4b30e4e2f0dc1cd59e68ccdc4113f3fe060694880782
3
+ metadata.gz: 0aa63fadd57282a307bf4664cce576c1b955617b6643c64d436827595a751ccb
4
+ data.tar.gz: 4e7cc6e58c5fcb45eb201182cbb87f723a558972334add650aecdeb80ed7c502
5
5
  SHA512:
6
- metadata.gz: 46bd06614cdbf530ada69f28fd5be8620b5e54d834c8af69663fdf0949f0592a7123e0f564d924046dbc9b3d9d04ffec96af593825c6b84cafebf1a6b1a60431
7
- data.tar.gz: df1f86d712a8a081356917989a4c98c775180eb805d54300b671d652a2d87eee86a7624d9b8812f2e7a4b3b26364a668948aa8aa06481c53295908428ab0d4ae
6
+ metadata.gz: 9972f4ac5258340ab534ce5bd3aad4dfda7f23e080013f0f3fdc96d4ea0fb45c458cfa3c68e14b5e2b7ffdf273e7fa81f76d7d80761cd0a5b3b6291f903a5157
7
+ data.tar.gz: 3009bf4ab97ee163ec05fc262bbdc130dc5e5ebf877bea9b1e220f0607251d96bf81ad10e1f6505d2f2999e8f12bb6bb823577dd142645295e2fdc2fc7d29fe2
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../reflection"
5
6
  require_relative "../source/node_walker"
6
7
  require_relative "../type"
7
8
  require_relative "diagnostic"
@@ -172,9 +173,7 @@ module Rigor
172
173
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
173
174
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
174
175
 
175
- loader = scope.environment.rbs_loader
176
- return nil if loader.nil?
177
- return nil unless loader.class_known?(class_name)
176
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
178
177
 
179
178
  # When the loader cannot build a class definition for a
180
179
  # name it nominally knows (constant-decl aliases such
@@ -182,9 +181,9 @@ module Rigor
182
181
  # malformed signatures), we cannot enumerate methods
183
182
  # so we MUST NOT emit a false positive. Skip the rule
184
183
  # in that case.
185
- return nil unless definition_available?(loader, receiver_type, class_name)
184
+ return nil unless definition_available?(receiver_type, class_name, scope)
186
185
 
187
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
186
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
188
187
  return nil if method_def
189
188
 
190
189
  build_undefined_method_diagnostic(path, call_node, receiver_type)
@@ -219,27 +218,29 @@ module Rigor
219
218
  nil
220
219
  end
221
220
 
222
- def definition_available?(loader, receiver_type, class_name)
221
+ def definition_available?(receiver_type, class_name, scope)
223
222
  if receiver_type.is_a?(Type::Singleton)
224
- !loader.singleton_definition(class_name).nil?
223
+ !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
225
224
  else
226
- !loader.instance_definition(class_name).nil?
225
+ !Rigor::Reflection.instance_definition(class_name, scope: scope).nil?
227
226
  end
228
- rescue StandardError
229
- false
230
227
  end
231
228
 
232
- def lookup_method(loader, receiver_type, class_name, method_name)
229
+ def lookup_method(receiver_type, class_name, method_name, scope)
233
230
  if receiver_type.is_a?(Type::Singleton)
234
- loader.singleton_method(class_name: class_name, method_name: method_name)
231
+ Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
235
232
  else
236
- loader.instance_method(class_name: class_name, method_name: method_name)
233
+ Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
237
234
  end
238
235
  rescue StandardError
239
- # The loader is best-effort and may raise on malformed
240
- # RBS. Treat any failure as "method exists" so we do
241
- # NOT emit a false positive when our knowledge of the
242
- # receiver class is structurally incomplete.
236
+ # The Reflection facade catches loader exceptions and
237
+ # returns nil. The wrapper here treats failures as
238
+ # "method exists" so we do NOT emit a false positive
239
+ # when our knowledge of the receiver class is
240
+ # structurally incomplete (Reflection's own rescue
241
+ # already returns nil; this catch is a defensive
242
+ # double-net for any future call shape that might
243
+ # raise).
243
244
  true
244
245
  end
245
246
 
@@ -271,12 +272,10 @@ module Rigor
271
272
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
272
273
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
273
274
 
274
- loader = scope.environment.rbs_loader
275
- return nil if loader.nil?
276
- return nil unless loader.class_known?(class_name)
277
- return nil unless definition_available?(loader, receiver_type, class_name)
275
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
276
+ return nil unless definition_available?(receiver_type, class_name, scope)
278
277
 
279
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
278
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
280
279
  return nil if method_def.nil? || method_def == true
281
280
 
282
281
  arity_envelope = compute_arity_envelope(method_def)
@@ -383,12 +382,14 @@ module Rigor
383
382
  receiver_type = scope.type_of(call_node.receiver)
384
383
  return nil unless receiver_type.is_a?(Type::Union)
385
384
 
386
- loader = scope.environment.rbs_loader
387
- return nil if loader.nil?
385
+ # The rule only fires when the analyzer has access to
386
+ # an RBS loader; without it, the per-member method-
387
+ # presence checks below cannot rule out a sound call.
388
+ return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
388
389
 
389
390
  return nil unless union_contains_nil?(receiver_type)
390
- return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, loader, scope)
391
- return nil if nil_class_has_method?(call_node.name, loader)
391
+ return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, scope)
392
+ return nil if nil_class_has_method?(call_node.name, scope)
392
393
 
393
394
  build_nil_receiver_diagnostic(path, call_node)
394
395
  end
@@ -409,27 +410,25 @@ module Rigor
409
410
  # that are unsound on the non-nil branch — that is the
410
411
  # `undefined_method_diagnostic` rule's job, and we want
411
412
  # exactly one diagnostic per offending call site.
412
- def union_method_present_on_non_nil?(union, method_name, loader, scope)
413
+ def union_method_present_on_non_nil?(union, method_name, scope)
413
414
  non_nil_members = union.members.reject { |m| nil_member?(m) }
414
415
  return false if non_nil_members.empty?
415
416
 
416
- non_nil_members.all? { |m| method_present_anywhere?(m, method_name, loader, scope) }
417
+ non_nil_members.all? { |m| method_present_anywhere?(m, method_name, scope) }
417
418
  end
418
419
 
419
- def method_present_anywhere?(member, method_name, loader, scope)
420
+ def method_present_anywhere?(member, method_name, scope)
420
421
  class_name = concrete_class_name(member)
421
422
  return true if class_name.nil? # Dynamic / Top / Bot — be permissive.
422
423
  return true if scope.discovered_method?(class_name, method_name, :instance)
423
- return true unless loader.class_known?(class_name)
424
- return true unless definition_available?(loader, member, class_name)
424
+ return true unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
425
+ return true unless definition_available?(member, class_name, scope)
425
426
 
426
- !lookup_method(loader, member, class_name, method_name).nil?
427
+ !lookup_method(member, class_name, method_name, scope).nil?
427
428
  end
428
429
 
429
- def nil_class_has_method?(method_name, loader)
430
- return false unless loader.class_known?("NilClass")
431
-
432
- definition = loader.instance_definition("NilClass")
430
+ def nil_class_has_method?(method_name, scope)
431
+ definition = Rigor::Reflection.instance_definition("NilClass", scope: scope)
433
432
  return false if definition.nil?
434
433
 
435
434
  !definition.methods[method_name.to_sym].nil?
@@ -684,12 +683,10 @@ module Rigor
684
683
  # supplies BOTH a `def` and an RBS sig, the sig is
685
684
  # the authoritative parameter contract and we
686
685
  # should validate calls against it.
687
- loader = scope.environment.rbs_loader
688
- return nil if loader.nil?
689
- return nil unless loader.class_known?(class_name)
690
- return nil unless definition_available?(loader, receiver_type, class_name)
686
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
687
+ return nil unless definition_available?(receiver_type, class_name, scope)
691
688
 
692
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
689
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
693
690
  return nil if method_def.nil? || method_def == true
694
691
  return nil unless method_def.method_types.size == 1
695
692
 
@@ -78,6 +78,41 @@ module Rigor
78
78
  return nil unless args.size == 2
79
79
 
80
80
  Type::Combinator.non_empty_hash(args[0], args[1])
81
+ },
82
+ # v0.0.7 — `key_of[T]` and `value_of[T]` type functions.
83
+ # Each takes a single type argument and projects the
84
+ # known-keys (resp. known-values) union out of `T`. See
85
+ # `Type::Combinator.key_of` for the per-shape projection
86
+ # rules. Use `lower_snake` per the
87
+ # imported-built-in-types.md type-function naming rule.
88
+ "key_of" => lambda { |args|
89
+ return nil unless args.size == 1
90
+
91
+ Type::Combinator.key_of(args.first)
92
+ },
93
+ "value_of" => lambda { |args|
94
+ return nil unless args.size == 1
95
+
96
+ Type::Combinator.value_of(args.first)
97
+ },
98
+ # `int_mask[1, 2, 4]` — every integer representable by
99
+ # a bitwise OR over the listed flags. Each arg must be a
100
+ # `Constant<Integer>`; the parser wraps integer literals
101
+ # for this purpose. Builder declines on any non-integer
102
+ # arg.
103
+ "int_mask" => lambda { |args|
104
+ flags = args.map { |arg| arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) ? arg.value : nil }
105
+ return nil if flags.any?(&:nil?)
106
+
107
+ Type::Combinator.int_mask(flags)
108
+ },
109
+ # `int_mask_of[T]` — derives the closure from a finite
110
+ # integer literal type (single Constant<Integer> or a
111
+ # Union of them).
112
+ "int_mask_of" => lambda { |args|
113
+ return nil unless args.size == 1
114
+
115
+ Type::Combinator.int_mask_of(args.first)
81
116
  }
82
117
  }.freeze
83
118
  private_constant :PARAMETERISED_TYPE_BUILDERS
@@ -145,7 +180,7 @@ module Rigor
145
180
  # soft (returns `nil` from `parse`) on any deviation so the
146
181
  # `RBS::Extended` directive site can fall back to the
147
182
  # RBS-declared type rather than crash on a typo.
148
- class Parser
183
+ class Parser # rubocop:disable Metrics/ClassLength
149
184
  def initialize(input)
150
185
  @scanner = StringScanner.new(input.strip)
151
186
  end
@@ -153,6 +188,12 @@ module Rigor
153
188
  def parse
154
189
  type = parse_type
155
190
  return nil if type.nil?
191
+
192
+ # v0.0.7 — trailing `[K]` indexed-access projects
193
+ # into the parsed type. Multiple `[K]` segments
194
+ # chain (`Tuple[A, B, C][1][0]`).
195
+ type = parse_indexed_access_chain(type)
196
+ return nil if type.nil?
156
197
  return nil unless @scanner.eos?
157
198
 
158
199
  type
@@ -160,12 +201,21 @@ module Rigor
160
201
 
161
202
  private
162
203
 
163
- SIMPLE_NAME = /[a-z][a-z0-9-]*/
204
+ # Refinement names use kebab-case (`non-empty-string`),
205
+ # type-function names use lower_snake (`key_of`,
206
+ # `value_of`, `int_mask`). The regex accepts both shapes;
207
+ # the registry lookup decides which family the name
208
+ # belongs to.
209
+ SIMPLE_NAME = /[a-z][a-z0-9_-]*/
164
210
  CLASS_NAME = /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
165
211
  SIGNED_INT = /-?\d+/
166
212
  private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT
167
213
 
168
214
  def parse_type
215
+ if (class_name = @scanner.scan(CLASS_NAME))
216
+ return parse_class_arg_tail(class_name)
217
+ end
218
+
169
219
  name = @scanner.scan(SIMPLE_NAME)
170
220
  return nil if name.nil?
171
221
 
@@ -176,6 +226,25 @@ module Rigor
176
226
  end
177
227
  end
178
228
 
229
+ # `T[K]` — keep applying `[K]` indexes until no more
230
+ # opening brackets are present. Each index consumes one
231
+ # type argument; multi-arg `[K1, K2]` fails (the spec
232
+ # specifies a single key).
233
+ def parse_indexed_access_chain(type)
234
+ loop do
235
+ skip_ws
236
+ break unless @scanner.peek(1) == "["
237
+
238
+ @scanner.getch
239
+ args = parse_type_arg_list
240
+ return nil if args.nil? || args.size != 1
241
+ return nil unless @scanner.getch == "]"
242
+
243
+ type = Type::Combinator.indexed_access(type, args.first)
244
+ end
245
+ type
246
+ end
247
+
179
248
  def parse_parametric_type_args(name)
180
249
  builder = PARAMETERISED_TYPE_BUILDERS[name]
181
250
  return nil if builder.nil?
@@ -227,12 +296,33 @@ module Rigor
227
296
  def parse_type_arg
228
297
  skip_ws
229
298
  if (class_name = @scanner.scan(CLASS_NAME))
230
- Type::Combinator.nominal_of(class_name)
299
+ parse_class_arg_tail(class_name)
300
+ elsif (literal = @scanner.scan(SIGNED_INT))
301
+ # Integer-literal arg, used by `int_mask[1, 2, 4]`.
302
+ # Wrapped as `Constant<Integer>` so type-arg builders
303
+ # see a uniform `Array<Type::t>`.
304
+ Type::Combinator.constant_of(Integer(literal))
231
305
  else
232
306
  parse_type
233
307
  end
234
308
  end
235
309
 
310
+ # Class-name-headed type argument with optional `[T_1,
311
+ # …]` type-args tail. Used so `key_of[Hash[Symbol,
312
+ # Integer]]` parses as the projection of a parameterised
313
+ # nominal carrier rather than rejecting the inner
314
+ # brackets.
315
+ def parse_class_arg_tail(class_name)
316
+ return Type::Combinator.nominal_of(class_name) unless @scanner.peek(1) == "["
317
+
318
+ @scanner.getch # consume '['
319
+ args = parse_type_arg_list
320
+ return nil if args.nil?
321
+ return nil unless @scanner.getch == "]"
322
+
323
+ Type::Combinator.nominal_of(class_name, type_args: args)
324
+ end
325
+
236
326
  def parse_int_bound
237
327
  skip_ws
238
328
  literal = @scanner.scan(SIGNED_INT)
@@ -52,6 +52,12 @@ module Rigor
52
52
  # Literals
53
53
  Prism::IntegerNode => :type_of_literal_value,
54
54
  Prism::FloatNode => :type_of_literal_value,
55
+ # `1i` / `2.5ri` lift via `node.value` which is already a
56
+ # `Complex` Ruby value; same for `1r` / `1.5r` whose
57
+ # value is a `Rational`. `Type::Constant` accepts both
58
+ # via `SCALAR_CLASSES`.
59
+ Prism::ImaginaryNode => :type_of_literal_value,
60
+ Prism::RationalNode => :type_of_literal_value,
55
61
  Prism::SymbolNode => :symbol_type_for,
56
62
  Prism::StringNode => :string_type_for,
57
63
  Prism::TrueNode => :type_of_true,
@@ -401,7 +407,13 @@ module Rigor
401
407
  # so callers stay backward compatible.
402
408
  def type_of_hash(node)
403
409
  elements = node.respond_to?(:elements) ? node.elements : []
404
- return Type::Combinator.nominal_of(Hash) if elements.empty?
410
+ # v0.0.7 `{}` resolves to the empty `HashShape{}` carrier
411
+ # rather than `Nominal[Hash]`, mirroring the v0.0.6 empty-
412
+ # array literal change. Both forms erase to plain `Hash`,
413
+ # but `HashShape{}` pins the literal's known size (zero)
414
+ # so HashShape projections (`empty?`, `first`, `count`,
415
+ # …) fold against it.
416
+ return Type::Combinator.hash_shape_of({}) if elements.empty?
405
417
 
406
418
  shape = static_hash_shape_for(elements)
407
419
  return shape if shape
@@ -691,7 +703,18 @@ module Rigor
691
703
  Type::Combinator.constant_of(Range.new(left, right, node.exclude_end?))
692
704
  end
693
705
 
694
- def type_of_regexp(_node)
706
+ # v0.0.7 — non-interpolated regex literals lift to
707
+ # `Constant<Regexp>` so `Constant<String>#scan(/regex/)`
708
+ # / `#match(/regex/)` etc. can fold through the catalog
709
+ # tier. Interpolated regexes (`/foo#{x}/`) reach the
710
+ # second `Prism::InterpolatedRegularExpressionNode` arm
711
+ # which keeps the conservative `Nominal[Regexp]` answer.
712
+ def type_of_regexp(node)
713
+ return Type::Combinator.nominal_of(Regexp) unless node.is_a?(Prism::RegularExpressionNode)
714
+
715
+ regex = Regexp.new(node.unescaped, node.options)
716
+ Type::Combinator.constant_of(regex)
717
+ rescue StandardError
695
718
  Type::Combinator.nominal_of(Regexp)
696
719
  end
697
720
 
@@ -109,6 +109,16 @@ module Rigor
109
109
 
110
110
  # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
111
111
  def try_fold(receiver:, method_name:, args:)
112
+ # v0.0.7 — `String#%` against a `Tuple` / `HashShape`
113
+ # argument runs Ruby's format-string engine when both
114
+ # sides are statically constant. The standard
115
+ # `numeric_set_of` path bails on Tuple / HashShape
116
+ # arguments because they are not scalar-Constant
117
+ # carriers, so the special-case sits ahead of the
118
+ # numeric path.
119
+ format_lift = try_fold_string_format(receiver, method_name, args)
120
+ return format_lift if format_lift
121
+
112
122
  receiver_set = numeric_set_of(receiver)
113
123
  return nil unless receiver_set
114
124
 
@@ -118,6 +128,59 @@ module Rigor
118
128
  dispatch_by_arity(receiver_set, method_name, arg_sets)
119
129
  end
120
130
 
131
+ # `Constant<String> % …` — runs the actual `String#%`
132
+ # operation when both sides are statically known. The
133
+ # argument may be:
134
+ # - A `Type::Constant` whose value is a scalar (Integer
135
+ # / Float / String / Symbol). Already handled by the
136
+ # numeric path; this method declines so the standard
137
+ # binary path picks it up.
138
+ # - A `Type::Tuple` whose elements are all `Constant`.
139
+ # Materialises the elements as a Ruby Array and runs
140
+ # the format.
141
+ # - A `Type::HashShape` with no optional keys whose
142
+ # values are all `Constant`. Materialises a Ruby Hash
143
+ # and runs the format. Symbol keys are kept as
144
+ # Symbols (matching Ruby's `%{key}` resolution).
145
+ # Anything else declines so the RBS tier widens.
146
+ # rubocop:disable Metrics/CyclomaticComplexity
147
+ def try_fold_string_format(receiver, method_name, args)
148
+ return nil unless method_name == :%
149
+ return nil unless args.size == 1
150
+ return nil unless receiver.is_a?(Type::Constant) && receiver.value.is_a?(String)
151
+
152
+ arg = args.first
153
+ ruby_arg = format_argument_value(arg)
154
+ return nil if ruby_arg.nil?
155
+
156
+ result = receiver.value % ruby_arg
157
+ return nil unless foldable_constant_value?(result)
158
+
159
+ Type::Combinator.constant_of(result)
160
+ rescue StandardError
161
+ nil
162
+ end
163
+ # rubocop:enable Metrics/CyclomaticComplexity
164
+
165
+ def format_argument_value(arg)
166
+ case arg
167
+ when Type::Tuple
168
+ return nil unless arg.elements.all?(Type::Constant)
169
+
170
+ arg.elements.map(&:value)
171
+ when Type::HashShape
172
+ hash_shape_format_value(arg)
173
+ end
174
+ end
175
+
176
+ def hash_shape_format_value(shape)
177
+ return nil unless shape.closed?
178
+ return nil unless shape.optional_keys.empty?
179
+ return nil unless shape.pairs.values.all?(Type::Constant)
180
+
181
+ shape.pairs.transform_values(&:value)
182
+ end
183
+
121
184
  def dispatch_by_arity(receiver_set, method_name, arg_sets)
122
185
  case arg_sets.size
123
186
  when 0 then try_fold_unary(receiver_set, method_name)
@@ -206,7 +269,17 @@ module Rigor
206
269
  [result]
207
270
  end
208
271
 
272
+ # rubocop:disable Metrics/CyclomaticComplexity
209
273
  def try_fold_unary_set(receiver_values, method_name)
274
+ range_lift = try_fold_range_constant_unary(receiver_values, method_name)
275
+ return range_lift if range_lift
276
+
277
+ string_lift = try_fold_string_array_unary(receiver_values, method_name)
278
+ return string_lift if string_lift
279
+
280
+ pathname_lift = try_fold_pathname_unary(receiver_values, method_name)
281
+ return pathname_lift if pathname_lift
282
+
210
283
  # Type-level allow check on every receiver. If one member's
211
284
  # type does not have the method in its allow list (e.g.
212
285
  # `Union[String, nil].nil?` — `:nil?` is not in
@@ -220,8 +293,68 @@ module Rigor
220
293
  end
221
294
  build_constant_type(results, source: receiver_values)
222
295
  end
296
+ # rubocop:enable Metrics/CyclomaticComplexity
297
+
298
+ # v0.0.7 — `Constant<Range>#to_a` and the no-arg
299
+ # `first` / `last` / `min` / `max` short-circuit through a
300
+ # Range-specific arm that catalog dispatch cannot reach:
301
+ # - `to_a` returns an Array (not foldable through
302
+ # `foldable_constant_value?`) — lift to `Tuple[Constant…]`
303
+ # when the cardinality fits within `RANGE_TO_A_LIMIT`.
304
+ # - `first` / `last` / `min` / `max` are catalog-classified
305
+ # `:block_dependent` because of the optional-block forms,
306
+ # but the no-arg form is pure for finite integer ranges.
307
+ #
308
+ # Only fires on a single-receiver Range with finite integer
309
+ # endpoints; mixed unions fall through so the existing
310
+ # union-of-Constants path keeps the rest of the arms.
311
+ RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length].freeze
312
+ RANGE_TO_A_LIMIT = 16
313
+ private_constant :RANGE_FOLD_METHODS, :RANGE_TO_A_LIMIT
314
+
315
+ def try_fold_range_constant_unary(receiver_values, method_name)
316
+ return nil unless RANGE_FOLD_METHODS.include?(method_name)
317
+ return nil unless receiver_values.size == 1
223
318
 
319
+ range = receiver_values.first
320
+ return nil unless range.is_a?(Range)
321
+ return nil unless range.begin.is_a?(Integer) && range.end.is_a?(Integer)
322
+
323
+ range_constant_unary(range, method_name)
324
+ end
325
+
326
+ def range_constant_unary(range, method_name)
327
+ case method_name
328
+ when :to_a then range_to_a_tuple(range)
329
+ when :first, :min then range_endpoint_constant(range, :first)
330
+ when :last, :max then range_endpoint_constant(range, :last)
331
+ when :count, :size, :length then Type::Combinator.constant_of(range.to_a.size)
332
+ end
333
+ end
334
+
335
+ def range_to_a_tuple(range)
336
+ values = range.to_a
337
+ return Type::Combinator.tuple_of if values.empty?
338
+ return nil if values.size > RANGE_TO_A_LIMIT
339
+
340
+ Type::Combinator.tuple_of(*values.map { |v| Type::Combinator.constant_of(v) })
341
+ end
342
+
343
+ def range_endpoint_constant(range, edge)
344
+ values = range.to_a
345
+ return Type::Combinator.constant_of(nil) if values.empty?
346
+
347
+ Type::Combinator.constant_of(edge == :first ? values.first : values.last)
348
+ end
349
+
350
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
224
351
  def try_fold_binary_set(receiver_values, method_name, arg_values)
352
+ string_lift = try_fold_string_array_binary(receiver_values, method_name, arg_values)
353
+ return string_lift if string_lift
354
+
355
+ pathname_lift = try_fold_pathname_binary(receiver_values, method_name, arg_values)
356
+ return pathname_lift if pathname_lift
357
+
225
358
  return nil if receiver_values.size * arg_values.size > UNION_FOLD_INPUT_LIMIT
226
359
  return nil unless receiver_values.all? { |rv| binary_method_allowed?(rv, method_name) }
227
360
 
@@ -230,6 +363,119 @@ module Rigor
230
363
  end
231
364
  build_constant_type(results, source: receiver_values + arg_values)
232
365
  end
366
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
367
+
368
+ # v0.0.7 — `Constant<String>#chars` / `bytes` / `lines` /
369
+ # `split` (no-arg) return a Ruby Array of foldable
370
+ # scalars; `foldable_constant_value?` rejects Array
371
+ # results, so the standard unary path declines. Lift the
372
+ # Array to a per-position `Tuple[Constant…]` directly,
373
+ # capped at `STRING_ARRAY_LIFT_LIMIT` to keep the result
374
+ # bounded for long strings.
375
+ STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :lines, :split].freeze
376
+ STRING_ARRAY_BINARY_METHODS = Set[:split, :scan].freeze
377
+ STRING_ARRAY_LIFT_LIMIT = 32
378
+ private_constant :STRING_ARRAY_UNARY_METHODS,
379
+ :STRING_ARRAY_BINARY_METHODS,
380
+ :STRING_ARRAY_LIFT_LIMIT
381
+
382
+ # v0.0.7 — `Constant<Pathname>` delegates to a curated set
383
+ # of pure path-manipulation methods. Pathname is immutable
384
+ # in Ruby (per its docstring) and the catalog classifies
385
+ # most methods `:dispatch` because the C body delegates to
386
+ # File / Dir / FileTest. The methods listed here are
387
+ # filesystem-independent — they read only `@path` — so
388
+ # invoking them at fold time produces a deterministic
389
+ # result regardless of the host filesystem state.
390
+ #
391
+ # Filesystem-touching methods (`exist?`, `file?`, `read`,
392
+ # `stat`, …) are intentionally NOT folded: their answer
393
+ # depends on the analysis machine's filesystem, which is
394
+ # neither stable nor relevant to the analyzed program.
395
+ PATHNAME_PURE_UNARY = Set[
396
+ :to_s, :to_path, :to_str,
397
+ :basename, :dirname, :extname, :cleanpath,
398
+ :parent, :sub_ext, :root?, :absolute?, :relative?,
399
+ :hash, :inspect
400
+ ].freeze
401
+ PATHNAME_PURE_BINARY = Set[
402
+ :+, :join, :sub_ext, :<=>, :==, :eql?, :===,
403
+ :relative_path_from
404
+ ].freeze
405
+ private_constant :PATHNAME_PURE_UNARY, :PATHNAME_PURE_BINARY
406
+
407
+ def try_fold_pathname_unary(receiver_values, method_name)
408
+ return nil unless PATHNAME_PURE_UNARY.include?(method_name)
409
+ return nil unless receiver_values.size == 1
410
+
411
+ receiver = receiver_values.first
412
+ return nil unless receiver.is_a?(Pathname)
413
+
414
+ result = receiver.public_send(method_name)
415
+ return nil unless foldable_constant_value?(result)
416
+
417
+ Type::Combinator.constant_of(result)
418
+ rescue StandardError
419
+ nil
420
+ end
421
+
422
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
423
+ def try_fold_pathname_binary(receiver_values, method_name, arg_values)
424
+ return nil unless PATHNAME_PURE_BINARY.include?(method_name)
425
+ return nil unless receiver_values.size == 1 && arg_values.size == 1
426
+
427
+ receiver = receiver_values.first
428
+ arg = arg_values.first
429
+ return nil unless receiver.is_a?(Pathname)
430
+ return nil unless arg.is_a?(Pathname) || arg.is_a?(String)
431
+
432
+ result = receiver.public_send(method_name, arg)
433
+ return nil unless foldable_constant_value?(result)
434
+
435
+ Type::Combinator.constant_of(result)
436
+ rescue StandardError
437
+ nil
438
+ end
439
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
440
+
441
+ def try_fold_string_array_unary(receiver_values, method_name)
442
+ return nil unless STRING_ARRAY_UNARY_METHODS.include?(method_name)
443
+ return nil unless receiver_values.size == 1
444
+
445
+ receiver = receiver_values.first
446
+ return nil unless receiver.is_a?(String)
447
+
448
+ lift_array_result(receiver.public_send(method_name))
449
+ rescue StandardError
450
+ nil
451
+ end
452
+
453
+ # `Constant<String>#split(arg)` / `#scan(arg)` — lift the
454
+ # Array result to a Tuple when both sides are statically
455
+ # known and the cardinality fits.
456
+ # rubocop:disable Metrics/CyclomaticComplexity
457
+ def try_fold_string_array_binary(receiver_values, method_name, arg_values)
458
+ return nil unless STRING_ARRAY_BINARY_METHODS.include?(method_name)
459
+ return nil unless receiver_values.size == 1 && arg_values.size == 1
460
+
461
+ receiver = receiver_values.first
462
+ arg = arg_values.first
463
+ return nil unless receiver.is_a?(String)
464
+ return nil unless arg.is_a?(String) || arg.is_a?(Regexp)
465
+
466
+ lift_array_result(receiver.public_send(method_name, arg))
467
+ rescue StandardError
468
+ nil
469
+ end
470
+ # rubocop:enable Metrics/CyclomaticComplexity
471
+
472
+ def lift_array_result(result)
473
+ return nil unless result.is_a?(Array)
474
+ return nil if result.size > STRING_ARRAY_LIFT_LIMIT
475
+ return nil unless result.all? { |v| foldable_constant_value?(v) }
476
+
477
+ Type::Combinator.tuple_of(*result.map { |v| Type::Combinator.constant_of(v) })
478
+ end
233
479
 
234
480
  # 2-arg fold dispatch. Used by `Comparable#between?(min, max)`,
235
481
  # `Comparable#clamp(min, max)`, and `Integer#pow(exp, mod)` —
@@ -879,7 +1125,7 @@ module Rigor
879
1125
  # round-trip through `Type::Combinator.constant_of`".
880
1126
  def foldable_constant_value?(value)
881
1127
  case value
882
- when Integer, Float, String, Symbol, true, false, nil then true
1128
+ when Integer, Float, Rational, Complex, String, Symbol, Regexp, Pathname, true, false, nil then true
883
1129
  else false
884
1130
  end
885
1131
  end