rigortype 0.1.8 → 0.1.10

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
@@ -48,11 +48,21 @@ module Rigor
48
48
  module ConstantFolding # rubocop:disable Metrics/ModuleLength
49
49
  module_function
50
50
 
51
- NUMERIC_BINARY = Set[:+, :-, :*, :/, :%, :<, :<=, :>, :>=, :==, :!=, :<=>].freeze
52
- STRING_BINARY = Set[:+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>].freeze
51
+ NUMERIC_BINARY = Set[
52
+ :+, :-, :*, :/, :%, :**, :&, :|, :^, :<<, :>>,
53
+ :<, :<=, :>, :>=, :==, :!=, :<=>,
54
+ :gcd, :lcm, :fdiv
55
+ ].freeze
56
+ STRING_BINARY = Set[
57
+ :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
58
+ :start_with?, :end_with?, :include?,
59
+ :delete_prefix, :delete_suffix,
60
+ :match?, :index, :rindex, :center, :ljust, :rjust
61
+ ].freeze
53
62
  SYMBOL_BINARY = Set[:==, :!=, :<=>, :<, :<=, :>, :>=].freeze
54
- BOOL_BINARY = Set[:&, :|, :^, :==, :!=].freeze
63
+ BOOL_BINARY = Set[:&, :|, :^, :==, :!=, :===].freeze
55
64
  NIL_BINARY = Set[:==, :!=].freeze
65
+ RATIONAL_BINARY = Set[:div, :modulo, :%, :remainder, :fdiv].freeze
56
66
 
57
67
  # v0.0.3 C — pure unary catalogue. Each method must:
58
68
  # - take zero arguments,
@@ -74,20 +84,23 @@ module Rigor
74
84
  :odd?, :even?, :zero?, :positive?, :negative?,
75
85
  :succ, :pred, :next, :abs, :magnitude,
76
86
  :bit_length, :to_s, :to_i, :to_int, :to_f,
87
+ :floor, :ceil, :round, :truncate, :chr,
77
88
  :inspect, :hash, :-@, :+@, :~
78
89
  ].freeze
79
90
  FLOAT_UNARY = Set[
80
91
  :zero?, :positive?, :negative?,
81
92
  :nan?, :finite?, :infinite?,
82
93
  :abs, :magnitude, :floor, :ceil, :round, :truncate,
94
+ :next_float, :prev_float,
83
95
  :to_s, :to_i, :to_int, :to_f,
84
96
  :inspect, :hash, :-@, :+@
85
97
  ].freeze
86
98
  STRING_UNARY = Set[
87
99
  :upcase, :downcase, :capitalize, :swapcase,
88
100
  :reverse, :length, :size, :bytesize,
89
- :empty?, :strip, :lstrip, :rstrip, :chomp,
101
+ :empty?, :strip, :lstrip, :rstrip, :chomp, :chop,
90
102
  :to_s, :to_str, :to_sym, :intern,
103
+ :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
91
104
  :inspect, :hash
92
105
  ].freeze
93
106
  SYMBOL_UNARY = Set[
@@ -97,6 +110,11 @@ module Rigor
97
110
  ].freeze
98
111
  BOOL_UNARY = Set[:!, :to_s, :inspect, :hash, :&, :|, :^].freeze
99
112
  NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect, :hash].freeze
113
+ RATIONAL_UNARY = Set[
114
+ :zero?, :integer?, :real, :abs2,
115
+ :conj, :conjugate, :nonzero?
116
+ ].freeze
117
+ COMPLEX_UNARY = Set[:zero?, :nonzero?].freeze
100
118
 
101
119
  STRING_FOLD_BYTE_LIMIT = 4096
102
120
 
@@ -283,6 +301,18 @@ module Rigor
283
301
  pathname_lift = try_fold_pathname_unary(receiver_values, method_name)
284
302
  return pathname_lift if pathname_lift
285
303
 
