rigortype 0.1.8 → 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 (35) 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/cli/annotate_command.rb +224 -0
  5. data/lib/rigor/cli/baseline_command.rb +36 -16
  6. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  7. data/lib/rigor/cli.rb +62 -4
  8. data/lib/rigor/environment.rb +9 -1
  9. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  10. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  11. data/lib/rigor/inference/expression_typer.rb +165 -6
  12. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  13. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  14. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  15. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  16. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  17. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  18. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  19. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  20. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  21. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  22. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  23. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  24. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  25. data/lib/rigor/inference/narrowing.rb +29 -10
  26. data/lib/rigor/inference/statement_evaluator.rb +3 -1
  27. data/lib/rigor/plugin/base.rb +39 -0
  28. data/lib/rigor/plugin/loader.rb +22 -1
  29. data/lib/rigor/plugin/manifest.rb +73 -10
  30. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  31. data/lib/rigor/plugin/registry.rb +66 -0
  32. data/lib/rigor/triage/catalogue.rb +2 -2
  33. data/lib/rigor/type/constant.rb +29 -2
  34. data/lib/rigor/version.rb +1 -1
  35. metadata +11 -1
@@ -87,7 +87,11 @@ module Rigor
87
87
  zip: :tuple_zip,
88
88
  :[] => :tuple_index,
89
89
  fetch: :tuple_index,
90
- dig: :tuple_dig
90
+ dig: :tuple_dig,
91
+ values_at: :tuple_values_at,
92
+ :+ => :tuple_concat,
93
+ compact: :tuple_compact,
94
+ take: :tuple_take
91
95
  }.freeze
92
96
 
93
97
  HASH_SHAPE_HANDLERS = {
@@ -96,19 +100,41 @@ module Rigor
96
100
  count: :hash_size,
97
101
  empty?: :hash_empty?,
98
102
  any?: :hash_any?,
103
+ none?: :hash_none?,
104
+ one?: :hash_one?,
99
105
  keys: :hash_keys,
100
106
  values: :hash_values,
101
107
  first: :hash_first,
102
108
  flatten: :hash_flatten,
103
109
  compact: :hash_compact,
104
110
  to_a: :hash_to_a,
111
+ entries: :hash_to_a,
105
112
  to_h: :hash_to_h,
113
+ to_hash: :hash_to_h,
114
+ deconstruct_keys: :hash_deconstruct_keys,
106
115
  invert: :hash_invert,
107
116
  merge: :hash_merge,
117
+ slice: :hash_slice,
118
+ except: :hash_except,
108
119
  :[] => :hash_lookup,
109
120
  fetch: :hash_lookup,
110
121
  dig: :hash_dig,
111
- values_at: :hash_values_at
122
+ values_at: :hash_values_at,
123
+ fetch_values: :hash_fetch_values,
124
+ assoc: :hash_assoc,
125
+ key: :hash_key,
126
+ has_key?: :hash_has_key?,
127
+ key?: :hash_has_key?,
128
+ member?: :hash_has_key?,
129
+ include?: :hash_has_key?,
130
+ has_value?: :hash_has_value?,
131
+ value?: :hash_has_value?,
132
+ default: :hash_default,
133
+ default_proc: :hash_default,
134
+ :< => :hash_compare,
135
+ :<= => :hash_compare,
136
+ :> => :hash_compare,
137
+ :>= => :hash_compare
112
138
  }.freeze
113
139
 
114
140
  # @return [Rigor::Type, nil] the precise element/value type, or
@@ -654,6 +680,67 @@ module Rigor
654
680
  [key.value, value]
655
681
  end
656
682
 
