rigortype 0.0.5 → 0.0.6

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.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Pathname` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # TODO(blocklist curation): read
12
+ # `data/builtins/ruby_core/pathname.yml` and add per-method
13
+ # blocklist entries for any `:leaf` classifications that are
14
+ # actually mutators or otherwise unsafe to fold. Each entry
15
+ # SHOULD carry a one-line comment naming the indirect mutator
16
+ # helper that triggered the false positive (see
17
+ # `string_catalog.rb`, `array_catalog.rb`, `time_catalog.rb`
18
+ # for the canonical shape).
19
+ PATHNAME_CATALOG = MethodCatalog.new(
20
+ path: File.expand_path(
21
+ "../../../../data/builtins/ruby_core/pathname.yml",
22
+ __dir__
23
+ ),
24
+ mutating_selectors: {
25
+ "Pathname" => Set[
26
+ # initialize_copy is blocklisted by convention so a
27
+ # hypothetical future `Constant<Pathname>` carrier
28
+ # cannot fold an aliasing copy through the catalog.
29
+ :initialize_copy
30
+ ]
31
+ }
32
+ )
33
+ end
34
+ end
35
+ end
@@ -500,39 +500,99 @@ module Rigor
500
500
  # rebinding is the StatementEvaluator's job (Slice 3 phase 2).
501
501
  # Without an else clause the branch's implicit value is nil, which
502
502
  # is included in the union.
503
+ #
504
+ # v0.0.6 — when the predicate folds to a `Type::Constant` whose
505
+ # value is Ruby-truthy (resp. Ruby-falsey), the unreachable
506
+ # branch is elided so the if-expression's type is the live
507
+ # branch alone. Statement-level branch elision lives in
508
+ # `StatementEvaluator#eval_if`; this handler covers the
509
+ # expression-position ternary form (`a ? b : c`) and any
510
+ # `if`/`unless` reached through `type_of`.
503
511
  def type_of_if(node)
504
512
  then_type = statements_or_nil(node.statements)
505
- else_type =
506
- if node.subsequent
507
- type_of(node.subsequent)
508
- else
509
- Type::Combinator.constant_of(nil)
510
- end
511
- Type::Combinator.union(then_type, else_type)
513
+ else_type = if_else_type(node.subsequent)
514
+ elide_or_union(node.predicate, then_type, else_type)
512
515
  end
513
516
 
514
517
  # `unless c; t; else; e; end`. Prism uses `else_clause` here (no
515
- # `elsif` chain).
518
+ # `elsif` chain). Branch-elision logic mirrors `type_of_if`,
519
+ # inverted: a truthy predicate selects the else branch.
516
520
  def type_of_unless(node)
517
521
  then_type = statements_or_nil(node.statements)
518
- else_type =
519
- if node.else_clause
520
- type_of(node.else_clause)
521
- else
522
- Type::Combinator.constant_of(nil)
523
- end
524
- Type::Combinator.union(then_type, else_type)
522
+ else_type = if_else_type(node.else_clause)
523
+ elide_or_union(node.predicate, else_type, then_type)
524
+ end
525
+
526
+ def if_else_type(subsequent)
527
+ return Type::Combinator.constant_of(nil) if subsequent.nil?
528
+
529
+ type_of(subsequent)
530
+ end
531
+
532
+ # Routes the predicate's typed value through branch elision.
533
+ # `live_when_truthy` and `live_when_falsey` are the branch
534
+ # types selected by the predicate's polarity; the names
535
+ # match `IfNode` semantics directly and invert at the
536
+ # `type_of_unless` call site.
537
+ def elide_or_union(predicate, live_when_truthy, live_when_falsey)
538
+ case constant_predicate_polarity(predicate)
539
+ when :truthy then live_when_truthy
540
+ when :falsey then live_when_falsey
541
+ else Type::Combinator.union(live_when_truthy, live_when_falsey)
542
+ end
543
+ end
544
+
545
+ # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary
546
+ # predicate expression. Only `Type::Constant` answers
547
+ # decisively — `Union[true, false]`, `Nominal[bool]`, and
548
+ # `Dynamic[T]` keep both branches live.
549
+ def constant_predicate_polarity(predicate)
550
+ return nil if predicate.nil?
551
+
552
+ type = type_of(predicate)
553
+ return nil unless type.is_a?(Type::Constant)
554
+
555
+ type.value ? :truthy : :falsey
525
556
  end
526
557
 
527
558
  def type_of_else(node)
528
559
  statements_or_nil(node.statements)
529
560
  end
530
561
 