304
+ regexp_lift = try_fold_regexp_array_unary(receiver_values, method_name)
305
+ return regexp_lift if regexp_lift
306
+
307
+ set_lift = try_fold_set_array_unary(receiver_values, method_name)
308
+ return set_lift if set_lift
309
+
310
+ integer_lift = try_fold_integer_array_unary(receiver_values, method_name)
311
+ return integer_lift if integer_lift
312
+
313
+ numeric_lift = try_fold_numeric_array_unary(receiver_values, method_name)
314
+ return numeric_lift if numeric_lift
315
+
286
316
  # Type-level allow check on every receiver. If one member's
287
317
  # type does not have the method in its allow list (e.g.
288
318
  # `Union[String, nil].nil?` — `:nil?` is not in
@@ -309,7 +339,7 @@ module Rigor
309
339
  # Only fires on a single-receiver Range with finite integer
310
340
  # endpoints; mixed unions fall through so the existing
311
341
  # union-of-Constants path keeps the rest of the arms.
312
- RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length].freeze
342
+ RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length, :entries, :minmax].freeze
313
343
  RANGE_TO_A_LIMIT = 16
314
344
  private_constant :RANGE_FOLD_METHODS, :RANGE_TO_A_LIMIT
315
345
 
@@ -326,10 +356,11 @@ module Rigor
326
356
 
327
357
  def range_constant_unary(range, method_name)
328
358
  case method_name
329
- when :to_a then range_to_a_tuple(range)
359
+ when :to_a, :entries then range_to_a_tuple(range)
330
360
  when :first, :min then range_endpoint_constant(range, :first)
331
361
  when :last, :max then range_endpoint_constant(range, :last)
332
362
  when :count, :size, :length then Type::Combinator.constant_of(range.to_a.size)
363
+ when :minmax then range_minmax_tuple(range)
333
364
  end
334
365
  end
335
366
 
@@ -348,6 +379,21 @@ module Rigor
348
379
  Type::Combinator.constant_of(edge == :first ? values.first : values.last)
349
380
  end
350
381
 
382
+ def range_minmax_tuple(range)
383
+ values = range.to_a
384
+ if values.empty?
385
+ return Type::Combinator.tuple_of(
386
+ Type::Combinator.constant_of(nil),
387
+ Type::Combinator.constant_of(nil)
388
+ )
389
+ end
390
+
391
+ Type::Combinator.tuple_of(
392
+ Type::Combinator.constant_of(values.first),
393
+ Type::Combinator.constant_of(values.last)
394
+ )
395
+ end
396
+
351
397
  def try_fold_binary_set(receiver_values, method_name, arg_values)
352
398
  string_lift = try_fold_string_array_binary(receiver_values, method_name, arg_values)
353
399
  return string_lift if string_lift
@@ -377,6 +423,30 @@ module Rigor
377
423
  :STRING_ARRAY_BINARY_METHODS,
378
424
  :STRING_ARRAY_LIFT_LIMIT
379
425
 
426
+ # `Constant<Regexp>#names` returns an Array of capture-group name
427
+ # strings. Lifted to a Tuple so downstream narrowing can project
428
+ # per-element types. The catalog classifies the C body as `:leaf`
429
+ # so it is safe to evaluate at fold time; no `$~` side effect.
430
+ REGEXP_ARRAY_UNARY_METHODS = Set[:names].freeze
431
+ private_constant :REGEXP_ARRAY_UNARY_METHODS
432
+
433
+ # `Constant<Set>#to_a` returns an Array of the set's elements.
434
+ # Ruby 3.2+ Set is C-implemented with a Hash as its backing store,
435
+ # so element ordering is deterministic (insertion order).
436
+ # The catalog marks `to_a` as `:dispatch` (it calls through to the
437
+ # internal hash), so this dedicated handler bypasses the catalog gate.
438
+ SET_ARRAY_UNARY_METHODS = Set[:to_a, :entries].freeze
439
+ private_constant :SET_ARRAY_UNARY_METHODS
440
+
441
+ # `Constant<Integer>#digits` returns the base-10 (or base-n with
442
+ # an argument — only the no-arg form is folded here) place
443
+ # values as a little-endian Array of Integers. Lifted to a
444
+ # Tuple so downstream rules see the precise per-position type.
445
+ # `digits` raises `Math::DomainError` on a negative receiver,
446
+ # so the negative case bails to the RBS tier.
447
+ INTEGER_ARRAY_UNARY_METHODS = Set[:digits].freeze
448
+ private_constant :INTEGER_ARRAY_UNARY_METHODS
449
+
380
450
  # v0.0.7 — `Constant<Pathname>` delegates to a curated set
