rigortype 0.0.2 → 0.0.3
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/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/lib/rigor/analysis/check_rules.rb +86 -1
- data/lib/rigor/analysis/runner.rb +4 -0
- data/lib/rigor/builtins/imported_refinements.rb +69 -0
- data/lib/rigor/configuration.rb +6 -1
- data/lib/rigor/inference/acceptance.rb +149 -0
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/expression_typer.rb +48 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/narrowing.rb +374 -4
- data/lib/rigor/inference/scope_indexer.rb +10 -2
- data/lib/rigor/inference/statement_evaluator.rb +211 -2
- data/lib/rigor/rbs_extended.rb +65 -1
- data/lib/rigor/scope.rb +14 -0
- data/lib/rigor/type/combinator.rb +69 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +3 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +51 -1
- metadata +15 -1
|
@@ -155,6 +155,61 @@ module Rigor
|
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Integer-comparison fragment of `type` against an Integer
|
|
159
|
+
# literal `bound`. Narrows the receiver of `x < n`, `x <= n`,
|
|
160
|
+
# `x > n`, `x >= n` (and the reversed forms) to the subset of
|
|
161
|
+
# the existing domain that satisfies the comparison. Hooks in:
|
|
162
|
+
# - `Constant<Integer>` is preserved when it satisfies the
|
|
163
|
+
# comparison, otherwise collapsed to `Bot`.
|
|
164
|
+
# - `IntegerRange[a..b]` becomes the intersection with the
|
|
165
|
+
# half-line implied by the comparison; an empty intersection
|
|
166
|
+
# collapses to `Bot`, a single-point intersection collapses
|
|
167
|
+
# to `Constant<Integer>`.
|
|
168
|
+
# - `Nominal[Integer]` becomes the half-line itself (e.g.
|
|
169
|
+
# `x > 0` on `Nominal[Integer]` is `positive_int`).
|
|
170
|
+
# - `Union` narrows each member independently.
|
|
171
|
+
# - Other carriers (Float, String, Top, Dynamic) flow through
|
|
172
|
+
# unchanged: the analyzer does not have a Float-range carrier
|
|
173
|
+
# today, and no other carrier participates in numeric ordering.
|
|
174
|
+
def narrow_integer_comparison(type, comparator, bound)
|
|
175
|
+
return type unless bound.is_a?(Integer) && %i[< <= > >=].include?(comparator)
|
|
176
|
+
|
|
177
|
+
narrow_integer_comparison_dispatch(type, comparator, bound)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Equality fragment of `type` against an Integer `value`.
|
|
181
|
+
# `Constant<Integer>` is preserved when it equals `value`,
|
|
182
|
+
# otherwise collapses to `Bot`. `IntegerRange` covers? `value`
|
|
183
|
+
# narrows to `Constant[value]`; an out-of-range comparison
|
|
184
|
+
# collapses to `Bot`. `Nominal[Integer]` narrows to
|
|
185
|
+
# `Constant[value]`. `Union` narrows each member.
|
|
186
|
+
def narrow_integer_equal(type, value)
|
|
187
|
+
return type unless value.is_a?(Integer)
|
|
188
|
+
|
|
189
|
+
narrow_integer_equal_dispatch(type, value)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Complement of {.narrow_integer_equal}. Removes a single
|
|
193
|
+
# integer value from the domain when one endpoint of an
|
|
194
|
+
# `IntegerRange` is exactly that value (so the result stays a
|
|
195
|
+
# contiguous range). Domains where the value sits strictly
|
|
196
|
+
# between the endpoints stay unchanged: punching a hole would
|
|
197
|
+
# require a two-piece carrier the lattice does not yet model.
|
|
198
|
+
def narrow_integer_not_equal(type, value)
|
|
199
|
+
return type unless value.is_a?(Integer)
|
|
200
|
+
|
|
201
|
+
case type
|
|
202
|
+
when Type::Constant
|
|
203
|
+
type.value == value ? Type::Combinator.bot : type
|
|
204
|
+
when Type::IntegerRange
|
|
205
|
+
narrow_integer_range_not_equal(type, value)
|
|
206
|
+
when Type::Union
|
|
207
|
+
Type::Combinator.union(*type.members.map { |m| narrow_integer_not_equal(m, value) })
|
|
208
|
+
else
|
|
209
|
+
type
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
158
213
|
# Class-membership fragment of `type`: the subset whose
|
|
159
214
|
# inhabitants are instances of `class_name` (or its subclasses
|
|
160
215
|
# when `exact: false`). `class_name` is the qualified name of
|
|
@@ -452,7 +507,21 @@ module Rigor
|
|
|
452
507
|
end
|
|
453
508
|
end
|
|
454
509
|
|
|
510
|
+
ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
|
|
511
|
+
COMPARISON_OPERATORS = %i[< <= > >=].freeze
|
|
512
|
+
private_constant :ZERO_CLASS_PREDICATES, :COMPARISON_OPERATORS
|
|
513
|
+
|
|
455
514
|
def dispatch_call(node, scope, name)
|
|
515
|
+
return dispatch_call_simple(node, scope, name) if simple_dispatch_name?(name)
|
|
516
|
+
|
|
517
|
+
dispatch_call_numeric(node, scope, name)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def simple_dispatch_name?(name)
|
|
521
|
+
%i[nil? ! is_a? kind_of? instance_of? == != ===].include?(name)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def dispatch_call_simple(node, scope, name)
|
|
456
525
|
case name
|
|
457
526
|
when :nil?, :! then dispatch_unary_predicate(node, scope, name)
|
|
458
527
|
when :is_a?, :kind_of? then analyse_class_predicate(node, scope, exact: false)
|
|
@@ -462,6 +531,92 @@ module Rigor
|
|
|
462
531
|
end
|
|
463
532
|
end
|
|
464
533
|
|
|
534
|
+
def dispatch_call_numeric(node, scope, name)
|
|
535
|
+
if COMPARISON_OPERATORS.include?(name)
|
|
536
|
+
analyse_comparison_predicate(node, scope, comparator: name)
|
|
537
|
+
elsif ZERO_CLASS_PREDICATES.include?(name)
|
|
538
|
+
analyse_zero_class_predicate(node, scope, predicate: name)
|
|
539
|
+
elsif name == :between?
|
|
540
|
+
analyse_between_predicate(node, scope)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# `:positive?` / `:negative?` / `:zero?` / `:nonzero?` are
|
|
545
|
+
# zero-arg predicates on `Numeric`. We model them as
|
|
546
|
+
# comparisons against the literal 0 so the existing range
|
|
547
|
+
# narrowing handles them uniformly.
|
|
548
|
+
ZERO_CLASS_PREDICATE_RULES = {
|
|
549
|
+
positive?: { truthy: [:>, 0], falsey: [:<=, 0] },
|
|
550
|
+
negative?: { truthy: [:<, 0], falsey: [:>=, 0] },
|
|
551
|
+
zero?: { truthy: [:eq, 0], falsey: [:ne, 0] },
|
|
552
|
+
nonzero?: { truthy: [:ne, 0], falsey: [:eq, 0] }
|
|
553
|
+
}.freeze
|
|
554
|
+
private_constant :ZERO_CLASS_PREDICATE_RULES
|
|
555
|
+
|
|
556
|
+
def analyse_zero_class_predicate(node, scope, predicate:)
|
|
557
|
+
return nil unless argument_free?(node)
|
|
558
|
+
return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
559
|
+
|
|
560
|
+
local_name = node.receiver.name
|
|
561
|
+
current = scope.local(local_name)
|
|
562
|
+
return nil if current.nil?
|
|
563
|
+
|
|
564
|
+
rules = ZERO_CLASS_PREDICATE_RULES[predicate]
|
|
565
|
+
truthy = apply_zero_rule(current, rules[:truthy])
|
|
566
|
+
falsey = apply_zero_rule(current, rules[:falsey])
|
|
567
|
+
[scope.with_local(local_name, truthy), scope.with_local(local_name, falsey)]
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def apply_zero_rule(type, rule)
|
|
571
|
+
case rule[0]
|
|
572
|
+
when :eq then narrow_integer_equal(type, rule[1])
|
|
573
|
+
when :ne then narrow_integer_not_equal(type, rule[1])
|
|
574
|
+
else
|
|
575
|
+
narrow_integer_comparison(type, rule[0], rule[1])
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# `x.between?(a, b)` truthy edge narrows to
|
|
580
|
+
# `narrow_integer_comparison(>=, a)` ∩
|
|
581
|
+
# `narrow_integer_comparison(<=, b)`. The falsey edge is left
|
|
582
|
+
# unchanged because the complement is a two-piece domain
|
|
583
|
+
# (`x < a || x > b`) that the lattice cannot express
|
|
584
|
+
# precisely. `a` and `b` MUST both be integer literals.
|
|
585
|
+
def analyse_between_predicate(node, scope)
|
|
586
|
+
return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
587
|
+
return nil if node.arguments.nil?
|
|
588
|
+
return nil unless node.arguments.arguments.size == 2
|
|
589
|
+
|
|
590
|
+
low, high = node.arguments.arguments
|
|
591
|
+
return nil unless low.is_a?(Prism::IntegerNode) && high.is_a?(Prism::IntegerNode)
|
|
592
|
+
|
|
593
|
+
local_name = node.receiver.name
|
|
594
|
+
current = scope.local(local_name)
|
|
595
|
+
return nil if current.nil?
|
|
596
|
+
|
|
597
|
+
truthy = narrow_integer_comparison(
|
|
598
|
+
narrow_integer_comparison(current, :>=, low.value),
|
|
599
|
+
:<=, high.value
|
|
600
|
+
)
|
|
601
|
+
[scope.with_local(local_name, truthy), scope]
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Helper for {.narrow_integer_not_equal}. Only adjusts when the
|
|
605
|
+
# value sits exactly on one endpoint, so the result stays
|
|
606
|
+
# contiguous; otherwise the input range is preserved.
|
|
607
|
+
def narrow_integer_range_not_equal(range, value)
|
|
608
|
+
return range if range.lower > value || range.upper < value
|
|
609
|
+
return Type::Combinator.bot if single_point_range_equal?(range, value)
|
|
610
|
+
return build_narrowing_integer_range(value + 1, range.upper) if range.lower == value
|
|
611
|
+
return build_narrowing_integer_range(range.lower, value - 1) if range.upper == value
|
|
612
|
+
|
|
613
|
+
range
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def single_point_range_equal?(range, value)
|
|
617
|
+
range.finite? && range.min == value && range.max == value
|
|
618
|
+
end
|
|
619
|
+
|
|
465
620
|
def dispatch_unary_predicate(node, scope, name)
|
|
466
621
|
return nil unless argument_free?(node)
|
|
467
622
|
|
|
@@ -491,6 +646,126 @@ module Rigor
|
|
|
491
646
|
equality == :== ? [positive, negative] : [negative, positive]
|
|
492
647
|
end
|
|
493
648
|
|
|
649
|
+
# Comparison predicate analyser. Recognised shapes:
|
|
650
|
+
# x < Int x <= Int x > Int x >= Int
|
|
651
|
+
# Int < x Int <= x Int > x Int >= x
|
|
652
|
+
# The reversed (literal-on-left) form is normalised by
|
|
653
|
+
# transposing the operator so the receiver-local always
|
|
654
|
+
# appears on the left of the rule.
|
|
655
|
+
INVERT_COMPARISON_OP = { :< => :>=, :<= => :>, :> => :<=, :>= => :< }.freeze
|
|
656
|
+
REVERSE_COMPARISON_OP = { :< => :>, :<= => :>=, :> => :<, :>= => :<= }.freeze
|
|
657
|
+
private_constant :INVERT_COMPARISON_OP, :REVERSE_COMPARISON_OP
|
|
658
|
+
|
|
659
|
+
def analyse_comparison_predicate(node, scope, comparator:)
|
|
660
|
+
return nil if node.arguments.nil?
|
|
661
|
+
return nil unless node.arguments.arguments.size == 1
|
|
662
|
+
|
|
663
|
+
match = comparison_local_literal(node.receiver, node.arguments.arguments.first, comparator)
|
|
664
|
+
return nil if match.nil?
|
|
665
|
+
|
|
666
|
+
local_name, normalised_op, bound = match
|
|
667
|
+
current = scope.local(local_name)
|
|
668
|
+
return nil if current.nil?
|
|
669
|
+
|
|
670
|
+
truthy = narrow_integer_comparison(current, normalised_op, bound)
|
|
671
|
+
falsey = narrow_integer_comparison(current, INVERT_COMPARISON_OP[normalised_op], bound)
|
|
672
|
+
[scope.with_local(local_name, truthy), scope.with_local(local_name, falsey)]
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def comparison_local_literal(left, right, comparator)
|
|
676
|
+
if left.is_a?(Prism::LocalVariableReadNode) && right.is_a?(Prism::IntegerNode)
|
|
677
|
+
return [left.name, comparator, right.value]
|
|
678
|
+
end
|
|
679
|
+
return nil unless right.is_a?(Prism::LocalVariableReadNode) && left.is_a?(Prism::IntegerNode)
|
|
680
|
+
|
|
681
|
+
[right.name, REVERSE_COMPARISON_OP[comparator], left.value]
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def narrow_integer_equal_dispatch(type, value)
|
|
685
|
+
case type
|
|
686
|
+
when Type::Constant then narrow_integer_equal_constant(type, value)
|
|
687
|
+
when Type::IntegerRange then narrow_integer_equal_range(type, value)
|
|
688
|
+
when Type::Nominal then narrow_integer_equal_nominal(type, value)
|
|
689
|
+
when Type::Union
|
|
690
|
+
Type::Combinator.union(*type.members.map { |m| narrow_integer_equal(m, value) })
|
|
691
|
+
else type
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def narrow_integer_equal_constant(constant, value)
|
|
696
|
+
constant.value == value ? constant : Type::Combinator.bot
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def narrow_integer_equal_range(range, value)
|
|
700
|
+
range.covers?(value) ? Type::Combinator.constant_of(value) : Type::Combinator.bot
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def narrow_integer_equal_nominal(nominal, value)
|
|
704
|
+
return nominal unless nominal.class_name == "Integer" && nominal.type_args.empty?
|
|
705
|
+
|
|
706
|
+
Type::Combinator.constant_of(value)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def narrow_integer_comparison_dispatch(type, comparator, bound)
|
|
710
|
+
case type
|
|
711
|
+
when Type::Constant
|
|
712
|
+
integer_constant_satisfies?(type.value, comparator, bound) ? type : Type::Combinator.bot
|
|
713
|
+
when Type::IntegerRange
|
|
714
|
+
intersect_integer_range(type, comparator, bound)
|
|
715
|
+
when Type::Nominal
|
|
716
|
+
narrow_integer_comparison_nominal(type, comparator, bound)
|
|
717
|
+
when Type::Union
|
|
718
|
+
Type::Combinator.union(
|
|
719
|
+
*type.members.map { |m| narrow_integer_comparison(m, comparator, bound) }
|
|
720
|
+
)
|
|
721
|
+
else
|
|
722
|
+
type
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def narrow_integer_comparison_nominal(nominal, comparator, bound)
|
|
727
|
+
return nominal unless nominal.class_name == "Integer" && nominal.type_args.empty?
|
|
728
|
+
|
|
729
|
+
intersect_integer_range(Type::Combinator.universal_int, comparator, bound)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def integer_constant_satisfies?(value, comparator, bound)
|
|
733
|
+
return false unless value.is_a?(Integer)
|
|
734
|
+
|
|
735
|
+
case comparator
|
|
736
|
+
when :< then value < bound
|
|
737
|
+
when :<= then value <= bound
|
|
738
|
+
when :> then value > bound
|
|
739
|
+
when :>= then value >= bound
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def intersect_integer_range(range, comparator, bound)
|
|
744
|
+
new_lower, new_upper = comparison_endpoints(range, comparator, bound)
|
|
745
|
+
return Type::Combinator.bot if new_lower > new_upper
|
|
746
|
+
|
|
747
|
+
build_narrowing_integer_range(new_lower, new_upper)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def comparison_endpoints(range, comparator, bound)
|
|
751
|
+
case comparator
|
|
752
|
+
when :< then [range.lower, [range.upper, bound - 1].min]
|
|
753
|
+
when :<= then [range.lower, [range.upper, bound].min]
|
|
754
|
+
when :> then [[range.lower, bound + 1].max, range.upper]
|
|
755
|
+
when :>= then [[range.lower, bound].max, range.upper]
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def build_narrowing_integer_range(lower, upper)
|
|
760
|
+
min = lower == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : Integer(lower)
|
|
761
|
+
max = upper == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : Integer(upper)
|
|
762
|
+
if min.is_a?(Integer) && max.is_a?(Integer) && min == max
|
|
763
|
+
Type::Combinator.constant_of(min)
|
|
764
|
+
else
|
|
765
|
+
Type::Combinator.integer_range(min, max)
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
494
769
|
def equality_local_literal(left, right, scope)
|
|
495
770
|
if left.is_a?(Prism::LocalVariableReadNode)
|
|
496
771
|
literal = static_literal_type(right, scope)
|
|
@@ -850,10 +1125,11 @@ module Rigor
|
|
|
850
1125
|
fully_narrowable = true
|
|
851
1126
|
|
|
852
1127
|
conditions.each do |condition|
|
|
853
|
-
|
|
854
|
-
if
|
|
855
|
-
truthy_members <<
|
|
856
|
-
falsey_type =
|
|
1128
|
+
applied = apply_case_when_condition(scope, current, condition, falsey_type)
|
|
1129
|
+
if applied
|
|
1130
|
+
truthy_members << applied[:truthy]
|
|
1131
|
+
falsey_type = applied[:falsey]
|
|
1132
|
+
fully_narrowable &&= applied[:fully_narrowable]
|
|
857
1133
|
else
|
|
858
1134
|
fully_narrowable = false
|
|
859
1135
|
end
|
|
@@ -866,6 +1142,100 @@ module Rigor
|
|
|
866
1142
|
]
|
|
867
1143
|
end
|
|
868
1144
|
|
|
1145
|
+
# Per-condition rule. Returns `nil` when the condition shape
|
|
1146
|
+
# is not recognised (caller marks `fully_narrowable = false`),
|
|
1147
|
+
# or `{truthy:, falsey:, fully_narrowable:}` when it is.
|
|
1148
|
+
def apply_case_when_condition(scope, current, condition, falsey_acc)
|
|
1149
|
+
int_range = case_equality_integer_range(condition)
|
|
1150
|
+
return integer_range_when_result(current, int_range, falsey_acc) if int_range && integer_rooted_type?(current)
|
|
1151
|
+
|
|
1152
|
+
int_literal = case_equality_integer_literal(condition)
|
|
1153
|
+
if int_literal && integer_rooted_type?(current)
|
|
1154
|
+
return integer_literal_when_result(current, int_literal, falsey_acc)
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
target = static_class_name(condition) || case_equality_target_class(condition)
|
|
1158
|
+
return class_when_result(scope, current, target, falsey_acc) if target
|
|
1159
|
+
|
|
1160
|
+
nil
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def case_equality_integer_literal(condition)
|
|
1164
|
+
condition = unwrap_parens(condition)
|
|
1165
|
+
condition.is_a?(Prism::IntegerNode) ? condition.value : nil
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
def integer_literal_when_result(current, value, falsey_acc)
|
|
1169
|
+
# `case n when k` is `k === n` which for Integer is value
|
|
1170
|
+
# equality. The truthy edge collapses the local to
|
|
1171
|
+
# `Constant[k]`; the falsey edge tightens via
|
|
1172
|
+
# `narrow_integer_not_equal` (only effective when k sits
|
|
1173
|
+
# at one endpoint of the current range).
|
|
1174
|
+
{
|
|
1175
|
+
truthy: narrow_integer_equal(current, value),
|
|
1176
|
+
falsey: narrow_integer_not_equal(falsey_acc, value),
|
|
1177
|
+
fully_narrowable: true
|
|
1178
|
+
}
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def integer_range_when_result(current, range_pair, falsey_acc)
|
|
1182
|
+
low, high = range_pair
|
|
1183
|
+
truthy = narrow_integer_comparison(
|
|
1184
|
+
narrow_integer_comparison(current, :>=, low),
|
|
1185
|
+
:<=, high
|
|
1186
|
+
)
|
|
1187
|
+
# The falsey edge of `n in [a, b]` is two-piece; we cannot
|
|
1188
|
+
# express the complement precisely with a single carrier,
|
|
1189
|
+
# so keep the accumulator unchanged. `fully_narrowable: false`
|
|
1190
|
+
# forces the else-branch to see `current` (the unmodified
|
|
1191
|
+
# entry type), which mirrors `between?` falsey behaviour.
|
|
1192
|
+
{ truthy: truthy, falsey: falsey_acc, fully_narrowable: false }
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
def class_when_result(scope, current, target, falsey_acc)
|
|
1196
|
+
{
|
|
1197
|
+
truthy: narrow_class(current, target, exact: false, environment: scope.environment),
|
|
1198
|
+
falsey: narrow_not_class(falsey_acc, target, exact: false, environment: scope.environment),
|
|
1199
|
+
fully_narrowable: true
|
|
1200
|
+
}
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# Returns `[low, high]` for a `Prism::RangeNode` whose
|
|
1204
|
+
# endpoints are both `Prism::IntegerNode` literals, with
|
|
1205
|
+
# `..`/`...` exclusivity respected. Open-ended ranges use
|
|
1206
|
+
# the symbolic infinities so the existing comparison
|
|
1207
|
+
# narrowing tier handles them. Returns `nil` for any other
|
|
1208
|
+
# shape (Float endpoints, String endpoints, dynamic
|
|
1209
|
+
# expressions).
|
|
1210
|
+
def case_equality_integer_range(condition)
|
|
1211
|
+
condition = unwrap_parens(condition)
|
|
1212
|
+
return nil unless condition.is_a?(Prism::RangeNode)
|
|
1213
|
+
|
|
1214
|
+
low = integer_range_endpoint(condition.left, default: Type::IntegerRange::NEG_INFINITY)
|
|
1215
|
+
high = integer_range_endpoint(condition.right, default: Type::IntegerRange::POS_INFINITY)
|
|
1216
|
+
return nil if low.nil? || high.nil?
|
|
1217
|
+
|
|
1218
|
+
high -= 1 if condition.exclude_end? && high.is_a?(Integer)
|
|
1219
|
+
[low, high]
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
def integer_range_endpoint(node, default:)
|
|
1223
|
+
return default if node.nil?
|
|
1224
|
+
return node.value if node.is_a?(Prism::IntegerNode)
|
|
1225
|
+
|
|
1226
|
+
nil
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
def integer_rooted_type?(type)
|
|
1230
|
+
case type
|
|
1231
|
+
when Type::Constant then type.value.is_a?(Integer)
|
|
1232
|
+
when Type::IntegerRange then true
|
|
1233
|
+
when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
|
|
1234
|
+
when Type::Union then type.members.all? { |m| integer_rooted_type?(m) }
|
|
1235
|
+
else false
|
|
1236
|
+
end
|
|
1237
|
+
end
|
|
1238
|
+
|
|
869
1239
|
def range_target_class(range_node)
|
|
870
1240
|
left = range_node.left
|
|
871
1241
|
right = range_node.right
|
|
@@ -418,11 +418,19 @@ module Rigor
|
|
|
418
418
|
end
|
|
419
419
|
end
|
|
420
420
|
|
|
421
|
+
# v0.0.3 A — sentinel key under which `record_def_node`
|
|
422
|
+
# files DefNodes that live outside any class / module
|
|
423
|
+
# body (top-level helpers, `def`s nested inside DSL
|
|
424
|
+
# blocks like `RSpec.describe ... do; def helper; end`).
|
|
425
|
+
# Looked up by `Scope#top_level_def_for` to give
|
|
426
|
+
# implicit-self calls priority over RBS dispatch when
|
|
427
|
+
# the file defines a same-named local method.
|
|
428
|
+
TOP_LEVEL_DEF_KEY = "<toplevel>"
|
|
429
|
+
|
|
421
430
|
def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
|
|
422
|
-
return if qualified_prefix.empty?
|
|
423
431
|
return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
|
|
424
432
|
|
|
425
|
-
class_name = qualified_prefix.join("::")
|
|
433
|
+
class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
|
|
426
434
|
accumulator[class_name] ||= {}
|
|
427
435
|
accumulator[class_name][def_node.name] = def_node
|
|
428
436
|
end
|
|
@@ -335,7 +335,17 @@ module Rigor
|
|
|
335
335
|
# nil-injection on half-bound names so a name set in one branch
|
|
336
336
|
# but not the other is observable as `T | nil` after the if.
|
|
337
337
|
def eval_if(node)
|
|
338
|
-
|
|
338
|
+
pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
339
|
+
|
|
340
|
+
# When the predicate is a known-truthy / known-falsey type
|
|
341
|
+
# (notably `Constant[true]` / `Constant[false]` after the
|
|
342
|
+
# constant-fold tier), only the live branch contributes a
|
|
343
|
+
# type and a post-scope. The dead branch is skipped so the
|
|
344
|
+
# result type is precise (`Constant[:even]` instead of the
|
|
345
|
+
# joined `Constant[:even] | Constant[:odd]`).
|
|
346
|
+
live = live_branch_for_if(node, pred_type, post_pred)
|
|
347
|
+
return live if live
|
|
348
|
+
|
|
339
349
|
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
340
350
|
then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
|
|
341
351
|
else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
|
|
@@ -360,7 +370,11 @@ module Rigor
|
|
|
360
370
|
# narrower's truthy/falsey edges are routed in swapped form
|
|
361
371
|
# because `unless` runs its body when the predicate is falsey.
|
|
362
372
|
def eval_unless(node)
|
|
363
|
-
|
|
373
|
+
pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
374
|
+
|
|
375
|
+
live = live_branch_for_unless(node, pred_type, post_pred)
|
|
376
|
+
return live if live
|
|
377
|
+
|
|
364
378
|
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
365
379
|
then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
|
|
366
380
|
else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
|
|
@@ -378,6 +392,38 @@ module Rigor
|
|
|
378
392
|
]
|
|
379
393
|
end
|
|
380
394
|
|
|
395
|
+
# Returns the `[type, post_scope]` of the live branch when the
|
|
396
|
+
# predicate is provably truthy / falsey, else nil so the
|
|
397
|
+
# caller falls through to the standard both-branch evaluation.
|
|
398
|
+
# Constant `true`/`false` is the obvious trigger; non-falsey
|
|
399
|
+
# carriers like `Nominal[Integer]` (Integer is always truthy
|
|
400
|
+
# in Ruby — including 0) also collapse the dead else.
|
|
401
|
+
def live_branch_for_if(node, pred_type, post_pred)
|
|
402
|
+
case predicate_certainty(pred_type)
|
|
403
|
+
when :always_truthy then eval_branch_or_nil(node.statements, post_pred)
|
|
404
|
+
when :always_falsey then eval_branch_or_nil(node.subsequent, post_pred)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def live_branch_for_unless(node, pred_type, post_pred)
|
|
409
|
+
case predicate_certainty(pred_type)
|
|
410
|
+
when :always_truthy then eval_branch_or_nil(node.else_clause, post_pred)
|
|
411
|
+
when :always_falsey then eval_branch_or_nil(node.statements, post_pred)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def predicate_certainty(pred_type)
|
|
416
|
+
return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
|
|
417
|
+
|
|
418
|
+
truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
|
|
419
|
+
falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
|
|
420
|
+
|
|
421
|
+
return :always_falsey if truthy_bot && !falsey_bot
|
|
422
|
+
return :always_truthy if !truthy_bot && falsey_bot
|
|
423
|
+
|
|
424
|
+
nil
|
|
425
|
+
end
|
|
426
|
+
|
|
381
427
|
def eval_else(node)
|
|
382
428
|
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
383
429
|
|
|
@@ -606,9 +652,172 @@ module Rigor
|
|
|
606
652
|
evaluate_block_if_present(node)
|
|
607
653
|
post_scope = record_closure_escape_if_any(node)
|
|
608
654
|
post_scope = apply_rbs_extended_assertions(node, post_scope)
|
|
655
|
+
post_scope = apply_rspec_matcher_narrowing(node, post_scope)
|
|
609
656
|
[call_type, post_scope]
|
|
610
657
|
end
|
|
611
658
|
|
|
659
|
+
# v0.0.3 — recognises a small catalogue of RSpec
|
|
660
|
+
# matcher patterns as assert-shaped narrows on the
|
|
661
|
+
# local passed to `expect(...)`. The pattern is
|
|
662
|
+
# matched purely on AST shape; no RBS for RSpec is
|
|
663
|
+
# required (and none is shipped today).
|
|
664
|
+
#
|
|
665
|
+
# Recognised today:
|
|
666
|
+
#
|
|
667
|
+
# expect(x).not_to(be_nil)
|
|
668
|
+
# expect(x).to_not(be_nil)
|
|
669
|
+
# → narrow `x` AWAY from `NilClass`.
|
|
670
|
+
#
|
|
671
|
+
# expect(x).to(be_a(C))
|
|
672
|
+
# expect(x).to(be_kind_of(C))
|
|
673
|
+
# expect(x).to(be_an_instance_of(C))
|
|
674
|
+
# → narrow `x` to `C` (exact for
|
|
675
|
+
# `be_an_instance_of`, subtype-permitting
|
|
676
|
+
# otherwise).
|
|
677
|
+
#
|
|
678
|
+
# Anything else is silently passed through. Symmetric
|
|
679
|
+
# negative class assertions (`not_to be_a(C)`) and
|
|
680
|
+
# narrowing TO `NilClass` are intentionally NOT
|
|
681
|
+
# modelled: they are rarely useful in practice and
|
|
682
|
+
# risk masking bugs if the assertion later fails.
|
|
683
|
+
def apply_rspec_matcher_narrowing(call_node, current_scope)
|
|
684
|
+
narrow = rspec_matcher_narrowing_request(call_node)
|
|
685
|
+
return current_scope if narrow.nil?
|
|
686
|
+
|
|
687
|
+
local_name = narrow.fetch(:local)
|
|
688
|
+
current_type = current_scope.local(local_name)
|
|
689
|
+
return current_scope if current_type.nil?
|
|
690
|
+
|
|
691
|
+
narrowed = apply_rspec_narrow(current_type, narrow, current_scope.environment)
|
|
692
|
+
current_scope.with_local(local_name, narrowed)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Decodes an `expect(x).<chain>` outer call into a
|
|
696
|
+
# narrowing request hash, or `nil` when the shape is
|
|
697
|
+
# not recognised. The hash carries `:local` (the local
|
|
698
|
+
# name being narrowed) plus the narrowing parameters.
|
|
699
|
+
def rspec_matcher_narrowing_request(call_node)
|
|
700
|
+
local_name = rspec_expectation_target(call_node)
|
|
701
|
+
return nil if local_name.nil?
|
|
702
|
+
|
|
703
|
+
case call_node.name
|
|
704
|
+
when :not_to, :to_not
|
|
705
|
+
rspec_negative_narrow(call_node, local_name)
|
|
706
|
+
when :to
|
|
707
|
+
rspec_positive_narrow(call_node, local_name)
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def rspec_negative_narrow(call_node, local_name)
|
|
712
|
+
return nil unless rspec_matcher_argument?(call_node, :be_nil)
|
|
713
|
+
|
|
714
|
+
{ local: local_name, kind: :not_class, class_name: "NilClass", exact: false }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def rspec_positive_narrow(call_node, local_name)
|
|
718
|
+
matcher = rspec_matcher_node(call_node)
|
|
719
|
+
return nil if matcher.nil?
|
|
720
|
+
|
|
721
|
+
case matcher.name
|
|
722
|
+
when :be_a, :be_kind_of
|
|
723
|
+
rspec_be_a_narrow(matcher, local_name, exact: false)
|
|
724
|
+
when :be_an_instance_of, :be_instance_of
|
|
725
|
+
rspec_be_a_narrow(matcher, local_name, exact: true)
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# `be_a` / `be_kind_of` / `be_an_instance_of` accept a
|
|
730
|
+
# single class argument — either a `ConstantReadNode`
|
|
731
|
+
# (`Integer`) or a `ConstantPathNode` (`Rigor::Type::Nominal`).
|
|
732
|
+
def rspec_be_a_narrow(matcher, local_name, exact:)
|
|
733
|
+
args = matcher.arguments&.arguments || []
|
|
734
|
+
return nil unless args.size == 1
|
|
735
|
+
|
|
736
|
+
class_name = constant_node_name(args.first)
|
|
737
|
+
return nil if class_name.nil?
|
|
738
|
+
|
|
739
|
+
{ local: local_name, kind: :class, class_name: class_name, exact: exact }
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def apply_rspec_narrow(current_type, narrow, environment)
|
|
743
|
+
case narrow.fetch(:kind)
|
|
744
|
+
when :not_class
|
|
745
|
+
Narrowing.narrow_not_class(current_type, narrow.fetch(:class_name),
|
|
746
|
+
exact: narrow.fetch(:exact), environment: environment)
|
|
747
|
+
when :class
|
|
748
|
+
Narrowing.narrow_class(current_type, narrow.fetch(:class_name),
|
|
749
|
+
exact: narrow.fetch(:exact), environment: environment)
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Returns the local name passed to `expect(...)` when
|
|
754
|
+
# the receiver chain matches `expect(<local>)` exactly,
|
|
755
|
+
# or nil otherwise. Centralised so each per-matcher
|
|
756
|
+
# decoder can short-circuit on a non-matching outer
|
|
757
|
+
# call.
|
|
758
|
+
def rspec_expectation_target(call_node) # rubocop:disable Metrics/CyclomaticComplexity
|
|
759
|
+
receiver = call_node.receiver
|
|
760
|
+
return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
|
|
761
|
+
return nil unless receiver.receiver.nil?
|
|
762
|
+
|
|
763
|
+
args = receiver.arguments&.arguments || []
|
|
764
|
+
return nil unless args.size == 1
|
|
765
|
+
|
|
766
|
+
target = args.first
|
|
767
|
+
target.is_a?(Prism::LocalVariableReadNode) ? target.name : nil
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def rspec_matcher_node(call_node)
|
|
771
|
+
args = call_node.arguments&.arguments || []
|
|
772
|
+
return nil unless args.size == 1
|
|
773
|
+
|
|
774
|
+
matcher = args.first
|
|
775
|
+
return nil unless matcher.is_a?(Prism::CallNode) && matcher.receiver.nil? && matcher.block.nil?
|
|
776
|
+
|
|
777
|
+
matcher
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# True when `call_node`'s sole argument is an
|
|
781
|
+
# implicit-self matcher call with the given name and
|
|
782
|
+
# no positional arguments — used by the no-arg
|
|
783
|
+
# matchers (`be_nil`).
|
|
784
|
+
def rspec_matcher_argument?(call_node, matcher_name)
|
|
785
|
+
matcher = rspec_matcher_node(call_node)
|
|
786
|
+
return false if matcher.nil?
|
|
787
|
+
return false unless matcher.name == matcher_name
|
|
788
|
+
|
|
789
|
+
matcher.arguments.nil? || matcher.arguments.arguments.empty?
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Decodes a `Prism::ConstantReadNode` /
|
|
793
|
+
# `Prism::ConstantPathNode` into a colon-joined class
|
|
794
|
+
# name string, or returns nil for any other node
|
|
795
|
+
# shape. Mirrors the conservative envelope used by the
|
|
796
|
+
# `is_a?` / `kind_of?` predicate narrower.
|
|
797
|
+
def constant_node_name(node)
|
|
798
|
+
case node
|
|
799
|
+
when Prism::ConstantReadNode
|
|
800
|
+
node.name.to_s
|
|
801
|
+
when Prism::ConstantPathNode
|
|
802
|
+
flatten_constant_path(node)
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def flatten_constant_path(node)
|
|
807
|
+
parts = []
|
|
808
|
+
cursor = node
|
|
809
|
+
while cursor.is_a?(Prism::ConstantPathNode)
|
|
810
|
+
parts.unshift(cursor.name.to_s)
|
|
811
|
+
cursor = cursor.parent
|
|
812
|
+
end
|
|
813
|
+
case cursor
|
|
814
|
+
when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
|
|
815
|
+
when nil then nil # ::Foo absolute root — preserve as-is
|
|
816
|
+
else return nil
|
|
817
|
+
end
|
|
818
|
+
parts.join("::")
|
|
819
|
+
end
|
|
820
|
+
|
|
612
821
|
# v0.0.2 — applies `RBS::Extended` `assert <target> is T`
|
|
613
822
|
# directives to the post-call scope. The conditional
|
|
614
823
|
# variants (`assert-if-true` / `assert-if-false`) are
|