rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -1025,11 +1025,15 @@ module Rigor
1025
1025
  local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1026
1026
  if local_def
1027
1027
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1028
- return local_inference if local_inference
1029
-
1030
- # The local def matches by name but the
1031
- # parameter shape is too complex for the first-
1032
- # iteration binder (kwargs / optionals / rest).
1028
+ return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1029
+
1030
+ # The local def matches by name but the inference
1031
+ # was disqualified either the parameter shape is
1032
+ # too complex for the first-iteration binder
1033
+ # (kwargs / optionals / rest), or ADR-24 slice 1's
1034
+ # conservative gate declined the resolved return
1035
+ # type inside a class body (see
1036
+ # `adoptable_self_call_result?`).
1033
1037
  # Returning `Dynamic[Top]` is the safest answer:
1034
1038
  # we know RBS dispatch would be wrong (the
1035
1039
  # method is user-defined and shadows whatever
@@ -1052,6 +1056,9 @@ module Rigor
1052
1056
  per_element = try_per_element_block_fold(node, receiver)
1053
1057
  return per_element if per_element
1054
1058
 
1059
+ hash_transform = try_hash_shape_block_fold(node, receiver)
1060
+ return hash_transform if hash_transform
1061
+
1055
1062
  result = MethodDispatcher.dispatch(
1056
1063
  receiver_type: receiver,
1057
1064
  method_name: node.name,
@@ -1069,7 +1076,11 @@ module Rigor
1069
1076
  # the body with the call's argument types bound and
1070
1077
  # return the body's last-expression type.
1071
1078
  user_inference = try_user_method_inference(receiver, node, arg_types)
1072
- return user_inference if user_inference
1079
+ if user_inference
1080
+ return user_inference if adoptable_self_call_result?(user_inference)
1081
+
1082
+ return dynamic_top
1083
+ end
1073
1084
 
1074
1085
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1075
1086
  # no positive rule resolves the call, the result inherits the
@@ -1112,10 +1123,39 @@ module Rigor
1112
1123
  nil
1113
1124
  end
1114
1125
 
1126
+ # ADR-24 slice 1 — implicit-self method-call resolution.
1127
+ # `discovered_def_nodes` is now carried into method /
1128
+ # class body scopes (see `StatementEvaluator#build_fresh_body_scope`),
1129
+ # so a call written with no explicit receiver inside a
1130
+ # method body resolves against the enclosing class's own
1131
+ # definitions and the file's top-level defs. Before
1132
+ # slice 1 every such call typed `Dynamic[top]`.
1133
+ #
1134
+ # The adoption of the resolved return type is gated:
1135
+ #
1136
+ # - At top-level / inside a DSL block (`scope.self_type`
1137
+ # is nil) the result is adopted unchanged — this is
1138
+ # the pre-slice-1 surface (the v0.0.3 A local-`def`
1139
+ # shortcut) and MUST keep working.
1140
+ # - Inside a class body / method body (`self_type` set)
1141
+ # the result is adopted ONLY when it is `Bot`. A `Bot`
1142
+ # return is an always-diverging guard helper; adopting
1143
+ # it can only ever enable correct terminating-branch
1144
+ # narrowing, never a new `undefined-method` /
1145
+ # argument-type false positive. A non-`Bot` resolved
1146
+ # return is kept as `Dynamic[top]` (WD3) — adopting
1147
+ # precise non-`Bot` returns project-wide awaits the
1148
+ # callee-return-inference precision a later slice
1149
+ # brings (measured: unconditional adoption regressed
1150
+ # `rigor check lib` by 16 diagnostics).
1151
+ def adoptable_self_call_result?(type)
1152
+ scope.self_type.nil? || type.is_a?(Type::Bot)
1153
+ end
1154
+
1115
1155
  def try_user_method_inference(receiver, call_node, arg_types)
1116
1156
  return nil unless receiver.is_a?(Type::Nominal)
1117
1157
 
1118
- def_node = scope.user_def_for(receiver.class_name, call_node.name)
1158
+ def_node = resolve_user_def_through_ancestors(receiver.class_name, call_node.name)
1119
1159
  return nil if def_node.nil?
1120
1160
 
1121
1161
  infer_user_method_return(def_node, receiver, arg_types)
@@ -1123,6 +1163,81 @@ module Rigor
1123
1163
  nil
1124
1164
  end
1125
1165
 
1166
+ # ADR-24 slice 2 — resolves `method_name` against
1167
+ # `class_name`'s own `def`s, then walks the user-class
1168
+ # ancestor chain: included / prepended modules (transitive)
1169
+ # and the superclass chain. RBS-known ancestors are NOT
1170
+ # walked here — the `MethodDispatcher` RBS tier runs before
1171
+ # `try_user_method_inference` and already covers them; an
1172
+ # ancestor name that resolves to no project-discovered
1173
+ # class/module ends that branch. Cross-file: the chain is
1174
+ # followed through `Scope#discovered_superclasses` /
1175
+ # `#discovered_includes` / `#discovered_def_nodes`, which
1176
+ # the runner seeds from the project-wide pre-pass. The walk
1177
+ # is breadth-first, cycle-guarded, and node-count-capped.
1178
+ ANCESTOR_WALK_LIMIT = 100
1179
+ private_constant :ANCESTOR_WALK_LIMIT
1180
+
1181
+ def resolve_user_def_through_ancestors(class_name, method_name)
1182
+ queue = [class_name.to_s]
1183
+ seen = {}
1184
+ visited = 0
1185
+ until queue.empty?
1186
+ current = queue.shift
1187
+ next if current.nil? || seen[current]
1188
+
1189
+ seen[current] = true
1190
+ visited += 1
1191
+ return nil if visited > ANCESTOR_WALK_LIMIT
1192
+
1193
+ found = scope.user_def_for(current, method_name)
1194
+ return found if found
1195
+
1196
+ enqueue_ancestors(current, queue)
1197
+ end
1198
+ nil
1199
+ end
1200
+
1201
+ # Pushes `current`'s direct ancestors onto the BFS queue:
1202
+ # included / prepended modules first (Ruby places mixins
1203
+ # nearer than the superclass), then the superclass. Each
1204
+ # as-written name is resolved against `current`'s lexical
1205
+ # nesting; names that resolve to no project class/module
1206
+ # are dropped (RBS-known / third-party ancestors).
1207
+ def enqueue_ancestors(current, queue)
1208
+ scope.includes_of(current).each do |raw|
1209
+ resolved = resolve_ancestor_class_name(current, raw)
1210
+ queue.push(resolved) if resolved
1211
+ end
1212
+ raw_super = scope.superclass_of(current)
1213
+ return if raw_super.nil?
1214
+
1215
+ resolved_super = resolve_ancestor_class_name(current, raw_super)
1216
+ queue.push(resolved_super) if resolved_super
1217
+ end
1218
+
1219
+ # Resolves a superclass name AS WRITTEN (`"Base"`, or a
1220
+ # qualified `"A::B"`) to a project-discovered class,
1221
+ # following Ruby's `Module.nesting` constant lookup: try
1222
+ # the raw name under each enclosing namespace of the
1223
+ # subclass, innermost first, then bare. Returns nil when
1224
+ # no candidate names a discovered user class (e.g. the
1225
+ # superclass is an RBS-known or third-party class).
1226
+ def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
1227
+ segments = subclass_qualified.split("::")
1228
+ (segments.length - 1).downto(0) do |i|
1229
+ candidate = (segments[0, i] + [raw_superclass]).join("::")
1230
+ return candidate if known_user_class?(candidate)
1231
+ end
1232
+ nil
1233
+ end
1234
+
1235
+ def known_user_class?(name)
1236
+ scope.discovered_superclasses.key?(name) ||
1237
+ scope.discovered_def_nodes.key?(name) ||
1238
+ scope.discovered_includes.key?(name)
1239
+ end
1240
+
1126
1241
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
1127
1242
  private_constant :INFERENCE_GUARD_KEY
1128
1243
 
@@ -1132,11 +1247,20 @@ module Rigor
1132
1247
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
1133
1248
  return nil if body_scope.nil?
1134
1249
 
1135
- # Recursion-guard signature. Uses `describe(:short)`
1136
- # so non-Nominal receivers (e.g. the implicit
1137
- # `Object` carrier used for top-level / DSL-block
1138
- # defs in v0.0.3 A) can participate without raising.
1139
- signature = [receiver.describe(:short), def_node.name, arg_types.map { |t| t.describe(:short) }]
1250
+ # Recursion-guard signature. Keyed on `(receiver,
1251
+ # method)` only NOT the argument types. ADR-24 WD5:
1252
+ # a method whose summary is still being computed
1253
+ # resolves to `Dynamic[top]` for that cycle. Keying on
1254
+ # arg types would let mutual recursion through a
1255
+ # `module_function` module (`Acceptance#accepts` →
1256
+ # `accepts_one` → `accepts_dynamic` → `accepts`)
1257
+ # recurse unboundedly whenever the carried argument
1258
+ # types differ at each level — observed as a
1259
+ # `SystemStackError` once implicit-self calls began
1260
+ # resolving during the main walk. `describe(:short)`
1261
+ # keeps non-Nominal receivers (the implicit `Object`
1262
+ # carrier for top-level / DSL-block defs) printable.
1263
+ signature = [receiver.describe(:short), def_node.name]
1140
1264
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1141
1265
  return Type::Combinator.untyped if stack.include?(signature)
1142
1266
 
@@ -1174,6 +1298,8 @@ module Rigor
1174
1298
  .with_program_globals(scope.program_globals)
1175
1299
  .with_discovered_methods(scope.discovered_methods)
1176
1300
  .with_discovered_def_nodes(scope.discovered_def_nodes)
1301
+ .with_discovered_superclasses(scope.discovered_superclasses)
1302
+ .with_discovered_includes(scope.discovered_includes)
1177
1303
  .with_self_type(receiver)
1178
1304
 
1179
1305
  required.each_with_index do |param, index|
@@ -1332,10 +1458,17 @@ module Rigor
1332
1458
  # the dispatch chain untouched.
1333
1459
  PER_ELEMENT_TUPLE_METHODS = Set[
1334
1460
  :map, :collect, :filter_map, :flat_map,
1461
+ :select, :filter, :reject,
1335
1462
  :find, :detect, :find_index, :index
1336
1463
  ].freeze
1337
1464
  private_constant :PER_ELEMENT_TUPLE_METHODS
1338
1465
 
1466
+ HASH_SHAPE_TRANSFORM_METHODS = Set[
1467
+ :transform_keys, :transform_keys!,
1468
+ :transform_values, :transform_values!
1469
+ ].freeze
1470
+ private_constant :HASH_SHAPE_TRANSFORM_METHODS
1471
+
1339
1472
  # Cardinality cap for per-element block fold over
1340
1473
  # finite-bound `Constant<Range>` receivers. Walking
1341
1474
  # `(1..1_000_000).map { … }` element-wise would balloon
@@ -1352,15 +1485,50 @@ module Rigor
1352
1485
  element_types = per_element_elements_of(receiver_type)
1353
1486
  return nil if element_types.nil? || element_types.empty?
1354
1487
 
1355
- block_node = call_node.block
1356
- return nil unless block_node.is_a?(Prism::BlockNode)
1488
+ per_position = per_element_block_results(call_node.block, element_types)
1489
+ return nil if per_position.nil? || per_position.any?(&:nil?)
1357
1490
 
1358
- per_position = element_types.map do |element_type|
1359
- type_block_body_with_param(block_node, [element_type])
1491
+ assemble_per_element_result(call_node.name, per_position, element_types)
1492
+ end
1493
+
1494
+ # Evaluates the call's block once per receiver element.
1495
+ # Two block shapes are supported:
1496
+ #
1497
+ # - `Prism::BlockNode` — a full `do … end` / `{ … }` block;
1498
+ # the body is re-typed per position with the element
1499
+ # bound to the block parameter.
1500
+ # - `Prism::BlockArgumentNode` wrapping a `SymbolNode` —
1501
+ # the `&:predicate` shorthand; the symbol is dispatched
1502
+ # as a zero-arg method on each element type.
1503
+ #
1504
+ # Any other shape (`&proc_local`, `&method(:foo)`, no
1505
+ # block) returns `nil` so the fold declines.
1506
+ def per_element_block_results(block, element_types)
1507
+ case block
1508
+ when Prism::BlockNode
1509
+ element_types.map { |element_type| type_block_body_with_param(block, [element_type]) }
1510
+ when Prism::BlockArgumentNode
1511
+ per_element_symbol_results(block, element_types)
1360
1512
  end
1361
- return nil if per_position.any?(&:nil?)
1513
+ end
1362
1514
 
1363
- assemble_per_element_result(call_node.name, per_position, element_types)
1515
+ def per_element_symbol_results(block_arg, element_types)
1516
+ expression = block_arg.expression
1517
+ return nil unless expression.is_a?(Prism::SymbolNode)
1518
+
1519
+ method_name = expression.unescaped.to_sym
1520
+ element_types.map do |element_type|
1521
+ MethodDispatcher.dispatch(
1522
+ receiver_type: element_type,
1523
+ method_name: method_name,
1524
+ arg_types: [],
1525
+ block_type: nil,
1526
+ environment: scope.environment,
1527
+ scope: scope
1528
+ )
1529
+ end
1530
+ rescue StandardError
1531
+ nil
1364
1532
  end
1365
1533
 
1366
1534
  # Returns the per-position element types for a finite,
@@ -1409,11 +1577,37 @@ module Rigor
1409
1577
  when :map, :collect then Type::Combinator.tuple_of(*per_position)
1410
1578
  when :filter_map then assemble_filter_map_result(per_position)
1411
1579
  when :flat_map then assemble_flat_map_result(per_position)
1580
+ when :select, :filter
1581
+ assemble_filter_result(per_position, element_types, keep_on_truthy: true)
1582
+ when :reject
1583
+ assemble_filter_result(per_position, element_types, keep_on_truthy: false)
1412
1584
  when :find, :detect then assemble_find_result(per_position, element_types)
1413
1585
  when :find_index, :index then assemble_find_index_result(per_position)
1414
1586
  end
1415
1587
  end
1416
1588
 
1589
+ # `select` / `filter` / `reject`: keeps each receiver
1590
+ # element whose per-position predicate result folds to a
1591
+ # decisive `Constant` — Ruby-truthy for `select` / `filter`,
1592
+ # Ruby-falsey for `reject`. The surviving elements assemble
1593
+ # into a `Tuple`, strictly tighter than the RBS-projected
1594
+ # `Array[Elem]`.
1595
+ #
1596
+ # Folds tightly only when EVERY position is a `Constant`:
1597
+ # a single non-`Constant` position leaves the result
1598
+ # cardinality unknown (the element might or might not
1599
+ # survive), so the dispatcher declines and the RBS tier
1600
+ # widens to `Array[Elem]`. `[].select` style empty results
1601
+ # are sound — an empty `Tuple` is the empty-array carrier.
1602
+ def assemble_filter_result(per_position, element_types, keep_on_truthy:)
1603
+ return nil unless per_position.all?(Type::Constant)
1604
+
1605
+ kept = element_types.each_index.filter_map do |index|
1606
+ element_types[index] if truthy_constant?(per_position[index]) == keep_on_truthy
1607
+ end
1608
+ Type::Combinator.tuple_of(*kept)
1609
+ end
1610
+
1417
1611
  # `filter_map` folds tightly only when every per-position
1418
1612
  # result is a `Constant`: positions whose value is `nil`
1419
1613
  # or `false` drop, the rest survive in declaration order.
@@ -1488,6 +1682,94 @@ module Rigor
1488
1682
  type.is_a?(Type::Constant) && type.value && type.value != false
1489
1683
  end
1490
1684
 
1685
+ # Per-pair block fold for `HashShape#transform_keys` and
1686
+ # `HashShape#transform_values` (and their bang variants).
1687
+ #
1688
+ # When the receiver is a closed `HashShape` with no optional
1689
+ # keys, applies the call's block (a `Prism::BlockNode` or
1690
+ # `Prism::BlockArgumentNode`) to each key/value pair
1691
+ # independently and assembles a new `HashShape`:
1692
+ #
1693
+ # - `transform_values` / `transform_values!`: re-types
1694
+ # each VALUE by binding it to the block parameter; keys
1695
+ # are preserved unchanged.
1696
+ # - `transform_keys` / `transform_keys!`: re-types each
1697
+ # KEY by wrapping it in `Constant[k]` and passing it to
1698
+ # the block; values are preserved unchanged. The result
1699
+ # key must be a `Constant[Symbol | String]` — otherwise
1700
+ # the tier declines (the new key cannot be used as a
1701
+ # static HashShape index). Collisions (two old keys
1702
+ # mapping to the same new key) also decline.
1703
+ #
1704
+ # Returns `nil` on any decline so the dispatcher falls
1705
+ # through to `RbsDispatch` and gets the widened `Hash[K, V]`
1706
+ # answer.
1707
+ def try_hash_shape_block_fold(call_node, receiver_type)
1708
+ return nil unless HASH_SHAPE_TRANSFORM_METHODS.include?(call_node.name)
1709
+ return nil unless receiver_type.is_a?(Type::HashShape)
1710
+ return nil unless receiver_type.closed?
1711
+ return nil unless receiver_type.optional_keys.empty?
1712
+
1713
+ block_arg = call_node.block
1714
+ return nil if block_arg.nil?
1715
+
1716
+ if %i[transform_values transform_values!].include?(call_node.name)
1717
+ fold_hash_shape_transform_values(receiver_type, block_arg)
1718
+ else
1719
+ fold_hash_shape_transform_keys(receiver_type, block_arg)
1720
+ end
1721
+ end
1722
+
1723
+ def fold_hash_shape_transform_values(shape, block_arg)
1724
+ new_pairs = {}
1725
+ shape.pairs.each do |key, value|
1726
+ new_value = apply_hash_block(block_arg, value)
1727
+ return nil if new_value.nil?
1728
+
1729
+ new_pairs[key] = new_value
1730
+ end
1731
+ Type::Combinator.hash_shape_of(new_pairs)
1732
+ end
1733
+
1734
+ def fold_hash_shape_transform_keys(shape, block_arg)
1735
+ new_pairs = {}
1736
+ shape.pairs.each do |key, value|
1737
+ key_type = Type::Combinator.constant_of(key)
1738
+ new_key_type = apply_hash_block(block_arg, key_type)
1739
+ return nil unless new_key_type.is_a?(Type::Constant)
1740
+
1741
+ new_key = new_key_type.value
1742
+ return nil unless new_key.is_a?(Symbol) || new_key.is_a?(String)
1743
+ return nil if new_pairs.key?(new_key)
1744
+
1745
+ new_pairs[new_key] = value
1746
+ end
1747
+ Type::Combinator.hash_shape_of(new_pairs)
1748
+ end
1749
+
1750
+ # Applies a single-argument block (either a full BlockNode
1751
+ # or a `&:symbol` BlockArgumentNode) to `param_type` and
1752
+ # returns the resulting type, or `nil` on failure.
1753
+ def apply_hash_block(block_arg, param_type)
1754
+ case block_arg
1755
+ when Prism::BlockNode
1756
+ type_block_body_with_param(block_arg, [param_type])
1757
+ when Prism::BlockArgumentNode
1758
+ expression = block_arg.expression
1759
+ return nil unless expression.is_a?(Prism::SymbolNode)
1760
+
1761
+ MethodDispatcher.dispatch(
1762
+ receiver_type: param_type,
1763
+ method_name: expression.unescaped.to_sym,
1764
+ arg_types: [],
1765
+ block_type: nil,
1766
+ environment: scope.environment,
1767
+ call_node: block_arg,
1768
+ scope: scope
1769
+ )
1770
+ end
1771
+ end
1772
+
1491
1773
  def type_block_body_with_param(block_node, expected_param_types)
1492
1774
  bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
1493
1775
  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