381
451
  # of pure path-manipulation methods. Pathname is immutable
382
452
  # in Ruby (per its docstring) and the catalog classifies
@@ -446,6 +516,83 @@ module Rigor
446
516
  nil
447
517
  end
448
518
 
519
+ # `Constant<Regexp>#names` — lift the Array[String] of named-capture
520
+ # group names to a Tuple[Constant[String]…]. Safe to evaluate at fold
521
+ # time: the C body reads only the regexp's internal names table,
522
+ # writes no global state, and always returns an Array of frozen Strings.
523
+ def try_fold_regexp_array_unary(receiver_values, method_name)
524
+ return nil unless REGEXP_ARRAY_UNARY_METHODS.include?(method_name)
525
+ return nil unless receiver_values.size == 1
526
+
527
+ receiver = receiver_values.first
528
+ return nil unless receiver.is_a?(Regexp)
529
+
530
+ lift_array_result(receiver.public_send(method_name))
531
+ rescue StandardError
532
+ nil
533
+ end
534
+
535
+ # `Constant<Set>#to_a` / `#entries` — lift the Array of set elements
536
+ # to a Tuple[Constant[…]…] when every element is a foldable scalar.
537
+ # Ruby 3.2+ Set is C-implemented; element order is deterministic
538
+ # (insertion order), so the result is stable across invocations.
539
+ def try_fold_set_array_unary(receiver_values, method_name)
540
+ return nil unless SET_ARRAY_UNARY_METHODS.include?(method_name)
541
+ return nil unless receiver_values.size == 1
542
+
543
+ receiver = receiver_values.first
544
+ return nil unless receiver.is_a?(::Set)
545
+
546
+ lift_array_result(receiver.to_a)
547
+ rescue StandardError
548
+ nil
549
+ end
550
+
551
+ # `Constant<Integer>#digits` — lift the Array of base-10 place
552
+ # values to a Tuple[Constant[Integer]…]. Safe to evaluate at
553
+ # fold time: the C body is pure arithmetic. Negative receivers
554
+ # raise `Math::DomainError`; the fold declines so the RBS tier
555
+ # answers with `Array[Integer]`.
556
+ def try_fold_integer_array_unary(receiver_values, method_name)
557
+ return nil unless INTEGER_ARRAY_UNARY_METHODS.include?(method_name)
558
+ return nil unless receiver_values.size == 1
559
+
560
+ receiver = receiver_values.first
561
+ return nil unless receiver.is_a?(Integer)
562
+ return nil if receiver.negative?
563
+
564
+ lift_array_result(receiver.digits)
565
+ rescue StandardError
566
+ nil
567
+ end
568
+
569
+ # `Constant<Complex>#rect` / `#rectangular` — lifts `[real, imaginary]`
570
+ # to `Tuple[Constant[re], Constant[im]]`. Both components are always
571
+ # numeric (Integer or Float for literal complexes), so they satisfy
572
+ # `foldable_constant_value?`.
573
+ #
574
+ # `Constant<Complex>#polar` — lifts `[abs, arg]` to
575
+ # `Tuple[Constant[Float], Constant[Float]]`. Evaluated at fold time
576
+ # via `Complex#polar` (which calls `Math.hypot` and `Math.atan2`).
577
+ # Deterministic: reads only the receiver's real and imaginary parts.
578
+ #
579
+ # Rational receivers also support `rect` / `rectangular` / `polar`:
580
+ # `Rational(r,1).rect` → `[r, 0]`, `Rational(r,1).polar` → `[abs, arg]`.
581
+ NUMERIC_ARRAY_UNARY_METHODS = Set[:rect, :rectangular, :polar].freeze
582
+ private_constant :NUMERIC_ARRAY_UNARY_METHODS
583
+
584
+ def try_fold_numeric_array_unary(receiver_values, method_name)
585
+ return nil unless NUMERIC_ARRAY_UNARY_METHODS.include?(method_name)
586
+ return nil unless receiver_values.size == 1
587
+
588
+ receiver = receiver_values.first
589
+ return nil unless receiver.is_a?(Complex) || receiver.is_a?(Rational)
590
+
591
+ lift_array_result(receiver.public_send(method_name))
592
+ rescue StandardError
593
+ nil
594
+ end
595
+
449
596
  # `Constant<String>#split(arg)` / `#scan(arg)` — lift the
