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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Returns the inferred return type of a `Prism::DefNode`, or nil
10
+ # when no type can be derived (empty body, scope-lookup miss,
11
+ # or any failure during inference — caller surfaces "no
12
+ # annotation" on a nil).
13
+ #
14
+ # The inferred type is the union of:
15
+ #
16
+ # - the body's last-statement type, and
17
+ # - the type of every explicit `return value` reachable in the
18
+ # body. Nested `def` / lambda / block bodies are return
19
+ # barriers — their `return`s do not bubble up to the enclosing
20
+ # method.
21
+ #
22
+ # Extracted from `Rigor::SigGen::Generator#infer_return_type` so
23
+ # `LineTypeCollector` (`rigor annotate`'s def-line annotator)
24
+ # and the sig-generator share one source of truth.
25
+ module DefReturnTyper
26
+ RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
27
+ private_constant :RETURN_BARRIER_NODES
28
+
29
+ module_function
30
+
31
+ def call(def_node, scope_index)
32
+ body = def_node.body
33
+ return nil if body.nil?
34
+
35
+ last = body_last_expression(body)
36
+ return nil if last.nil?
37
+
38
+ inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
39
+ return nil if inner_scope.nil?
40
+
41
+ last_type = safe_type_of(inner_scope, last)
42
+ return nil if last_type.nil?
43
+
44
+ union_with_explicit_returns(body, last_type, scope_index)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def body_last_expression(body)
50
+ case body
51
+ when Prism::StatementsNode then body.body.last
52
+ when Prism::BeginNode then body_last_expression(body.statements)
53
+ else body
54
+ end
55
+ end
56
+
57
+ def union_with_explicit_returns(body, last_type, scope_index)
58
+ return_types = []
59
+ collect_return_types(body, scope_index, return_types)
60
+ return last_type if return_types.empty?
61
+
62
+ Type::Combinator.union(last_type, *return_types)
63
+ end
64
+
65
+ def collect_return_types(node, scope_index, out)
66
+ return unless node.is_a?(Prism::Node)
67
+ return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
68
+
69
+ type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
70
+ node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
71
+ end
72
+
73
+ def type_return_node(return_node, scope_index, out)
74
+ args = return_node.arguments&.arguments || []
75
+ if args.empty?
76
+ out << Type::Combinator.constant_of(nil)
77
+ return
78
+ end
79
+
80
+ scope = scope_index[return_node] || scope_index[args.first]
81
+ return if scope.nil?
82
+ # `return a, b` packs into a Tuple at runtime; the MVP only
83
+ # handles the single-value form. Multi-arg returns
84
+ # contribute no type to keep the implementation focused.
85
+ return unless args.size == 1
86
+
87
+ type = safe_type_of(scope, args.first)
88
+ out << type unless type.nil?
89
+ end
90
+
91
+ def safe_type_of(scope, node)
92
+ scope.type_of(node)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ end
@@ -8,6 +8,7 @@ require_relative "block_parameter_binder"
8
8
  require_relative "fallback"
9
9
  require_relative "macro_block_self_type"
10
10
  require_relative "method_dispatcher"
11
+ require_relative "narrowing"
11
12
 
12
13
  module Rigor
13
14
  module Inference
@@ -660,16 +661,26 @@ module Rigor
660
661
  end
661
662
 
662
663
  # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary
663
- # predicate expression. Only `Type::Constant` answers
664
- # decisively `Union[true, false]`, `Nominal[bool]`, and
665
- # `Dynamic[T]` keep both branches live.
664
+ # predicate expression under three-valued logic. Uses the
665
+ # same {Narrowing} probe as `StatementEvaluator#eval_if`:
666
+ # the predicate is truthy when its falsey fragment is `Bot`,
667
+ # falsey when its truthy fragment is `Bot`. So
668
+ # `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`,
669
+ # and `Constant[false]` fold one branch; `Union[true, false]`,
670
+ # `Dynamic[T]`, and `Top` keep both branches live.
666
671
  def constant_predicate_polarity(predicate)
667
672
  return nil if predicate.nil?
668
673
 
669
674
  type = type_of(predicate)
670
- return nil unless type.is_a?(Type::Constant)
675
+ return nil if type.nil? || type.is_a?(Type::Bot)
671
676
 
