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.
- checksums.yaml +4 -4
- data/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +42 -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
|
|
@@ -191,6 +217,16 @@ module Rigor
|
|
|
191
217
|
end
|
|
192
218
|
|
|
193
219
|
def dispatch_nominal_size(nominal, method_name, args)
|
|
220
|
+
if nominal.class_name == "String" && args.size == 1
|
|
221
|
+
string_binary = dispatch_string_binary_from_arg(method_name, args.first)
|
|
222
|
+
return string_binary if string_binary
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if nominal.class_name == "Integer" && args.size == 1
|
|
226
|
+
integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
|
|
227
|
+
return integer_binary if integer_binary
|
|
228
|
+
end
|
|
229
|
+
|
|
194
230
|
return nil unless args.empty?
|
|
195
231
|
|
|
196
232
|
selectors = SIZE_RETURNING_NOMINALS[nominal.class_name]
|
|
@@ -199,6 +235,38 @@ module Rigor
|
|
|
199
235
|
Type::Combinator.non_negative_int
|
|
200
236
|
end
|
|
201
237
|
|
|
238
|
+
# Arg-type-driven String binary projections for any String-typed
|
|
239
|
+
# receiver (including Nominal, Refined, and Difference fallbacks).
|
|
240
|
+
# Called before the no-arg size guard so binary operators are seen.
|
|
241
|
+
#
|
|
242
|
+
# - `String + non-empty-string` → `non-empty-string`
|
|
243
|
+
# (arg guarantees the concatenation is non-empty)
|
|
244
|
+
# - `String * Constant[0]` → `Constant[""]`
|
|
245
|
+
# (every string repeated 0 times is the empty string)
|
|
246
|
+
def dispatch_string_binary_from_arg(method_name, arg)
|
|
247
|
+
case method_name
|
|
248
|
+
when :+
|
|
249
|
+
return Type::Combinator.non_empty_string if Type::Combinator.non_empty_string_compatible?(arg)
|
|
250
|
+
when :*
|
|
251
|
+
if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
|
|
252
|
+
return Type::Combinator.constant_of("")
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Arg-type-driven Integer binary projections for any Integer-typed
|
|
259
|
+
# receiver (including Nominal, Refined, and Difference fallbacks).
|
|
260
|
+
#
|
|
261
|
+
# - `Integer * Constant[0]` → `Constant[0]`
|
|
262
|
+
# (any integer multiplied by 0 is 0)
|
|
263
|
+
def dispatch_integer_binary_from_arg(method_name, arg)
|
|
264
|
+
return nil unless method_name == :*
|
|
265
|
+
return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
|
|
266
|
+
|
|
267
|
+
Type::Combinator.constant_of(0)
|
|
268
|
+
end
|
|
269
|
+
|
|
202
270
|
# `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
|
|
203
271
|
# When the range's lower bound is `>= 0`, every member is
|
|
204
272
|
# a non-negative integer and `to_s(base)` returns a
|
|
@@ -255,7 +323,7 @@ module Rigor
|
|
|
255
323
|
return nil unless base.is_a?(Type::Nominal)
|
|
256
324
|
|
|
257
325
|
if removes_empty_witness?(difference)
|
|
258
|
-
precise = empty_removal_projection(
|
|
326
|
+
precise = empty_removal_projection(difference, method_name, args)
|
|
259
327
|
return precise if precise
|
|
260
328
|
end
|
|
261
329
|
|
|
@@ -279,14 +347,62 @@ module Rigor
|
|
|
279
347
|
!!(predicate && predicate.call(difference.removed))
|
|
280
348
|
end
|
|
281
349
|
|
|
282
|
-
|
|
283
|
-
|
|
350
|
+
# Methods on a non-empty String that preserve non-emptiness
|
|
351
|
+
# (they transform characters but never reduce the string to "").
|
|
352
|
+
NON_EMPTY_STRING_PRESERVING_UNARY = Set[:upcase, :downcase, :capitalize, :swapcase, :reverse].freeze
|
|
353
|
+
# Methods on non-zero-int that return a non-zero-int (identity ops).
|
|
354
|
+
# Negation of a non-zero integer is non-zero; `to_i`/`to_int` are
|
|
355
|
+
# identity operations on Integer.
|
|
356
|
+
NON_ZERO_INT_PRESERVING_UNARY = Set[:-@, :+@, :to_i, :to_int].freeze
|
|
357
|
+
private_constant :NON_EMPTY_STRING_PRESERVING_UNARY, :NON_ZERO_INT_PRESERVING_UNARY
|
|
284
358
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
359
|
+
def empty_removal_projection(difference, method_name, args)
|
|
360
|
+
base = difference.base
|
|
361
|
+
return empty_removal_unary(difference, base, method_name) if args.empty?
|
|
362
|
+
|
|
363
|
+
empty_removal_binary(difference, base, method_name, args)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def empty_removal_unary(difference, base, method_name)
|
|
367
|
+
return size_returning_for_empty_removal(base, method_name) if
|
|
368
|
+
%i[size length count bytesize].include?(method_name)
|
|
369
|
+
|
|
370
|
+
predicate_result = empty_predicate_projection(base, method_name)
|
|
371
|
+
return predicate_result if predicate_result
|
|
372
|
+
|
|
373
|
+
return difference if base.class_name == "String" &&
|
|
374
|
+
NON_EMPTY_STRING_PRESERVING_UNARY.include?(method_name)
|
|
375
|
+
|
|
376
|
+
non_zero_int_unary_projection(difference, base, method_name)
|
|
377
|
+
end
|
|
288
378
|
|
|
289
|
-
|
|
379
|
+
def non_zero_int_unary_projection(difference, base, method_name)
|
|
380
|
+
return nil unless base.class_name == "Integer"
|
|
381
|
+
return Type::Combinator.positive_int if %i[abs magnitude].include?(method_name)
|
|
382
|
+
return difference if NON_ZERO_INT_PRESERVING_UNARY.include?(method_name)
|
|
383
|
+
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def empty_removal_binary(difference, base, method_name, args)
|
|
388
|
+
return empty_string_binary(difference, method_name, args) if base.class_name == "String"
|
|
389
|
+
return empty_integer_binary(difference, method_name, args) if base.class_name == "Integer"
|
|
390
|
+
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def empty_string_binary(difference, method_name, args)
|
|
395
|
+
return difference if method_name == :+ && args.size == 1
|
|
396
|
+
return non_empty_string_repeat(difference, args.first) if method_name == :* && args.size == 1
|
|
397
|
+
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def empty_integer_binary(difference, method_name, args)
|
|
402
|
+
return nil unless method_name == :* && args.size == 1
|
|
403
|
+
return nil unless Type::Combinator.non_zero_int_compatible?(args.first)
|
|
404
|
+
|
|
405
|
+
difference
|
|
290
406
|
end
|
|
291
407
|
|
|
292
408
|
def empty_predicate_projection(base, method_name)
|
|
@@ -298,6 +414,24 @@ module Rigor
|
|
|
298
414
|
end
|
|
299
415
|
end
|
|
300
416
|
|
|
417
|
+
# `non-empty-string * n` result:
|
|
418
|
+
# - `n == 0` → `Constant[""]` (any string repeated 0 times is empty)
|
|
419
|
+
# - `n >= 1` → `difference` (non-empty-string stays non-empty)
|
|
420
|
+
# - otherwise → nil (fall through, e.g. unknown n or non-negative-int)
|
|
421
|
+
def non_empty_string_repeat(difference, arg)
|
|
422
|
+
case arg
|
|
423
|
+
when Type::Constant
|
|
424
|
+
return nil unless arg.value.is_a?(Integer)
|
|
425
|
+
|
|
426
|
+
return Type::Combinator.constant_of("") if arg.value.zero?
|
|
427
|
+
return difference if arg.value.positive?
|
|
428
|
+
when Type::IntegerRange
|
|
429
|
+
return Type::Combinator.constant_of("") if arg.lower.zero? && arg.upper.zero?
|
|
430
|
+
return difference if arg.lower >= 1
|
|
431
|
+
end
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
301
435
|
def size_returning_for_empty_removal(base, method_name)
|
|
302
436
|
return nil if base.class_name == "Integer" # Integer has no size method on Difference
|
|
303
437
|
|
|
@@ -654,6 +788,67 @@ module Rigor
|
|
|
654
788
|
[key.value, value]
|
|
655
789
|
end
|
|
656
790
|
|
|
791
|
+
# `tuple.values_at(i1, i2, ...)` — returns a Tuple of
|
|
792
|
+
# per-index elements. Each argument must be a
|
|
793
|
+
# `Constant[Integer]`. Out-of-range indices fill with
|
|
794
|
+
# `Constant[nil]`, mirroring Ruby's runtime behaviour.
|
|
795
|
+
# Declines when any argument is non-static.
|
|
796
|
+
def tuple_values_at(tuple, _method_name, args)
|
|
797
|
+
return nil if args.empty?
|
|
798
|
+
|
|
799
|
+
values = args.map do |arg|
|
|
800
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
801
|
+
return nil unless arg.value.is_a?(Integer)
|
|
802
|
+
|
|
803
|
+
idx = normalise_index(arg.value, tuple.elements.size)
|
|
804
|
+
idx ? tuple.elements[idx] : Type::Combinator.constant_of(nil)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
Type::Combinator.tuple_of(*values)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# `tuple + other` — concatenates two Tuples. Both sides
|
|
811
|
+
# must be `Type::Tuple`. Returns a new Tuple whose
|
|
812
|
+
# elements are those of the receiver followed by those
|
|
813
|
+
# of the argument.
|
|
814
|
+
def tuple_concat(tuple, _method_name, args)
|
|
815
|
+
return nil unless args.size == 1
|
|
816
|
+
|
|
817
|
+
other = args.first
|
|
818
|
+
return nil unless other.is_a?(Type::Tuple)
|
|
819
|
+
|
|
820
|
+
Type::Combinator.tuple_of(*tuple.elements, *other.elements)
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# `tuple.compact` — removes every element that is
|
|
824
|
+
# `Constant[nil]`. Folds only when every element is a
|
|
825
|
+
# `Constant` (so the nil set is decidable). Mixed-shape
|
|
826
|
+
# elements decline so the RBS tier widens.
|
|
827
|
+
def tuple_compact(tuple, _method_name, args)
|
|
828
|
+
return nil unless args.empty?
|
|
829
|
+
return nil unless tuple.elements.all?(Type::Constant)
|
|
830
|
+
|
|
831
|
+
kept = tuple.elements.reject { |e| e.is_a?(Type::Constant) && e.value.nil? }
|
|
832
|
+
Type::Combinator.tuple_of(*kept)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# `tuple.take(n)` — returns the first n elements as a
|
|
836
|
+
# new Tuple. The argument must be a `Constant[Integer]`.
|
|
837
|
+
# n <= 0 returns the empty Tuple; n >= size returns the
|
|
838
|
+
# full receiver.
|
|
839
|
+
def tuple_take(tuple, _method_name, args)
|
|
840
|
+
return nil unless args.size == 1
|
|
841
|
+
|
|
842
|
+
arg = args.first
|
|
843
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
844
|
+
return nil unless arg.value.is_a?(Integer)
|
|
845
|
+
|
|
846
|
+
n = arg.value
|
|
847
|
+
return Type::Combinator.tuple_of if n <= 0
|
|
848
|
+
|
|
849
|
+
Type::Combinator.tuple_of(*tuple.elements.take(n))
|
|
850
|
+
end
|
|
851
|
+
|
|
657
852
|
# Returns `true` / `false` if every element's truthiness
|
|
658
853
|
# agrees, nil for mixed-or-unknown shapes. `all: true`
|
|
659
854
|
# checks every element is truthy; `all: false` checks
|
|
@@ -817,6 +1012,185 @@ module Rigor
|
|
|
817
1012
|
|
|
818
1013
|
Type::Combinator.constant_of(!shape.pairs.empty?)
|
|
819
1014
|
end
|
|
1015
|
+
|
|
1016
|
+
# `shape.none?` (no block, no arg) — mirror of `any?`.
|
|
1017
|
+
# Folds to `Constant[shape.pairs.empty?]` for closed
|
|
1018
|
+
# shapes with no optional keys.
|
|
1019
|
+
def hash_none?(shape, _method_name, args)
|
|
1020
|
+
return nil unless args.empty?
|
|
1021
|
+
return nil unless shape.closed?
|
|
1022
|
+
return nil unless shape.optional_keys.empty?
|
|
1023
|
+
|
|
1024
|
+
Type::Combinator.constant_of(shape.pairs.empty?)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# `shape.one?` (no block, no arg) — folds to
|
|
1028
|
+
# `Constant[shape.pairs.size == 1]` for a closed shape
|
|
1029
|
+
# with no optional keys.
|
|
1030
|
+
def hash_one?(shape, _method_name, args)
|
|
1031
|
+
return nil unless args.empty?
|
|
1032
|
+
return nil unless shape.closed?
|
|
1033
|
+
return nil unless shape.optional_keys.empty?
|
|
1034
|
+
|
|
1035
|
+
Type::Combinator.constant_of(shape.pairs.size == 1)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
# `shape.deconstruct_keys(keys)` — Ruby's `Hash#deconstruct_keys`
|
|
1039
|
+
# returns the receiver itself regardless of the `keys`
|
|
1040
|
+
# argument, so the precise answer is the shape unchanged.
|
|
1041
|
+
def hash_deconstruct_keys(shape, _method_name, args)
|
|
1042
|
+
return nil unless args.size == 1
|
|
1043
|
+
|
|
1044
|
+
shape
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# `shape.fetch_values(:a, :b, ...)` — like `values_at` but
|
|
1048
|
+
# raises `KeyError` on a missing key. Folds to `Tuple[V…]`
|
|
1049
|
+
# only when every requested key is present; a missing key
|
|
1050
|
+
# declines so the RBS tier reflects the raise.
|
|
1051
|
+
def hash_fetch_values(shape, _method_name, args)
|
|
1052
|
+
return nil if args.empty?
|
|
1053
|
+
return nil unless shape.closed?
|
|
1054
|
+
return nil unless shape.optional_keys.empty?
|
|
1055
|
+
|
|
1056
|
+
values = []
|
|
1057
|
+
args.each do |arg|
|
|
1058
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1059
|
+
|
|
1060
|
+
key = arg.value
|
|
1061
|
+
return nil unless key.is_a?(Symbol) || key.is_a?(String)
|
|
1062
|
+
return nil unless shape.pairs.key?(key)
|
|
1063
|
+
|
|
1064
|
+
values << shape.pairs[key]
|
|
1065
|
+
end
|
|
1066
|
+
Type::Combinator.tuple_of(*values)
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
# `shape.assoc(key)` — returns `Tuple[Constant[k], V]` for a
|
|
1070
|
+
# known key, `Constant[nil]` for a missing key.
|
|
1071
|
+
def hash_assoc(shape, _method_name, args)
|
|
1072
|
+
return nil unless args.size == 1
|
|
1073
|
+
return nil unless shape.closed?
|
|
1074
|
+
return nil unless shape.optional_keys.empty?
|
|
1075
|
+
|
|
1076
|
+
arg = args.first
|
|
1077
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1078
|
+
|
|
1079
|
+
key = arg.value
|
|
1080
|
+
return nil unless key.is_a?(Symbol) || key.is_a?(String)
|
|
1081
|
+
return Type::Combinator.constant_of(nil) unless shape.pairs.key?(key)
|
|
1082
|
+
|
|
1083
|
+
Type::Combinator.tuple_of(Type::Combinator.constant_of(key), shape.pairs[key])
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# `shape.key(value)` — reverse lookup. Folds when every
|
|
1087
|
+
# value is a `Constant` so equality is decidable: returns
|
|
1088
|
+
# `Constant[k]` for the first matching key, `Constant[nil]`
|
|
1089
|
+
# when no value matches.
|
|
1090
|
+
def hash_key(shape, _method_name, args)
|
|
1091
|
+
return nil unless args.size == 1
|
|
1092
|
+
return nil unless shape.closed?
|
|
1093
|
+
return nil unless shape.optional_keys.empty?
|
|
1094
|
+
return nil unless shape.pairs.values.all?(Type::Constant)
|
|
1095
|
+
|
|
1096
|
+
arg = args.first
|
|
1097
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1098
|
+
|
|
1099
|
+
pair = shape.pairs.find { |_k, v| v.value == arg.value }
|
|
1100
|
+
Type::Combinator.constant_of(pair&.first)
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# `shape.has_value?(v)` / `value?(v)` — folds to
|
|
1104
|
+
# `Constant[true/false]` when every value is a `Constant`
|
|
1105
|
+
# (so equality is decidable) and the argument is a
|
|
1106
|
+
# `Constant`.
|
|
1107
|
+
def hash_has_value?(shape, _method_name, args)
|
|
1108
|
+
return nil unless args.size == 1
|
|
1109
|
+
return nil unless shape.closed?
|
|
1110
|
+
return nil unless shape.optional_keys.empty?
|
|
1111
|
+
return nil unless shape.pairs.values.all?(Type::Constant)
|
|
1112
|
+
|
|
1113
|
+
arg = args.first
|
|
1114
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1115
|
+
|
|
1116
|
+
found = shape.pairs.values.any? { |v| v.value == arg.value }
|
|
1117
|
+
Type::Combinator.constant_of(found)
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# `shape.default` / `default_proc` — a literal `HashShape`
|
|
1121
|
+
# carries no default value or proc, so both fold to
|
|
1122
|
+
# `Constant[nil]`. `default` accepts an optional key
|
|
1123
|
+
# argument (still returns the default), `default_proc`
|
|
1124
|
+
# takes none — the `args.size <= 1` guard covers both.
|
|
1125
|
+
def hash_default(shape, _method_name, args)
|
|
1126
|
+
return nil unless args.size <= 1
|
|
1127
|
+
return nil unless shape.closed?
|
|
1128
|
+
|
|
1129
|
+
Type::Combinator.constant_of(nil)
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
# `shape < other` / `<=` / `>` / `>=` — Hash containment
|
|
1133
|
+
# comparison. Both sides must be closed `HashShape`s whose
|
|
1134
|
+
# values are all `Constant` (so pair equality is
|
|
1135
|
+
# decidable). `<` / `>` are proper-subset / -superset.
|
|
1136
|
+
def hash_compare(shape, method_name, args)
|
|
1137
|
+
return nil unless args.size == 1
|
|
1138
|
+
return nil unless shape.closed? && shape.optional_keys.empty?
|
|
1139
|
+
|
|
1140
|
+
other = args.first
|
|
1141
|
+
return nil unless other.is_a?(Type::HashShape)
|
|
1142
|
+
return nil unless other.closed? && other.optional_keys.empty?
|
|
1143
|
+
|
|
1144
|
+
left = constant_pairs(shape)
|
|
1145
|
+
right = constant_pairs(other)
|
|
1146
|
+
return nil if left.nil? || right.nil?
|
|
1147
|
+
|
|
1148
|
+
Type::Combinator.constant_of(hash_containment(method_name, left, right))
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
# Unwraps a closed shape's pairs to a plain Ruby Hash of
|
|
1152
|
+
# `key => value` for value-equality comparison. Returns nil
|
|
1153
|
+
# when any value is not a `Constant`.
|
|
1154
|
+
def constant_pairs(shape)
|
|
1155
|
+
return nil unless shape.pairs.values.all?(Type::Constant)
|
|
1156
|
+
|
|
1157
|
+
shape.pairs.transform_values(&:value)
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
def hash_containment(method_name, left, right)
|
|
1161
|
+
case method_name
|
|
1162
|
+
when :< then hash_proper_subset?(left, right)
|
|
1163
|
+
when :<= then hash_subset?(left, right)
|
|
1164
|
+
when :> then hash_proper_subset?(right, left)
|
|
1165
|
+
when :>= then hash_subset?(right, left)
|
|
1166
|
+
end
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def hash_subset?(left, right)
|
|
1170
|
+
left.all? { |k, v| right.key?(k) && right[k] == v }
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
def hash_proper_subset?(left, right)
|
|
1174
|
+
left.size < right.size && hash_subset?(left, right)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# `shape.has_key?(k)` / `key?(k)` / `member?(k)` /
|
|
1178
|
+
# `include?(k)` — folds to `Constant[true/false]` when
|
|
1179
|
+
# the argument is a `Constant[Symbol|String]` and the
|
|
1180
|
+
# shape is closed with no optional keys.
|
|
1181
|
+
def hash_has_key?(shape, _method_name, args)
|
|
1182
|
+
return nil unless args.size == 1
|
|
1183
|
+
return nil unless shape.closed?
|
|
1184
|
+
return nil unless shape.optional_keys.empty?
|
|
1185
|
+
|
|
1186
|
+
arg = args.first
|
|
1187
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1188
|
+
|
|
1189
|
+
key = arg.value
|
|
1190
|
+
return nil unless key.is_a?(Symbol) || key.is_a?(String)
|
|
1191
|
+
|
|
1192
|
+
Type::Combinator.constant_of(shape.pairs.key?(key))
|
|
1193
|
+
end
|
|
820
1194
|
# rubocop:enable Style/ReturnNilInPredicateMethodDefinition
|
|
821
1195
|
|
|
822
1196
|
# `shape.keys` — returns a `Tuple[Constant<k>…]` for a
|
|
@@ -1027,6 +1401,54 @@ module Rigor
|
|
|
1027
1401
|
Type::Combinator.tuple_of(*values)
|
|
1028
1402
|
end
|
|
1029
1403
|
|
|
1404
|
+
# `shape.slice(:a, :b, ...)` — returns a sub-HashShape
|
|
1405
|
+
# containing only the specified keys. All arguments must
|
|
1406
|
+
# be `Constant[Symbol|String]`. Keys not present in the
|
|
1407
|
+
# shape are silently omitted (matching Ruby's runtime
|
|
1408
|
+
# semantics — no nil padding). Declines on open shapes
|
|
1409
|
+
# or when any argument is not a static key.
|
|
1410
|
+
def hash_slice(shape, _method_name, args)
|
|
1411
|
+
return nil if args.empty?
|
|
1412
|
+
return nil unless shape.closed?
|
|
1413
|
+
return nil unless shape.optional_keys.empty?
|
|
1414
|
+
|
|
1415
|
+
requested = []
|
|
1416
|
+
args.each do |arg|
|
|
1417
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1418
|
+
|
|
1419
|
+
key = arg.value
|
|
1420
|
+
return nil unless key.is_a?(Symbol) || key.is_a?(String)
|
|
1421
|
+
|
|
1422
|
+
requested << key
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
Type::Combinator.hash_shape_of(shape.pairs.slice(*requested))
|
|
1426
|
+
end
|
|
1427
|
+
|
|
1428
|
+
# `shape.except(:a, :b, ...)` — returns a sub-HashShape
|
|
1429
|
+
# with the specified keys removed. All arguments must be
|
|
1430
|
+
# `Constant[Symbol|String]`. Keys not present in the shape
|
|
1431
|
+
# are silently ignored. Declines on open shapes or when
|
|
1432
|
+
# any argument is not a static key.
|
|
1433
|
+
def hash_except(shape, _method_name, args)
|
|
1434
|
+
return nil if args.empty?
|
|
1435
|
+
return nil unless shape.closed?
|
|
1436
|
+
return nil unless shape.optional_keys.empty?
|
|
1437
|
+
|
|
1438
|
+
excluded = {}
|
|
1439
|
+
args.each do |arg|
|
|
1440
|
+
return nil unless arg.is_a?(Type::Constant)
|
|
1441
|
+
|
|
1442
|
+
key = arg.value
|
|
1443
|
+
return nil unless key.is_a?(Symbol) || key.is_a?(String)
|
|
1444
|
+
|
|
1445
|
+
excluded[key] = true
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
kept = shape.pairs.reject { |k, _v| excluded.key?(k) }
|
|
1449
|
+
Type::Combinator.hash_shape_of(kept)
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1030
1452
|
# Continues a `dig` chain after the first step. Tuple and
|
|
1031
1453
|
# HashShape members re-dispatch into the catalogue;
|
|
1032
1454
|
# `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
|