450
597
  # Array result to a Tuple when both sides are statically
451
598
  # known and the cardinality fits.
@@ -841,6 +988,11 @@ module Rigor
841
988
  magnitude: :range_unary_abs,
842
989
  "-@": :range_unary_negate,
843
990
  "+@": :range_unary_identity,
991
+ # `Integer#to_i` / `Integer#to_int` are identity operations —
992
+ # they return `self` — so the IntegerRange carrier is preserved.
993
+ # `positive-int.to_i → positive-int`, etc.
994
+ to_i: :range_unary_identity,
995
+ to_int: :range_unary_identity,
844
996
  bit_length: :range_unary_bit_length
845
997
  }.freeze
846
998
  private_constant :UNARY_RANGE_DIRECT
@@ -1101,6 +1253,8 @@ module Rigor
1101
1253
  when Symbol then SYMBOL_UNARY
1102
1254
  when true, false then BOOL_UNARY
1103
1255
  when nil then NIL_UNARY
1256
+ when Rational then RATIONAL_UNARY
1257
+ when Complex then COMPLEX_UNARY
1104
1258
  else Set.new
1105
1259
  end
1106
1260
  end
@@ -1126,11 +1280,15 @@ module Rigor
1126
1280
  # have their own shape carriers; this method picks
1127
1281
  # the conservative envelope of "values that already
1128
1282
  # round-trip through `Type::Combinator.constant_of`".
1283
+ FOLDABLE_CONSTANT_CLASSES = [
1284
+ Integer, Float, Rational, Complex, String, Symbol,
1285
+ Regexp, Pathname, ::Set, Date, Time,
1286
+ TrueClass, FalseClass, NilClass
1287
+ ].freeze
1288
+ private_constant :FOLDABLE_CONSTANT_CLASSES
1289
+
1129
1290
  def foldable_constant_value?(value)
1130
- case value
1131
- when Integer, Float, Rational, Complex, String, Symbol, Regexp, Pathname, true, false, nil then true
1132
- else false
1133
- end
1291
+ FOLDABLE_CONSTANT_CLASSES.any? { |klass| value.is_a?(klass) }
1134
1292
  end
1135
1293
 
1136
1294
  def safe?(receiver_value, method_name, arg_value)
@@ -1150,6 +1308,7 @@ module Rigor
1150
1308
  when Symbol then SYMBOL_BINARY
1151
1309
  when true, false then BOOL_BINARY
1152
1310
  when nil then NIL_BINARY
1311
+ when Rational then RATIONAL_BINARY
1153
1312
  else Set.new
1154
1313
  end
1155
1314
  end
@@ -1169,10 +1328,19 @@ module Rigor
1169
1328
  case method_name
1170
1329
  when :+ then string_concat_blow_up?(receiver_value, arg_value)
1171
1330
  when :* then string_repeat_blow_up?(receiver_value, arg_value)
1331
+ when :center, :ljust, :rjust then string_pad_blow_up?(arg_value)
1172
1332
  else false
1173
1333
  end
1174
1334
  end
1175
1335
 