672
- type.value ? :truthy : :falsey
677
+ truthy_bot = Narrowing.narrow_truthy(type).is_a?(Type::Bot)
678
+ falsey_bot = Narrowing.narrow_falsey(type).is_a?(Type::Bot)
679
+
680
+ return :falsey if truthy_bot && !falsey_bot
681
+ return :truthy if !truthy_bot && falsey_bot
682
+
683
+ nil
673
684
  end
674
685
 
675
686
  def type_of_else(node)
@@ -712,15 +723,135 @@ module Rigor
712
723
  type.value ? :truthy : :falsey
713
724
  end
714
725
 
726
+ # Three-valued evaluation of `case predicate when pattern`
727
+ # dispatch. For each `when` clause we ask: under static types,
728
+ # does `pattern === predicate` definitely match (`:yes`),
729
+ # definitely not match (`:no`), or possibly match (`:maybe`)?
730
+ # Walking in source order:
731
+ #
732
+ # - `:yes` — this branch fires, subsequent branches are
733
+ # unreachable. Result = union(prior `:maybe` branches, this
734
+ # `:yes` branch).
735
+ # - `:no` — branch dropped.
736
+ # - `:maybe` — branch is a candidate, continue.
737
+ #
738
+ # If no `:yes` was reached, the else clause (or `Constant[nil]`
739
+ # when absent) is added to the candidate set.
740
+ #
741
+ # The `case ... in` pattern-matching form (`CaseMatchNode`) and
742
+ # the predicate-less form (`case; when c1; ...`) bypass the
743
+ # `===` analysis: pattern matching has richer semantics, and a
744
+ # predicate-less `case` reduces to a `if c1; ...; elsif c2`
745
+ # chain that statement-level narrowing already handles.
715
746
  def type_of_case(node)
716
- branch_types = node.conditions.map { |branch| type_of(branch) }
717
- else_type =
718
- if node.else_clause
719
- type_of(node.else_clause)
720
- else
721
- Type::Combinator.constant_of(nil)
747
+ return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
748
+
749
+ subject_type = type_of(node.predicate)
750
+ candidates = []
751
+ reached_yes = false
752
+
753
+ node.conditions.each do |when_node|
754
+ case case_when_branch_certainty(subject_type, when_node)
755
+ when :yes
756
+ candidates << type_of(when_node)
757
+ reached_yes = true
758
+ break
759
+ when :maybe
760
+ candidates << type_of(when_node)
761
+ # :no — drop the branch
722
762
  end