683
+ # `tuple.values_at(i1, i2, ...)` — returns a Tuple of
684
+ # per-index elements. Each argument must be a
685
+ # `Constant[Integer]`. Out-of-range indices fill with
686
+ # `Constant[nil]`, mirroring Ruby's runtime behaviour.
687
+ # Declines when any argument is non-static.
688
+ def tuple_values_at(tuple, _method_name, args)
689
+ return nil if args.empty?
690
+
691
+ values = args.map do |arg|
692
+ return nil unless arg.is_a?(Type::Constant)
693
+ return nil unless arg.value.is_a?(Integer)
694
+
695
+ idx = normalise_index(arg.value, tuple.elements.size)
696
+ idx ? tuple.elements[idx] : Type::Combinator.constant_of(nil)
697
+ end
698
+
699
+ Type::Combinator.tuple_of(*values)
700
+ end
701
+
702
+ # `tuple + other` — concatenates two Tuples. Both sides
703
+ # must be `Type::Tuple`. Returns a new Tuple whose
704
+ # elements are those of the receiver followed by those
705
+ # of the argument.
706
+ def tuple_concat(tuple, _method_name, args)
707
+ return nil unless args.size == 1
708
+
709
+ other = args.first
710
+ return nil unless other.is_a?(Type::Tuple)
711
+
712
+ Type::Combinator.tuple_of(*tuple.elements, *other.elements)
713
+ end
714
+
715
+ # `tuple.compact` — removes every element that is
716
+ # `Constant[nil]`. Folds only when every element is a
717
+ # `Constant` (so the nil set is decidable). Mixed-shape
718
+ # elements decline so the RBS tier widens.
719
+ def tuple_compact(tuple, _method_name, args)
720
+ return nil unless args.empty?
721
+ return nil unless tuple.elements.all?(Type::Constant)
722
+
723
+ kept = tuple.elements.reject { |e| e.is_a?(Type::Constant) && e.value.nil? }
724
+ Type::Combinator.tuple_of(*kept)
725
+ end
726
+
727
+ # `tuple.take(n)` — returns the first n elements as a
728
+ # new Tuple. The argument must be a `Constant[Integer]`.
729
+ # n <= 0 returns the empty Tuple; n >= size returns the
730
+ # full receiver.
731
+ def tuple_take(tuple, _method_name, args)
732
+ return nil unless args.size == 1
733
+
734
+ arg = args.first
735
+ return nil unless arg.is_a?(Type::Constant)
736
+ return nil unless arg.value.is_a?(Integer)
737
+
738
+ n = arg.value
739
+ return Type::Combinator.tuple_of if n <= 0
740
+
741
+ Type::Combinator.tuple_of(*tuple.elements.take(n))
742
+ end
743
+
657
744
  # Returns `true` / `false` if every element's truthiness
658
745
  # agrees, nil for mixed-or-unknown shapes. `all: true`
659
746
  # checks every element is truthy; `all: false` checks
@@ -817,6 +904,185 @@ module Rigor
817
904
 
818
905
  Type::Combinator.constant_of(!shape.pairs.empty?)
819
906
  end