1336
+ # `"x".center(width)` / `#ljust` / `#rjust` produce a string
1337
+ # of `max(width, len)` characters. A literal `width` far
1338
+ # larger than the receiver would materialise a huge Constant;
1339
+ # cap it at the same byte limit the concat / repeat paths use.
1340
+ def string_pad_blow_up?(arg_value)
1341
+ arg_value.is_a?(Integer) && arg_value > STRING_FOLD_BYTE_LIMIT
1342
+ end
1343
+
1176
1344
  def string_concat_blow_up?(receiver_value, arg_value)
1177
1345
  arg_value.is_a?(String) &&
1178
1346
  receiver_value.bytesize + arg_value.bytesize > STRING_FOLD_BYTE_LIMIT
@@ -57,11 +57,63 @@ module Rigor
57
57
  return nil if receiver.nil?
58
58
  return try_array(args) if method_name == :Array
59
59
  return try_numeric_constructor(method_name, args) if NUMERIC_CONSTRUCTORS.key?(method_name)
60
- return try_integer_from_refinement(args) if method_name == :Integer
60
+ return try_integer(args) if method_name == :Integer
61
+ return try_float(args) if method_name == :Float
61
62
 
62
63
  nil
63
64
  end
64
65
 
66
+ # `Kernel#Integer(arg)` / `Integer(arg, base)`. Two folding
67
+ # paths, tried in order:
68
+ #
69
+ # 1. A `Refined[String, predicate]` argument whose predicate
70
+ # is a digit-only carrier narrows to `non-negative-int`
71
+ # (see {try_integer_from_refinement}).
72
+ # 2. A `Constant` String or Numeric argument — optionally
73
+ # with a `Constant[Integer]` base — runs the actual
74
+ # `Integer()` conversion and lifts the result to
75
+ # `Constant[Integer]`.
76
+ def try_integer(args)
77
+ refined = try_integer_from_refinement(args)
78
+ return refined if refined
79
+
80
+ try_integer_constant(args)
81
+ end
82
+
83
+ # Constant-folding path for `Integer()`. A non-parseable
84
+ # string raises `ArgumentError` (or `TypeError` for a base
85
+ # against a non-string) at fold time; the handler declines
86
+ # so the RBS tier answers with the widened `Integer`.
87
+ def try_integer_constant(args)
88
+ return nil unless [1, 2].include?(args.size)
89
+ return nil unless args.all?(Type::Constant)
90
+
91
+ values = args.map(&:value)
92
+ return nil unless values[0].is_a?(String) || values[0].is_a?(Numeric)
93
+ return nil if values.size == 2 && !values[1].is_a?(Integer)
94
+
95
+ Type::Combinator.constant_of(Integer(*values))
96
+ rescue ArgumentError, TypeError
97
+ nil
98
+ end
99
+
100
+ # `Kernel#Float(arg)` — folds a `Constant` String or Numeric
101
+ # argument to `Constant[Float]`. A non-parseable string
102
+ # raises `ArgumentError` at fold time; the handler declines.
103
+ def try_float(args)
104
+ return nil unless args.size == 1
105
+
106
+ arg = args.first
107
+ return nil unless arg.is_a?(Type::Constant)
108
+
109
+ value = arg.value
110
+ return nil unless value.is_a?(String) || value.is_a?(Numeric)
111
+
112
+ Type::Combinator.constant_of(Float(value))
113
+ rescue ArgumentError, TypeError
114
+ nil
115
+ end
116
+
65
117
  # `Kernel#Integer(s)` over a `Refined[String, predicate]`
66
118
  # whose predicate is in {INTEGER_REFINEMENT_PREDICATES}.
67
119
  # Mirrors the `String#to_i` projection in `ShapeDispatch`
@@ -65,6 +65,11 @@ module Rigor
65
65
  # == ""`); the carrier collapses from `non-empty-literal-string`
66
66
  # down to plain `literal-string`.
67
67
  LITERAL_PRESERVING_METHODS = %i[strip lstrip rstrip chomp chop scrub].freeze