723
- Type::Combinator.union(*branch_types, else_type)
763
+ end
764
+
765
+ candidates << type_of_case_else(node) unless reached_yes
766
+ Type::Combinator.union(*candidates)
767
+ end
768
+
769
+ def type_of_case_simple_union(node)
770
+ branch_types = node.conditions.map { |branch| type_of(branch) }
771
+ Type::Combinator.union(*branch_types, type_of_case_else(node))
772
+ end
773
+
774
+ def type_of_case_else(node)
775
+ return Type::Combinator.constant_of(nil) if node.else_clause.nil?
776
+
777
+ type_of(node.else_clause)
778
+ end
779
+
780
+ # Combines per-pattern certainty across a `when` clause's
781
+ # conditions (`when a, b, c` ≡ `a === s || b === s || c === s`).
782
+ # `:yes` if any pattern is `:yes`; `:no` if all are `:no`;
783
+ # `:maybe` otherwise.
784
+ def case_when_branch_certainty(subject_type, when_node)
785
+ return :maybe unless when_node.respond_to?(:conditions)
786
+
787
+ results = when_node.conditions.map { |c| case_when_pattern_certainty(subject_type, c) }
788
+ return :maybe if results.empty?
789
+ return :yes if results.include?(:yes)
790
+ return :no if results.all?(:no)
791
+
792
+ :maybe
793
+ end
794
+
795
+ # Static three-valued certainty for `pattern === subject`.
796
+ # Specialises two pattern shapes:
797
+ #
798
+ # - **Class / Module reference** (`Integer`, `Foo::Bar`):
799
+ # reduce to `subject.is_a?(class)` via
800
+ # `Narrowing.narrow_class` / `narrow_not_class`. A Bot
801
+ # truthy fragment means no inhabitant matches (`:no`); a
802
+ # Bot falsey fragment means every inhabitant matches
803
+ # (`:yes`).
804
+ # - **Value-equality literal** (numeric / String / Symbol /
805
+ # true / false / nil) against a `Constant[c]` subject:
806
+ # the static comparison `pattern_value === c` is exact.
807
+ # Other subject carriers stay `:maybe` because the
808
+ # runtime value isn't pinned.
809
+ #
810
+ # Other pattern shapes (Range, Regexp, custom `===`) stay
811
+ # `:maybe` — the existing union fallback handles them.
812
+ def case_when_pattern_certainty(subject_type, pattern_node)
813
+ class_name = build_constant_path_name(pattern_node)
814
+ return class_pattern_certainty(subject_type, class_name) if class_name
815
+
816
+ literal = literal_pattern_value(pattern_node)
817
+ return literal_pattern_certainty(subject_type, literal[:value]) if literal
818
+
819
+ :maybe
820
+ end
821
+
822
+ def class_pattern_certainty(subject_type, class_name)
823
+ env = scope.environment
824
+ truthy_bot = Narrowing.narrow_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
825
+ falsey_bot = Narrowing.narrow_not_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
826
+
827
+ return :no if truthy_bot && !falsey_bot
828
+ return :yes if !truthy_bot && falsey_bot
829
+
830
+ :maybe
831
+ end
832
+
833
+ VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
834
+ TrueClass, FalseClass, NilClass].freeze
835
+ private_constant :VALUE_EQUALITY_CLASSES
836
+
837
+ # Returns `{ value: v }` when `pattern_node` types to a
838
+ # `Constant[v]` of a value-equality-safe class (so `===`
839
+ # reduces to `==`), else nil. Wrapped in a hash so a literal
840
+ # `nil` / `false` value doesn't collide with the "no literal"
841
+ # signal.
842
+ def literal_pattern_value(pattern_node)
843
+ type = type_of(pattern_node)
844
+ return nil unless type.is_a?(Type::Constant)
845
+ return nil unless VALUE_EQUALITY_CLASSES.any? { |klass| type.value.is_a?(klass) }
846
+
847
+ { value: type.value }
848
+ end
849
+
850
+ def literal_pattern_certainty(subject_type, pattern_value)
851
+ return :maybe unless subject_type.is_a?(Type::Constant)
852
+ return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
853
+
854
+ pattern_value == subject_type.value ? :yes : :no
724
855
  end
725
856
 
726
857
  # `when` clauses for `case` and `in` clauses for `case ... in` have
@@ -1056,6 +1187,9 @@ module Rigor
1056
1187
  per_element = try_per_element_block_fold(node, receiver)
1057
1188
  return per_element if per_element
1058
1189
 