907
+
908
+ # `shape.none?` (no block, no arg) — mirror of `any?`.
909
+ # Folds to `Constant[shape.pairs.empty?]` for closed
910
+ # shapes with no optional keys.
911
+ def hash_none?(shape, _method_name, args)
912
+ return nil unless args.empty?
913
+ return nil unless shape.closed?
914
+ return nil unless shape.optional_keys.empty?
915
+
916
+ Type::Combinator.constant_of(shape.pairs.empty?)
917
+ end
918
+
919
+ # `shape.one?` (no block, no arg) — folds to
920
+ # `Constant[shape.pairs.size == 1]` for a closed shape
921
+ # with no optional keys.
922
+ def hash_one?(shape, _method_name, args)
923
+ return nil unless args.empty?
924
+ return nil unless shape.closed?
925
+ return nil unless shape.optional_keys.empty?
926
+
927
+ Type::Combinator.constant_of(shape.pairs.size == 1)
928
+ end
929
+
930
+ # `shape.deconstruct_keys(keys)` — Ruby's `Hash#deconstruct_keys`
931
+ # returns the receiver itself regardless of the `keys`
932
+ # argument, so the precise answer is the shape unchanged.
933
+ def hash_deconstruct_keys(shape, _method_name, args)
934
+ return nil unless args.size == 1
935
+
936
+ shape
937
+ end
938
+
939
+ # `shape.fetch_values(:a, :b, ...)` — like `values_at` but
940
+ # raises `KeyError` on a missing key. Folds to `Tuple[V…]`
941
+ # only when every requested key is present; a missing key
942
+ # declines so the RBS tier reflects the raise.
943
+ def hash_fetch_values(shape, _method_name, args)
944
+ return nil if args.empty?
945
+ return nil unless shape.closed?
946
+ return nil unless shape.optional_keys.empty?
947
+
948
+ values = []
949
+ args.each do |arg|
950
+ return nil unless arg.is_a?(Type::Constant)
951
+
952
+ key = arg.value
953
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
954
+ return nil unless shape.pairs.key?(key)
955
+
956
+ values << shape.pairs[key]
957
+ end
958
+ Type::Combinator.tuple_of(*values)
959
+ end
960
+
961
+ # `shape.assoc(key)` — returns `Tuple[Constant[k], V]` for a
962
+ # known key, `Constant[nil]` for a missing key.
963
+ def hash_assoc(shape, _method_name, args)
964
+ return nil unless args.size == 1
965
+ return nil unless shape.closed?
966
+ return nil unless shape.optional_keys.empty?
967
+
968
+ arg = args.first
969
+ return nil unless arg.is_a?(Type::Constant)
970
+
971
+ key = arg.value
972
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
973
+ return Type::Combinator.constant_of(nil) unless shape.pairs.key?(key)
974
+
975
+ Type::Combinator.tuple_of(Type::Combinator.constant_of(key), shape.pairs[key])
976
+ end
977
+
978
+ # `shape.key(value)` — reverse lookup. Folds when every
979
+ # value is a `Constant` so equality is decidable: returns
980
+ # `Constant[k]` for the first matching key, `Constant[nil]`
981
+ # when no value matches.
982
+ def hash_key(shape, _method_name, args)
983
+ return nil unless args.size == 1
984
+ return nil unless shape.closed?
985
+ return nil unless shape.optional_keys.empty?
986
+ return nil unless shape.pairs.values.all?(Type::Constant)
987
+
988
+ arg = args.first
989
+ return nil unless arg.is_a?(Type::Constant)
990
+
991
+ pair = shape.pairs.find { |_k, v| v.value == arg.value }
992
+ Type::Combinator.constant_of(pair&.first)
993
+ end
994
+
995
+ # `shape.has_value?(v)` / `value?(v)` — folds to
996
+ # `Constant[true/false]` when every value is a `Constant`
997
+ # (so equality is decidable) and the argument is a
998
+ # `Constant`.
999
+ def hash_has_value?(shape, _method_name, args)
1000
+ return nil unless args.size == 1
1001
+ return nil unless shape.closed?
1002
+ return nil unless shape.optional_keys.empty?
1003
+ return nil unless shape.pairs.values.all?(Type::Constant)
1004
+
1005
+ arg = args.first
1006
+ return nil unless arg.is_a?(Type::Constant)
1007
+
1008
+ found = shape.pairs.values.any? { |v| v.value == arg.value }
1009
+ Type::Combinator.constant_of(found)
1010
+ end
1011
+
1012
+ # `shape.default` / `default_proc` — a literal `HashShape`
1013
+ # carries no default value or proc, so both fold to
1014
+ # `Constant[nil]`. `default` accepts an optional key
1015
+ # argument (still returns the default), `default_proc`
1016
+ # takes none — the `args.size <= 1` guard covers both.
1017
+ def hash_default(shape, _method_name, args)
1018
+ return nil unless args.size <= 1
1019
+ return nil unless shape.closed?
1020
+
1021
+ Type::Combinator.constant_of(nil)
1022
+ end
1023
+
1024
+ # `shape < other` / `<=` / `>` / `>=` — Hash containment
1025
+ # comparison. Both sides must be closed `HashShape`s whose
1026
+ # values are all `Constant` (so pair equality is
1027
+ # decidable). `<` / `>` are proper-subset / -superset.
1028
+ def hash_compare(shape, method_name, args)
1029
+ return nil unless args.size == 1
1030
+ return nil unless shape.closed? && shape.optional_keys.empty?
1031
+
1032
+ other = args.first
1033
+ return nil unless other.is_a?(Type::HashShape)
1034
+ return nil unless other.closed? && other.optional_keys.empty?
1035
+
1036
+ left = constant_pairs(shape)
1037
+ right = constant_pairs(other)
1038
+ return nil if left.nil? || right.nil?
1039
+
1040
+ Type::Combinator.constant_of(hash_containment(method_name, left, right))
1041
+ end
1042
+
1043
+ # Unwraps a closed shape's pairs to a plain Ruby Hash of
1044
+ # `key => value` for value-equality comparison. Returns nil
1045
+ # when any value is not a `Constant`.
1046
+ def constant_pairs(shape)
1047
+ return nil unless shape.pairs.values.all?(Type::Constant)
1048
+
1049
+ shape.pairs.transform_values(&:value)
1050
+ end
1051
+
1052
+ def hash_containment(method_name, left, right)
1053
+ case method_name
1054
+ when :< then hash_proper_subset?(left, right)
1055
+ when :<= then hash_subset?(left, right)
1056
+ when :> then hash_proper_subset?(right, left)
1057
+ when :>= then hash_subset?(right, left)
1058
+ end
1059
+ end
1060
+
1061
+ def hash_subset?(left, right)
1062
+ left.all? { |k, v| right.key?(k) && right[k] == v }
1063
+ end
1064
+
1065
+ def hash_proper_subset?(left, right)
1066
+ left.size < right.size && hash_subset?(left, right)
1067
+ end
1068
+
1069
+ # `shape.has_key?(k)` / `key?(k)` / `member?(k)` /
1070
+ # `include?(k)` — folds to `Constant[true/false]` when
1071
+ # the argument is a `Constant[Symbol|String]` and the
1072
+ # shape is closed with no optional keys.
1073
+ def hash_has_key?(shape, _method_name, args)
1074
+ return nil unless args.size == 1
1075
+ return nil unless shape.closed?
1076
+ return nil unless shape.optional_keys.empty?
1077
+
1078
+ arg = args.first
1079
+ return nil unless arg.is_a?(Type::Constant)
1080
+
1081
+ key = arg.value
1082
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1083
+
1084
+ Type::Combinator.constant_of(shape.pairs.key?(key))
1085
+ end
820
1086
  # rubocop:enable Style/ReturnNilInPredicateMethodDefinition