68
+ # Methods that preserve literal-bearing AND non-empty-string-ness.
69
+ # Unlike `LITERAL_PRESERVING_METHODS` (strip/chomp/etc.) these do
70
+ # not reduce the string — they transform characters without
71
+ # removing any, so a non-empty receiver stays non-empty.
72
+ NON_EMPTY_LITERAL_PRESERVING_METHODS = %i[upcase downcase capitalize swapcase reverse].freeze
68
73
  # v0.1.1 Track 1 slice 5c — width-padding methods. `center`
69
74
  # / `ljust` / `rjust` take a `width` Integer plus an
70
75
  # optional literal padding `String`. When the receiver
@@ -72,28 +77,32 @@ module Rigor
72
77
  # literal-bearing, the result is literal-bearing too.
73
78
  WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
74
79
  private_constant :CONCAT_METHODS, :FORMAT_METHODS,
75
- :LITERAL_PRESERVING_METHODS, :WIDTH_PADDING_METHODS
80
+ :LITERAL_PRESERVING_METHODS, :NON_EMPTY_LITERAL_PRESERVING_METHODS,
81
+ :WIDTH_PADDING_METHODS
76
82
 
77
83
  def try_dispatch(receiver:, method_name:, args:, **)
78
84
  return fold_array_join(receiver, args) if method_name == :join
79
85
  return fold_format(args) if FORMAT_METHODS.include?(method_name)
80
-
81
86
  return nil unless Type::Combinator.literal_string_compatible?(receiver)
82
-
83
87
  return fold_string_percent(args) if method_name == :%
84
- if args.empty?
85
- return LITERAL_PRESERVING_METHODS.include?(method_name) ? Type::Combinator.literal_string : nil
86
- end
88
+ return fold_no_arg(receiver, method_name) if args.empty?
87
89
  return fold_width_pad(args) if WIDTH_PADDING_METHODS.include?(method_name)
88
90
  return nil unless args.size == 1
89
91
 
90
92
  if CONCAT_METHODS.include?(method_name)
91
- fold_concat(args.first)
93
+ fold_concat(receiver, args.first)
92
94
  elsif method_name == :*
93
- fold_repeat(args.first)
95
+ fold_repeat(receiver, args.first)
94
96
  end
95
97
  end
96
98
 
99
+ def fold_no_arg(receiver, method_name)
100
+ return Type::Combinator.literal_string if LITERAL_PRESERVING_METHODS.include?(method_name)
101
+ return non_empty_literal_result(receiver) if NON_EMPTY_LITERAL_PRESERVING_METHODS.include?(method_name)
102
+
103
+ nil
104
+ end
105
+
97
106
  # `String#center` / `#ljust` / `#rjust` — first argument is
98
107
  # the target width (Integer-typed), optional second
99
108
  # argument is the padding string (must be literal-bearing
@@ -111,19 +120,39 @@ module Rigor
111
120
  Type::Combinator.literal_string
112
121
  end
113
122
 
114
- def fold_concat(arg_type)
115
- return nil unless Type::Combinator.literal_string_compatible?(arg_type)
123
+ def fold_concat(receiver, arg)
124
+ return nil unless Type::Combinator.literal_string_compatible?(arg)
125
+
126
+ if Type::Combinator.non_empty_string_compatible?(receiver) ||
127
+ Type::Combinator.non_empty_string_compatible?(arg)
128
+ return Type::Combinator.non_empty_literal_string
129
+ end
116
130
 
117
131
  Type::Combinator.literal_string
118
132
  end
119
133
 
120
- def fold_repeat(arg_type)
121
- return nil unless integer_typed?(arg_type)
122
- return nil if known_negative_integer?(arg_type)
134
+ def fold_repeat(receiver, arg)
135
+ return nil unless integer_typed?(arg)
136
+ return nil if known_negative_integer?(arg)
137
+ return Type::Combinator.constant_of("") if known_zero_integer?(arg)
138
+
139
+ if Type::Combinator.non_empty_string_compatible?(receiver) && known_positive_integer?(arg)
140
+ return Type::Combinator.non_empty_literal_string
141
+ end
123
142
 