1190
+ hash_transform = try_hash_shape_block_fold(node, receiver)
1191
+ return hash_transform if hash_transform
1192
+
1059
1193
  result = MethodDispatcher.dispatch(
1060
1194
  receiver_type: receiver,
1061
1195
  method_name: node.name,
@@ -1455,10 +1589,17 @@ module Rigor
1455
1589
  # the dispatch chain untouched.
1456
1590
  PER_ELEMENT_TUPLE_METHODS = Set[
1457
1591
  :map, :collect, :filter_map, :flat_map,
1592
+ :select, :filter, :reject,
1458
1593
  :find, :detect, :find_index, :index
1459
1594
  ].freeze
1460
1595
  private_constant :PER_ELEMENT_TUPLE_METHODS
1461
1596
 
1597
+ HASH_SHAPE_TRANSFORM_METHODS = Set[
1598
+ :transform_keys, :transform_keys!,
1599
+ :transform_values, :transform_values!
1600
+ ].freeze
1601
+ private_constant :HASH_SHAPE_TRANSFORM_METHODS
1602
+
1462
1603
  # Cardinality cap for per-element block fold over
1463
1604
  # finite-bound `Constant<Range>` receivers. Walking
1464
1605
  # `(1..1_000_000).map { … }` element-wise would balloon
@@ -1475,15 +1616,50 @@ module Rigor
1475
1616
  element_types = per_element_elements_of(receiver_type)
1476
1617
  return nil if element_types.nil? || element_types.empty?
1477
1618
 
1478
- block_node = call_node.block
1479
- return nil unless block_node.is_a?(Prism::BlockNode)
1619
+ per_position = per_element_block_results(call_node.block, element_types)
1620
+ return nil if per_position.nil? || per_position.any?(&:nil?)
1480
1621
 
1481
- per_position = element_types.map do |element_type|
1482
- type_block_body_with_param(block_node, [element_type])
1622
+ assemble_per_element_result(call_node.name, per_position, element_types)
1623
+ end
1624
+
1625
+ # Evaluates the call's block once per receiver element.
1626
+ # Two block shapes are supported:
1627
+ #
1628
+ # - `Prism::BlockNode` — a full `do … end` / `{ … }` block;
1629
+ # the body is re-typed per position with the element
1630
+ # bound to the block parameter.
1631
+ # - `Prism::BlockArgumentNode` wrapping a `SymbolNode` —
1632
+ # the `&:predicate` shorthand; the symbol is dispatched
1633
+ # as a zero-arg method on each element type.
1634
+ #
1635
+ # Any other shape (`&proc_local`, `&method(:foo)`, no
1636
+ # block) returns `nil` so the fold declines.
1637
+ def per_element_block_results(block, element_types)
1638
+ case block
1639
+ when Prism::BlockNode
1640
+ element_types.map { |element_type| type_block_body_with_param(block, [element_type]) }
1641
+ when Prism::BlockArgumentNode
1642
+ per_element_symbol_results(block, element_types)
1483
1643
  end
1484
- return nil if per_position.any?(&:nil?)
1644
+ end
1485
1645
 
1486
- assemble_per_element_result(call_node.name, per_position, element_types)
1646
+ def per_element_symbol_results(block_arg, element_types)
1647
+ expression = block_arg.expression
1648
+ return nil unless expression.is_a?(Prism::SymbolNode)
1649
+
1650
+ method_name = expression.unescaped.to_sym
1651
+ element_types.map do |element_type|
1652
+ MethodDispatcher.dispatch(
1653
+ receiver_type: element_type,
1654
+ method_name: method_name,
1655
+ arg_types: [],
1656
+ block_type: nil,
1657
+ environment: scope.environment,
1658
+ scope: scope
1659
+ )
1660
+ end
1661
+ rescue StandardError
1662
+ nil
1487
1663
  end
1488
1664
 
1489
1665
  # Returns the per-position element types for a finite,
@@ -1532,11 +1708,37 @@ module Rigor
1532
1708
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
1533
1709
  when :filter_map then assemble_filter_map_result(per_position)
1534
1710
  when :flat_map then assemble_flat_map_result(per_position)
1711
+ when :select, :filter
1712
+ assemble_filter_result(per_position, element_types, keep_on_truthy: true)
1713
+ when :reject
1714
+ assemble_filter_result(per_position, element_types, keep_on_truthy: false)
1535
1715
  when :find, :detect then assemble_find_result(per_position, element_types)
1536
1716
  when :find_index, :index then assemble_find_index_result(per_position)
1537
1717
  end
1538
1718
  end
1539
1719
 
1720
+ # `select` / `filter` / `reject`: keeps each receiver
1721
+ # element whose per-position predicate result folds to a
1722
+ # decisive `Constant` — Ruby-truthy for `select` / `filter`,
1723
+ # Ruby-falsey for `reject`. The surviving elements assemble
1724
+ # into a `Tuple`, strictly tighter than the RBS-projected
1725
+ # `Array[Elem]`.
1726
+ #
1727
+ # Folds tightly only when EVERY position is a `Constant`:
1728
+ # a single non-`Constant` position leaves the result
1729
+ # cardinality unknown (the element might or might not
1730
+ # survive), so the dispatcher declines and the RBS tier
1731
+ # widens to `Array[Elem]`. `[].select` style empty results
1732
+ # are sound — an empty `Tuple` is the empty-array carrier.
1733
+ def assemble_filter_result(per_position, element_types, keep_on_truthy:)
1734
+ return nil unless per_position.all?(Type::Constant)
1735
+
1736
+ kept = element_types.each_index.filter_map do |index|
1737
+ element_types[index] if truthy_constant?(per_position[index]) == keep_on_truthy
1738
+ end
1739
+ Type::Combinator.tuple_of(*kept)
1740
+ end
1741
+
1540
1742
  # `filter_map` folds tightly only when every per-position
1541
1743
  # result is a `Constant`: positions whose value is `nil`
1542
1744
  # or `false` drop, the rest survive in declaration order.
@@ -1611,6 +1813,94 @@ module Rigor
1611
1813
  type.is_a?(Type::Constant) && type.value && type.value != false
1612
1814
  end
1613
1815
 
1816
+ # Per-pair block fold for `HashShape#transform_keys` and
1817
+ # `HashShape#transform_values` (and their bang variants).
1818
+ #
1819
+ # When the receiver is a closed `HashShape` with no optional
1820
+ # keys, applies the call's block (a `Prism::BlockNode` or
1821
+ # `Prism::BlockArgumentNode`) to each key/value pair
1822
+ # independently and assembles a new `HashShape`:
1823
+ #
1824
+ # - `transform_values` / `transform_values!`: re-types
1825
+ # each VALUE by binding it to the block parameter; keys
1826
+ # are preserved unchanged.
1827
+ # - `transform_keys` / `transform_keys!`: re-types each
1828
+ # KEY by wrapping it in `Constant[k]` and passing it to
1829
+ # the block; values are preserved unchanged. The result
1830
+ # key must be a `Constant[Symbol | String]` — otherwise
1831
+ # the tier declines (the new key cannot be used as a
1832
+ # static HashShape index). Collisions (two old keys
1833
+ # mapping to the same new key) also decline.
1834
+ #
1835
+ # Returns `nil` on any decline so the dispatcher falls
1836
+ # through to `RbsDispatch` and gets the widened `Hash[K, V]`
1837
+ # answer.
1838
+ def try_hash_shape_block_fold(call_node, receiver_type)
1839
+ return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
1840
+ return nil unless receiver_type.is_a?(Type::HashShape)
1841
+ return nil unless receiver_type.closed?
1842
+ return nil unless receiver_type.optional_keys.empty?
1843
+
1844
+ block_arg = call_node.block
1845
+ return nil if block_arg.nil?
1846
+
1847
+ if %i[transform_values transform_values!].include?(call_node.name)
1848
+ fold_hash_shape_transform_values(receiver_type, block_arg)
1849
+ else
1850
+ fold_hash_shape_transform_keys(receiver_type, block_arg)
1851
+ end
1852
+ end
1853
+
1854
+ def fold_hash_shape_transform_values(shape, block_arg)
1855
+ new_pairs = {}
1856
+ shape.pairs.each do |key, value|
1857
+ new_value = apply_hash_block(block_arg, value)
1858
+ return nil if new_value.nil?
1859
+
1860
+ new_pairs[key] = new_value
1861
+ end
1862
+ Type::Combinator.hash_shape_of(new_pairs)
1863
+ end
1864
+
1865
+ def fold_hash_shape_transform_keys(shape, block_arg)
1866
+ new_pairs = {}
1867
+ shape.pairs.each do |key, value|
1868
+ key_type = Type::Combinator.constant_of(key)
1869
+ new_key_type = apply_hash_block(block_arg, key_type)
1870
+ return nil unless new_key_type.is_a?(Type::Constant)
1871
+
1872
+ new_key = new_key_type.value
1873
+ return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
1874
+ return nil if new_pairs.key?(new_key)
1875
+
1876
+ new_pairs[new_key] = value
1877
+ end
1878
+ Type::Combinator.hash_shape_of(new_pairs)
1879
+ end
1880
+
1881
+ # Applies a single-argument block (either a full BlockNode
1882
+ # or a `&:symbol` BlockArgumentNode) to `param_type` and
1883
+ # returns the resulting type, or `nil` on failure.
1884
+ def apply_hash_block(block_arg, param_type)
1885
+ case block_arg
1886
+ when Prism::BlockNode
1887
+ type_block_body_with_param(block_arg, [param_type])
1888
+ when Prism::BlockArgumentNode
1889
+ expression = block_arg.expression
1890
+ return nil unless expression.is_a?(Prism::SymbolNode)
1891
+
1892
+ MethodDispatcher.dispatch(
1893
+ receiver_type: param_type,
1894
+ method_name: expression.unescaped.to_sym,
1895
+ arg_types: [],
1896
+ block_type: nil,
1897
+ environment: scope.environment,
1898
+ call_node: block_arg,
1899
+ scope: scope
1900
+ )
1901
+ end
1902
+ end
1903
+
1614
1904
  def type_block_body_with_param(block_node, expected_param_types)
1615
1905
  bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
1616
1906
  block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/util"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `CGI` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `CGI.escapeHTML` / `CGI.unescapeHTML` and related methods
13
+ # are pure, deterministic functions over their string inputs.
14
+ # When the argument is a `Constant[String]`, the analyzer can
15
+ # evaluate the call at inference time and return the concrete
16
+ # `Constant[String]` result.
17
+ #
18
+ # === Supported methods
19
+ #
20
+ # * `escapeHTML(str)` / `escape_html(str)` / `h(str)` —
21
+ # HTML-escape. Returns `Constant[String]`.
22
+ # * `unescapeHTML(str)` / `unescape_html(str)` —
23
+ # HTML-unescape. Returns `Constant[String]`.
24
+ # * `escape(str)` / `unescape(str)` —
25
+ # URL-encode / decode (`application/x-www-form-urlencoded`).
26
+ # Returns `Constant[String]`.
27
+ # * `escapeURIComponent(str)` / `escape_uri_component(str)`,
28
+ # `unescapeURIComponent(str)` / `unescape_uri_component(str)` —
29
+ # URI-component percent-encode / decode. Returns `Constant[String]`.
30
+ # * `escapeElement(str, *elements)` / `escape_element(str, *elements)`,
31
+ # `unescapeElement(str, *elements)` / `unescape_element(str, *elements)` —
32
+ # element-level escape / unescape (first arg is the string,
33
+ # remaining args are element names). Returns `Constant[String]`.
34
+ #
35
+ # === Non-constant / unsupported cases
36
+ #
37
+ # Returns `nil` (deferring to the next dispatcher tier) when:
38
+ # - the receiver is not `Singleton[CGI]`,
39
+ # - the first argument is not a `Constant[String]`,
40
+ # - the method is not in the supported set.
41
+ module CGIFolding
42
+ CGI_HTML_ESCAPE_METHODS = Set[:escapeHTML, :escape_html, :h].freeze
43
+ CGI_HTML_UNESCAPE_METHODS = Set[:unescapeHTML, :unescape_html].freeze
44
+ CGI_URL_ESCAPE_METHODS = Set[:escape, :unescape].freeze
45
+ CGI_URI_ESCAPE_METHODS = Set[
46
+ :escapeURIComponent, :escape_uri_component,
47
+ :unescapeURIComponent, :unescape_uri_component
48
+ ].freeze
49
+ CGI_ELEMENT_ESCAPE_METHODS = Set[
50
+ :escapeElement, :escape_element,
51
+ :unescapeElement, :unescape_element
52
+ ].freeze
53
+ CGI_ALL_ESCAPE_METHODS = (
54
+ CGI_HTML_ESCAPE_METHODS | CGI_HTML_UNESCAPE_METHODS |
55
+ CGI_URL_ESCAPE_METHODS | CGI_URI_ESCAPE_METHODS |
56
+ CGI_ELEMENT_ESCAPE_METHODS
57
+ ).freeze
58
+
59
+ private_constant :CGI_HTML_ESCAPE_METHODS, :CGI_HTML_UNESCAPE_METHODS,
60
+ :CGI_URL_ESCAPE_METHODS, :CGI_URI_ESCAPE_METHODS,
61
+ :CGI_ELEMENT_ESCAPE_METHODS, :CGI_ALL_ESCAPE_METHODS
62
+
63
+ module_function
64
+
65
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
66
+ def try_dispatch(receiver:, method_name:, args:)
67
+ return nil unless dispatch_target?(receiver)
68
+ return nil unless CGI_ALL_ESCAPE_METHODS.include?(method_name)
69
+
70
+ fold_cgi_call(method_name, args)
71
+ end
72
+
73
+ def dispatch_target?(receiver)
74
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "CGI"
75
+ end
76
+
77
+ def fold_cgi_call(method_name, args)
78
+ return nil if args.empty?
79
+ return nil unless args.first.is_a?(Type::Constant) && args.first.value.is_a?(String)
80
+
81
+ str = args.first.value
82
+
83
+ if CGI_ELEMENT_ESCAPE_METHODS.include?(method_name)
84
+ fold_cgi_element(method_name, str, args.drop(1))
85
+ else
86
+ Type::Combinator.constant_of(CGI.public_send(method_name, str))
87
+ end
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ # `CGI.escapeElement(str, "elem1", "elem2", ...)` — element-
93
+ # level escape / unescape. The remaining args after the first
94
+ # must be `Constant[String]` element names.
95
+ def fold_cgi_element(method_name, str, element_args)
96
+ elements = element_args.map do |arg|
97
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
98
+
99
+ arg.value
100
+ end
101
+
102
+ Type::Combinator.constant_of(CGI.public_send(method_name, str, *elements))
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end