821
1087
 
822
1088
  # `shape.keys` — returns a `Tuple[Constant<k>…]` for a
@@ -1027,6 +1293,54 @@ module Rigor
1027
1293
  Type::Combinator.tuple_of(*values)
1028
1294
  end
1029
1295
 
1296
+ # `shape.slice(:a, :b, ...)` — returns a sub-HashShape
1297
+ # containing only the specified keys. All arguments must
1298
+ # be `Constant[Symbol|String]`. Keys not present in the
1299
+ # shape are silently omitted (matching Ruby's runtime
1300
+ # semantics — no nil padding). Declines on open shapes
1301
+ # or when any argument is not a static key.
1302
+ def hash_slice(shape, _method_name, args)
1303
+ return nil if args.empty?
1304
+ return nil unless shape.closed?
1305
+ return nil unless shape.optional_keys.empty?
1306
+
1307
+ requested = []
1308
+ args.each do |arg|
1309
+ return nil unless arg.is_a?(Type::Constant)
1310
+
1311
+ key = arg.value
1312
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1313
+
1314
+ requested << key
1315
+ end
1316
+
1317
+ Type::Combinator.hash_shape_of(shape.pairs.slice(*requested))
1318
+ end
1319
+
1320
+ # `shape.except(:a, :b, ...)` — returns a sub-HashShape
1321
+ # with the specified keys removed. All arguments must be
1322
+ # `Constant[Symbol|String]`. Keys not present in the shape
1323
+ # are silently ignored. Declines on open shapes or when
1324
+ # any argument is not a static key.
1325
+ def hash_except(shape, _method_name, args)
1326
+ return nil if args.empty?
1327
+ return nil unless shape.closed?
1328
+ return nil unless shape.optional_keys.empty?
1329
+
1330
+ excluded = {}
1331
+ args.each do |arg|
1332
+ return nil unless arg.is_a?(Type::Constant)
1333
+
1334
+ key = arg.value
1335
+ return nil unless key.is_a?(Symbol) || key.is_a?(String)
1336
+
1337
+ excluded[key] = true
1338
+ end
1339
+
1340
+ kept = shape.pairs.reject { |k, _v| excluded.key?(k) }
1341
+ Type::Combinator.hash_shape_of(kept)
1342
+ end
1343
+
1030
1344
  # Continues a `dig` chain after the first step. Tuple and
1031
1345
  # HashShape members re-dispatch into the catalogue;