124
143
  Type::Combinator.literal_string
125
144
  end
126
145
 
146
+ # Returns `non_empty_literal_string` when the receiver is provably
147
+ # non-empty; otherwise collapses to plain `literal_string`.
148
+ def non_empty_literal_result(receiver)
149
+ if Type::Combinator.non_empty_string_compatible?(receiver)
150
+ Type::Combinator.non_empty_literal_string
151
+ else
152
+ Type::Combinator.literal_string
153
+ end
154
+ end
155
+
127
156
  # `[lit, lit].join(sep)` — receiver must be a Tuple
128
157
  # whose every element is literal-bearing; separator
129
158
  # (when given) must be literal-bearing too. Multi-arg
@@ -205,9 +234,27 @@ module Rigor
205
234
  type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.negative?
206
235
  end
207
236
 
208
- private_class_method :fold_concat, :fold_repeat, :fold_array_join,
237
+ def known_zero_integer?(type)
238
+ case type
239
+ when Type::Constant then type.value.is_a?(Integer) && type.value.zero?
240
+ when Type::IntegerRange then type.lower.zero? && type.upper.zero?
241
+ else false
242
+ end
243
+ end
244
+
245
+ def known_positive_integer?(type)
246
+ case type
247
+ when Type::Constant then type.value.is_a?(Integer) && type.value.positive?
248
+ when Type::IntegerRange then type.lower >= 1
249
+ else false
250
+ end
251
+ end
252
+
253
+ private_class_method :fold_no_arg, :fold_concat, :fold_repeat, :fold_array_join,
209
254
  :fold_format, :fold_string_percent, :fold_width_pad,
210
- :literal_or_constant?, :integer_typed?
255
+ :non_empty_literal_result, :literal_or_constant?,
256
+ :integer_typed?, :known_negative_integer?,
257
+ :known_zero_integer?, :known_positive_integer?
211
258
  end
212
259
  end