531
- # `a && b` and `a || b` short-circuit. Without a truthy/falsy
532
- # narrowing model (Slice 6), the result of either side is reachable
533
- # so the type is the union of the operand types.
562
+ # `a && b` and `a || b` short-circuit at the value level:
563
+ # `a && b` returns `a` when `a` is falsey, else `b`.
564
+ # `a || b` returns `a` when `a` is truthy, else `b`.
565
+ #
566
+ # v0.0.6 — when the left operand folds to a `Type::Constant`,
567
+ # we know which side actually flows through, so the result
568
+ # is one operand's type instead of a union. Otherwise the
569
+ # union-of-both-operands fallback is preserved.
534
570
  def type_of_and_or(node)
535
- Type::Combinator.union(type_of(node.left), type_of(node.right))
571
+ left_type = type_of(node.left)
572
+ polarity = constant_value_polarity(left_type)
573
+ return short_circuit_for(node, left_type, polarity) if polarity
574
+
575
+ Type::Combinator.union(left_type, type_of(node.right))
576
+ end
577
+
578
+ def short_circuit_for(node, left_type, polarity)
579
+ and_node = node.is_a?(Prism::AndNode)
580
+ if polarity == :truthy
581
+ and_node ? type_of(node.right) : left_type
582
+ else
583
+ and_node ? left_type : type_of(node.right)
584
+ end
585
+ end
586
+
587
+ # Returns `:truthy` / `:falsey` for a `Type::Constant`,
588
+ # nil otherwise. Mirrors `constant_predicate_polarity` but
589
+ # operates on a typed value (already-type-of'd) rather
590
+ # than a Prism node, so the same predicate analysis can
591
+ # be reused in both contexts.
592
+ def constant_value_polarity(type)
593
+ return nil unless type.is_a?(Type::Constant)
594
+
595
+ type.value ? :truthy : :falsey
536
596
  end
537
597
 
538
598
  def type_of_case(node)
@@ -701,12 +761,17 @@ module Rigor
701
761
  # when every element is a non-splat value. Splatted entries
702
762
  # (`[*xs, 1]`) preserve the Slice 4 phase 2d behavior: we union
703
763
  # the contributed element types and emit
704
- # `Nominal[Array, [union]]`. An empty literal stays as the raw
705
- # `Array` (no element evidence to lock either an arity or an
706
- # element type).
764
+ # `Nominal[Array, [union]]`.
765
+ #
766
+ # v0.0.6 — the empty literal `[]` resolves to the empty
767
+ # `Tuple[]` carrier rather than the raw `Nominal[Array]`.
768
+ # Both carriers erase to RBS `Array`, but `Tuple[]` pins
769
+ # the literal's known arity (zero), which lets the
770
+ # per-element block fold concatenate across all-empty
771
+ # positions like `[1, 2].flat_map { |_| [] }`.
707
772
  def array_type_for(node)
708
773
  elements = node.elements
709
- return Type::Combinator.nominal_of(Array) if elements.empty?
774
+ return Type::Combinator.tuple_of if elements.empty?
710
775
 
711
776
  if elements.any?(Prism::SplatNode)
712
777
  element_types = elements.map { |e| type_of(e) }
@@ -739,6 +804,7 @@ module Rigor
739
804
  # for the CallNode itself (the inner type_of calls already record
740
805
  # their own fallbacks for unrecognised receivers/args, so the tracer
741
806
  # captures both the immediate dispatch miss and the deeper cause).
807
+ # rubocop:disable Metrics/CyclomaticComplexity
742
808
  def call_type_for(node)
743
809
  receiver = call_receiver_type_for(node)
744
810
  arg_types = call_arg_types(node)
@@ -771,6 +837,18 @@ module Rigor
771
837
  return dynamic_top
772
838
  end
773
839
 