1032
1346
  # `Constant[nil]` short-circuits the chain (Hash#dig and
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `Shellwords` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `Shellwords` is a pure, side-effect-free module whose functions
13
+ # are deterministic over their inputs — the same string always
14
+ # produces the same escaped / split / joined result. When all
15
+ # relevant arguments are `Constant[String]` (or `Tuple` of them
16
+ # for `join`), the analyzer can evaluate the call at inference
17
+ # time and return the concrete `Constant<T>` result.
18
+ #
19
+ # === Supported methods
20
+ #
21
+ # * `escape` / `shellescape(str)` — returns `Constant[String]`.
22
+ # `Shellwords.escape("")` → `"''"`, so the result is always
23
+ # non-empty; callers that care about the `non-empty-string`
24
+ # refinement will get it for free once that carrier is wired
25
+ # to scalar-constant returns.
26
+ #
27
+ # * `split` / `shellsplit` / `shellwords(line)` — splits the
28
+ # shell command line into tokens, returns
29
+ # `Tuple[Constant[String], …]`. Raises `ArgumentError` on
30
+ # unmatched quotes; the handler declines (returns `nil`) so
31
+ # the RBS tier widens gracefully.
32
+ #
33
+ # * `join` / `shelljoin(array)` — joins an array of tokens into
34
+ # a shell-safe string. Requires a `Tuple` whose every element
35
+ # is a `Constant[String]`; returns `Constant[String]`.
36
+ #
37
+ # === Non-constant / unsupported cases
38
+ #
39
+ # Any call where:
40
+ # - the receiver is not `Singleton[Shellwords]`,
41
+ # - the required argument is not a `Constant[String]` (or
42
+ # `Tuple[Constant[String]…]` for `join`),
43
+ # - the method is not in the supported set, or
44
+ # - `Shellwords.split` raises on malformed input
45
+ #
46
+ # returns `nil`, deferring to the next dispatcher tier.
47
+ module ShellwordsFolding
48
+ SHELLWORDS_ESCAPE_METHODS = Set[:escape, :shellescape].freeze
49
+ SHELLWORDS_SPLIT_METHODS = Set[:split, :shellsplit, :shellwords].freeze
50
+ SHELLWORDS_JOIN_METHODS = Set[:join, :shelljoin].freeze
51
+ SHELLWORDS_ALL_METHODS = (
52
+ SHELLWORDS_ESCAPE_METHODS | SHELLWORDS_SPLIT_METHODS | SHELLWORDS_JOIN_METHODS
53
+ ).freeze
54
+
55
+ # Maximum number of tokens `split` may produce before we
56
+ # decline the fold (same rationale as STRING_ARRAY_LIFT_LIMIT
57
+ # in ConstantFolding — keep the result Tuple manageable).
58
+ SHELLWORDS_SPLIT_LIMIT = 64
59
+
60
+ private_constant :SHELLWORDS_ESCAPE_METHODS, :SHELLWORDS_SPLIT_METHODS,
61
+ :SHELLWORDS_JOIN_METHODS, :SHELLWORDS_ALL_METHODS,
62
+ :SHELLWORDS_SPLIT_LIMIT
63
+
64
+ module_function
65
+
66
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
67
+ def try_dispatch(receiver:, method_name:, args:)
68
+ return nil unless dispatch_target?(receiver)
69
+ return nil unless SHELLWORDS_ALL_METHODS.include?(method_name)
70
+
71
+ if SHELLWORDS_ESCAPE_METHODS.include?(method_name)
72
+ fold_escape(args)
73
+ elsif SHELLWORDS_SPLIT_METHODS.include?(method_name)
74
+ fold_split(args)
75
+ else
76
+ fold_join(args)
77
+ end
78
+ end
79
+
80
+ def dispatch_target?(receiver)
81
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Shellwords"
82
+ end
83
+
84
+ # `Shellwords.escape(str)` / `.shellescape(str)` — one String arg.
85
+ def fold_escape(args)
86
+ return nil unless args.size == 1
87
+
88
+ arg = args.first
89
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
90
+
91
+ Type::Combinator.constant_of(Shellwords.escape(arg.value))
92
+ end
93
+
94
+ # `Shellwords.split(line)` / `.shellsplit` / `.shellwords` —
95
+ # one String arg, result lifted to Tuple[Constant[String]…].
96
+ def fold_split(args)
97
+ return nil unless args.size == 1
98
+
99
+ arg = args.first
100
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
101
+
102
+ tokens = Shellwords.split(arg.value)
103
+ return nil if tokens.size > SHELLWORDS_SPLIT_LIMIT
104
+
105
+ Type::Combinator.tuple_of(*tokens.map { |t| Type::Combinator.constant_of(t) })
106
+ rescue ArgumentError
107
+ # Unmatched quotes / invalid shell syntax — decline so the
108
+ # RBS tier returns the widened Array[String] envelope.
109
+ nil
110
+ end
111
+
112
+ # `Shellwords.join(array)` / `.shelljoin` — one Tuple arg
113
+ # whose every element is `Constant[String]`.
114
+ def fold_join(args)
115
+ return nil unless args.size == 1
116
+
117
+ arg = args.first
118
+ return nil unless arg.is_a?(Type::Tuple)
119
+ return nil unless arg.elements.all? { |e| e.is_a?(Type::Constant) && e.value.is_a?(String) }
120
+
121
+ Type::Combinator.constant_of(Shellwords.join(arg.elements.map(&:value)))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Folds the zone-pinned `Time` class constructors on statically
9
+ # known arguments.
10
+ #
11
+ # Only `Time.utc` / `Time.gm` are folded. They pin the result
12
+ # to UTC, so the literal is machine-independent — every reader
13
+ # (`year`, `hour`, `utc_offset`, `strftime`, …) yields the same
14
+ # answer on any analysis host.
15
+ #
16
+ # `Time.now` (non-deterministic), `Time.at` / `Time.local` /
17
+ # `Time.mktime` / `Time.new` (local-zone — the result's wall
18
+ # clock and `utc_offset` depend on the analysis machine's
19
+ # timezone) are deliberately NOT folded; they keep their
20
+ # `Nominal[Time]` RBS answer. For the same reason `Time#getlocal`
21
+ # is blocklisted in `TIME_CATALOG` so a folded `Constant[Time]`
22
+ # cannot produce a machine-dependent local-zone copy.
23
+ module TimeFolding
24
+ TIME_UTC_METHODS = Set[:utc, :gm].freeze
25
+ private_constant :TIME_UTC_METHODS
26
+
27
+ # `Time.utc` accepts the 1–7 positional (year-first) form and
28
+ # a 10-arg (sec-first) form; cap the arity so a malformed
29
+ # call declines cheaply rather than reaching the constructor.
30
+ MAX_TIME_ARITY = 10
31
+ private_constant :MAX_TIME_ARITY
32
+
33
+ module_function
34
+
35
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
36
+ def try_dispatch(receiver:, method_name:, args:)
37
+ return nil unless dispatch_target?(receiver)
38
+ return nil unless TIME_UTC_METHODS.include?(method_name)
39
+ return nil unless args.size.between?(1, MAX_TIME_ARITY)
40
+ return nil unless args.all?(Type::Constant)
41
+
42
+ values = args.map(&:value)
43
+ return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(String) }
44
+
45
+ Type::Combinator.constant_of(Time.utc(*values))
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def dispatch_target?(receiver)
51
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Time"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `URI` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `URI.encode_www_form_component` / `decode_www_form_component`
13
+ # and the newer `encode_uri_component` / `decode_uri_component`
14
+ # are pure, deterministic functions over their string inputs.
15
+ # When the argument is a `Constant[String]`, the analyzer can
16
+ # evaluate the call at inference time and return the concrete
17
+ # `Constant[String]` result.
18
+ #
19
+ # === Supported methods
20
+ #
21
+ # * `encode_www_form_component(str)` / `decode_www_form_component(str)` —
22
+ # RFC 3986 percent-encode / decode. Returns `Constant[String]`.
23
+ # * `encode_uri_component(str)` / `decode_uri_component(str)` —
24
+ # Same encoding but may preserve additional reserved chars
25
+ # (Ruby 3.2+). Returns `Constant[String]`.
26
+ #
27
+ # === Non-constant / unsupported cases
28
+ #
29
+ # Returns `nil` (deferring to the next dispatcher tier) when:
30
+ # - the receiver is not `Singleton[URI]`,
31
+ # - the first argument is not a `Constant[String]`,
32
+ # - the method is not in the supported set.
33
+ module URIFolding
34
+ URI_COMPONENT_METHODS = Set[
35
+ :encode_www_form_component, :decode_www_form_component,
36
+ :encode_uri_component, :decode_uri_component
37
+ ].freeze
38
+ private_constant :URI_COMPONENT_METHODS
39
+
40
+ module_function
41
+
42
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
43
+ def try_dispatch(receiver:, method_name:, args:)
44
+ return nil unless dispatch_target?(receiver)
45
+ return nil unless URI_COMPONENT_METHODS.include?(method_name)
46
+
47
+ fold_uri_call(method_name, args)
48
+ end
49
+
50
+ def dispatch_target?(receiver)
51
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "URI"
52
+ end
53
+
54
+ def fold_uri_call(method_name, args)
55
+ return nil unless args.size == 1
56
+
57
+ arg = args.first
58
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
59
+
60
+ Type::Combinator.constant_of(URI.public_send(method_name, arg.value))
61
+ rescue StandardError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end