213
260
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Folds `Math` module-function calls on statically known numeric
9
+ # constants.
10
+ #
11
+ # `Math` is a pure, side-effect-free module whose functions are
12
+ # deterministic over their inputs — the same number always
13
+ # produces the same result. When every relevant argument is a
14
+ # `Constant` carrying a `Numeric` value, the analyzer evaluates
15
+ # the call at inference time and returns the concrete result.
16
+ #
17
+ # === Supported methods
18
+ #
19
+ # * Single-argument transcendental functions (`sqrt`, `cbrt`,
20
+ # `exp`, `log2`, `log10`, `log1p`, `expm1`, `sin`, `cos`,
21
+ # `tan`, `asin`, `acos`, `atan`, `sinh`, `cosh`, `tanh`,
22
+ # `asinh`, `acosh`, `atanh`, `erf`, `erfc`, `gamma`) — return
23
+ # `Constant[Float]`.
24
+ #
25
+ # * Two-argument functions (`atan2`, `hypot`, `ldexp`) — return
26
+ # `Constant[Float]`.
27
+ #
28
+ # * `log(x)` / `log(x, base)` — variadic; folds the 1- and
29
+ # 2-argument forms to `Constant[Float]`.
30
+ #
31
+ # * `frexp(x)` / `lgamma(x)` — return a two-element array, lifted
32
+ # to `Tuple[Constant[Float], Constant[Integer]]`.
33
+ #
34
+ # === Non-constant / unsupported cases
35
+ #
36
+ # Any call where the receiver is not `Singleton[Math]`, an
37
+ # argument is not a numeric `Constant`, the method is not in the
38
+ # supported set, or the Ruby call raises `Math::DomainError` /
39
+ # `RangeError` (domain-error inputs like `Math.sqrt(-1)`) returns
40
+ # `nil`, deferring to the next dispatcher tier.
41
+ #
42
+ # An infinite or NaN result (`Math.log(0.0)` → `-Infinity`) is
43
+ # still folded — `Constant[Float]` carries those values, matching
44
+ # `ConstantFolding`'s treatment of `Float / 0`.
45
+ module MathFolding
46
+ MATH_UNARY = Set[
47
+ :sqrt, :cbrt, :exp, :log2, :log10, :log1p, :expm1,
48
+ :sin, :cos, :tan, :asin, :acos, :atan,
49
+ :sinh, :cosh, :tanh, :asinh, :acosh, :atanh,
50
+ :erf, :erfc, :gamma
51
+ ].freeze
52
+ MATH_BINARY = Set[:atan2, :hypot, :ldexp].freeze
53
+ MATH_TUPLE_UNARY = Set[:frexp, :lgamma].freeze
54
+
55
+ private_constant :MATH_UNARY, :MATH_BINARY, :MATH_TUPLE_UNARY
56
+
57
+ module_function
58
+
59
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
60
+ def try_dispatch(receiver:, method_name:, args:)
61
+ return nil unless dispatch_target?(receiver)
62
+
63
+ # `log` is variadic (1 or 2 args), so it cannot live in the
64
+ # fixed-arity sets above.
65
+ return fold_log(args) if method_name == :log
66
+ return fold_unary(method_name, args) if MATH_UNARY.include?(method_name)
67
+ return fold_binary(method_name, args) if MATH_BINARY.include?(method_name)
68
+ return fold_tuple_unary(method_name, args) if MATH_TUPLE_UNARY.include?(method_name)
69
+
70
+ nil
71
+ end
72
+
73
+ def dispatch_target?(receiver)
74
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Math"
75
+ end
76
+
77
+ # Unwraps a numeric `Constant` argument to its Ruby value.
78
+ # Returns nil for any non-`Constant` or non-`Numeric` carrier.
79
+ def numeric_constant(arg)
80
+ return nil unless arg.is_a?(Type::Constant)
81
+
82
+ value = arg.value
83
+ value.is_a?(Numeric) ? value : nil
84
+ end
85
+
86
+ def fold_unary(method_name, args)
87
+ return nil unless args.size == 1
88
+
89
+ x = numeric_constant(args.first)
90
+ return nil if x.nil?
91
+
92
+ fold_float_result(Math.public_send(method_name, x))
93
+ rescue Math::DomainError, RangeError
94
+ nil
95
+ end
96
+
97
+ def fold_binary(method_name, args)
98
+ return nil unless args.size == 2
99
+
100
+ a = numeric_constant(args[0])
101
+ b = numeric_constant(args[1])
102
+ return nil if a.nil? || b.nil?
103
+
104
+ fold_float_result(Math.public_send(method_name, a, b))
105
+ rescue Math::DomainError, RangeError
106
+ nil
107
+ end
108
+
109
+ # `Math.log(x)` and `Math.log(x, base)` — the only variadic
110
+ # `Math` function.
111
+ def fold_log(args)
112
+ return nil unless [1, 2].include?(args.size)
113
+
114
+ values = args.map { |a| numeric_constant(a) }
115
+ return nil if values.any?(&:nil?)
116
+
117
+ fold_float_result(Math.log(*values))
118
+ rescue Math::DomainError, RangeError
119
+ nil
120
+ end
121
+
122
+ # `Math.frexp` / `Math.lgamma` return a two-element array;
123
+ # lift it to `Tuple[Constant[Float], Constant[Integer]]`.
124
+ def fold_tuple_unary(method_name, args)
125
+ return nil unless args.size == 1
126
+
127
+ x = numeric_constant(args.first)
128
+ return nil if x.nil?
129
+
130
+ result = Math.public_send(method_name, x)
131
+ return nil unless result.is_a?(Array) && result.size == 2
132
+
133
+ Type::Combinator.tuple_of(
134
+ Type::Combinator.constant_of(result[0]),
135
+ Type::Combinator.constant_of(result[1])
136
+ )
137
+ rescue Math::DomainError, RangeError
138
+ nil
139
+ end
140
+
141
+ def fold_float_result(result)
142
+ return nil unless result.is_a?(Float)
143
+
144
+ Type::Combinator.constant_of(result)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end