840
+ # v0.0.6 phase 2 — per-element block fold for Tuple
841
+ # receivers. When `[a, b, c].map { |x| f(x) }` and the
842
+ # receiver is a `Tuple` carrier with finite elements,
843
+ # type the block body once per position with the
844
+ # corresponding element bound to the block parameter
845
+ # and assemble the results into a `Tuple[U_1..U_n]`.
846
+ # This sits ahead of `MethodDispatcher.dispatch` so
847
+ # the RBS tier does not re-widen the answer back to
848
+ # `Array[union]`.
849
+ per_element = try_per_element_block_fold(node, receiver)
850
+ return per_element if per_element
851
+
774
852
  result = MethodDispatcher.dispatch(
775
853
  receiver_type: receiver,
776
854
  method_name: node.name,
@@ -797,6 +875,7 @@ module Rigor
797
875
 
798
876
  fallback_for(node, family: :prism)
799
877
  end
878
+ # rubocop:enable Metrics/CyclomaticComplexity
800
879
 
801
880
  # v0.0.2 #5 — re-types the body of a user-defined
802
881
  # instance method with the call site's argument types
@@ -976,6 +1055,189 @@ module Rigor
976
1055
 
977
1056
  block_scope.type_of(body)
978
1057
  end
1058
+
1059
+ # v0.0.6 phase 2 — per-element block fold for Tuple
1060
+ # receivers under `:map` / `:collect`. Walks every Tuple
1061
+ # position, binds the block parameter to that element's
1062
+ # type, and re-types the block body. The per-position
1063
+ # results are assembled into `Tuple[U_1..U_n]`, strictly
1064
+ # tighter than the RBS-projected `Array[union]`.
1065
+ #
1066
+ # Declines (returns nil) when the receiver is not a
1067
+ # `Tuple` with at least one element, when the call has
1068
+ # no `Prism::BlockNode`, when the method is outside the
1069
+ # supported set, when block typing raises mid-loop, or
1070
+ # when the block has no body. The decline path leaves
1071
+ # the dispatch chain untouched.
1072
+ PER_ELEMENT_TUPLE_METHODS = Set[
1073
+ :map, :collect, :filter_map, :flat_map,
1074
+ :find, :detect, :find_index, :index
1075
+ ].freeze
1076
+ private_constant :PER_ELEMENT_TUPLE_METHODS
1077
+
1078
+ # Cardinality cap for per-element block fold over
1079
+ # finite-bound `Constant<Range>` receivers. Walking
1080
+ # `(1..1_000_000).map { … }` element-wise would balloon
1081
+ # block-typing cost and explode the resulting Tuple, so
1082
+ # only short ranges expand into per-position folds.
1083
+ # Larger ranges decline so the RBS tier widens.
1084
+ PER_ELEMENT_RANGE_LIMIT = 8
1085
+ private_constant :PER_ELEMENT_RANGE_LIMIT
1086
+
1087
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1088
+ def try_per_element_block_fold(call_node, receiver_type)
1089
+ return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
1090
+ return nil if find_family_with_args?(call_node)
1091
+
1092
+ element_types = per_element_elements_of(receiver_type)
1093
+ return nil if element_types.nil? || element_types.empty?
1094
+
1095
+ block_node = call_node.block
1096
+ return nil unless block_node.is_a?(Prism::BlockNode)
1097
+
1098
+ per_position = element_types.map do |element_type|
1099
+ type_block_body_with_param(block_node, [element_type])
1100
+ end
1101
+ return nil if per_position.any?(&:nil?)
1102
+
1103
+ assemble_per_element_result(call_node.name, per_position, element_types)
1104
+ end
1105
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1106
+
1107
+ # Returns the per-position element types for a finite,
1108
+ # statically-known receiver shape — or nil when the
1109
+ # receiver does not pin a finite element list.
1110
+ #
1111
+ # `Tuple[A, B, …]` → [A, B, …]
1112
+ # `Constant<a..b>` → [Constant[a], …, Constant[b]]
1113
+ # everything else → nil
1114
+ #
1115
+ # Note: `Type::IntegerRange` is the bounded-Integer
1116
+ # carrier (`int<a, b>` represents "an Integer between
1117
+ # a and b"), not a Range value. Calls like `.map` /
1118
+ # `.find` on an `IntegerRange` receiver would resolve
1119
+ # to `Integer#map` / `Integer#find` — neither exists —
1120
+ # so IntegerRange does NOT participate in this fold.
1121
+ def per_element_elements_of(receiver_type)
1122
+ case receiver_type
1123
+ when Type::Tuple then receiver_type.elements
1124
+ when Type::Constant then constant_range_elements(receiver_type.value)
1125
+ end
1126
+ end
1127
+
1128
+ # rubocop:disable Metrics/CyclomaticComplexity
1129
+ def constant_range_elements(value)
1130
+ return nil unless value.is_a?(Range)
1131
+ return nil unless value.begin.is_a?(Integer) && value.end.is_a?(Integer)
1132
+
1133
+ cardinality = value.exclude_end? ? value.end - value.begin : value.end - value.begin + 1
1134
+ return nil if cardinality <= 0 || cardinality > PER_ELEMENT_RANGE_LIMIT
1135
+
1136
+ value.to_a.map { |v| Type::Combinator.constant_of(v) }
1137
+ end
1138
+ # rubocop:enable Metrics/CyclomaticComplexity
1139
+
1140
+ # `index(value)` and `find_index(value)` carry a positional
1141
+ # argument and search by `==` rather than running the block.
1142
+ # Decline so the RBS tier owns those forms.
1143
+ def find_family_with_args?(call_node)
1144
+ return false unless %i[find_index index].include?(call_node.name)
1145
+
1146
+ args = call_node.arguments
1147
+ !args.nil? && !args.arguments.empty?
1148
+ end
1149
+
1150
+ def assemble_per_element_result(method_name, per_position, element_types)
1151
+ case method_name
1152
+ when :map, :collect then Type::Combinator.tuple_of(*per_position)
1153
+ when :filter_map then assemble_filter_map_result(per_position)
1154
+ when :flat_map then assemble_flat_map_result(per_position)
1155
+ when :find, :detect then assemble_find_result(per_position, element_types)
1156
+ when :find_index, :index then assemble_find_index_result(per_position)
1157
+ end
1158
+ end
1159
+
1160
+ # `filter_map` folds tightly only when every per-position
1161
+ # result is a `Constant`: positions whose value is `nil`
1162
+ # or `false` drop, the rest survive in declaration order.
1163
+ # When any position is non-Constant the dispatcher
1164
+ # declines (returns nil) so the RBS tier widens to
1165
+ # `Array[U]`.
1166
+ def assemble_filter_map_result(per_position)
1167
+ return nil unless per_position.all?(Type::Constant)
1168
+
1169
+ kept = per_position.reject { |type| type.value.nil? || type.value == false }
1170
+ Type::Combinator.tuple_of(*kept)
1171
+ end
1172
+
1173
+ # `flat_map` flattens a single level: if the per-position
1174
+ # result is a `Tuple`, its elements are concatenated; if
1175
+ # it's a non-Array scalar carrier (`Constant<…>` over a
1176
+ # non-Array literal) it contributes one element. We fold
1177
+ # tightly only when every per-position result is one of
1178
+ # those two recognisable shapes — `Nominal[Array[T]]`,
1179
+ # `Union[…]`, and other opaque carriers decline so the
1180
+ # RBS tier widens to `Array[U]`.
1181
+ #
1182
+ # `Type::Constant` only ever holds non-Array scalars (the
1183
+ # carrier rejects Array literals), so a single `Constant`
1184
+ # safely contributes itself as a single Tuple element.
1185
+ def assemble_flat_map_result(per_position)
1186
+ flattened = per_position.flat_map { |type| flat_map_contribution(type) }
1187
+ return nil if flattened.nil? || flattened.any?(&:nil?)
1188
+
1189
+ Type::Combinator.tuple_of(*flattened)
1190
+ end
1191
+
1192
+ def flat_map_contribution(type)
1193
+ case type
1194
+ when Type::Tuple then type.elements
1195
+ when Type::Constant then [type]
1196
+ else [nil]
1197
+ end
1198
+ end
1199
+
1200
+ # `find` / `detect`: returns the first receiver element
1201
+ # whose block result is Ruby-truthy, or `nil` when no
1202
+ # position folds to truthy.
1203
+ #
1204
+ # Folds tightly only when every per-position block result
1205
+ # is a `Type::Constant` — otherwise we cannot decide which
1206
+ # position (if any) is "the first matching one". When the
1207
+ # first decisive truthy position is found, the answer is
1208
+ # the corresponding receiver element. When every position
1209
+ # folds to falsey, the answer is `Constant[nil]`.
1210
+ def assemble_find_result(per_position, element_types)
1211
+ return nil unless per_position.all?(Type::Constant)
1212
+
1213
+ first_truthy_index = per_position.index { |type| truthy_constant?(type) }
1214
+ return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
1215
+
1216
+ element_types[first_truthy_index]
1217
+ end
1218
+
1219
+ # `find_index` / `index`: returns the index of the first
1220
+ # truthy position, or `Constant[nil]` when nothing matches.
1221
+ def assemble_find_index_result(per_position)
1222
+ return nil unless per_position.all?(Type::Constant)
1223
+
1224
+ first_truthy_index = per_position.index { |type| truthy_constant?(type) }
1225
+ return Type::Combinator.constant_of(nil) if first_truthy_index.nil?
1226
+
1227
+ Type::Combinator.constant_of(first_truthy_index)
1228
+ end
1229
+
1230
+ def truthy_constant?(type)
1231
+ type.is_a?(Type::Constant) && type.value && type.value != false
1232
+ end
1233
+
1234
+ def type_block_body_with_param(block_node, expected_param_types)
1235
+ bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
1236
+ block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
1237
+ type_block_body(block_node, block_scope)
1238
+ rescue StandardError
1239
+ nil
1240
+ end
979
1241
  end
980
1242
  # rubocop:enable Metrics/ClassLength
981
1243
  end