rigortype 0.0.2 → 0.0.4
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 +24 -7
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/hash.yml +936 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/range.yml +389 -0
- data/data/builtins/ruby_core/set.yml +594 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/data/builtins/ruby_core/time.yml +750 -0
- data/lib/rigor/analysis/check_rules.rb +97 -4
- data/lib/rigor/analysis/runner.rb +4 -0
- data/lib/rigor/builtins/imported_refinements.rb +251 -0
- data/lib/rigor/configuration.rb +6 -1
- data/lib/rigor/inference/acceptance.rb +324 -6
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/hash_catalog.rb +40 -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/range_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
- data/lib/rigor/inference/expression_typer.rb +48 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/method_parameter_binder.rb +29 -4
- data/lib/rigor/inference/narrowing.rb +376 -4
- data/lib/rigor/inference/scope_indexer.rb +10 -2
- data/lib/rigor/inference/statement_evaluator.rb +213 -2
- data/lib/rigor/rbs_extended.rb +230 -15
- data/lib/rigor/scope.rb +14 -0
- data/lib/rigor/type/combinator.rb +159 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type/intersection.rb +135 -0
- data/lib/rigor/type/refined.rb +174 -0
- data/lib/rigor/type.rb +4 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +14 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +91 -1
- metadata +25 -1
|
@@ -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
|
|
@@ -685,6 +894,8 @@ module Rigor
|
|
|
685
894
|
end
|
|
686
895
|
|
|
687
896
|
def narrow_for_assert_effect(current_type, effect, environment)
|
|
897
|
+
return effect.refinement_type if effect.refinement?
|
|
898
|
+
|
|
688
899
|
if effect.negative?
|
|
689
900
|
Narrowing.narrow_not_class(current_type, effect.class_name, exact: false, environment: environment)
|
|
690
901
|
else
|
data/lib/rigor/rbs_extended.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "type"
|
|
4
|
+
require_relative "builtins/imported_refinements"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
# Slice 7 phase 15 — first-preview reader for the
|
|
@@ -40,7 +41,7 @@ module Rigor
|
|
|
40
41
|
# `::Foo::Bar` style constant path. Negative refinements
|
|
41
42
|
# (`~T`), intersections, and unions are deferred to the
|
|
42
43
|
# next iteration.
|
|
43
|
-
module RbsExtended
|
|
44
|
+
module RbsExtended # rubocop:disable Metrics/ModuleLength
|
|
44
45
|
DIRECTIVE_PREFIX = "rigor:v1:"
|
|
45
46
|
|
|
46
47
|
# Returned for `predicate-if-true` / `predicate-if-false`.
|
|
@@ -49,10 +50,19 @@ module Rigor
|
|
|
49
50
|
# when the directive uses the `~ClassName` form, in
|
|
50
51
|
# which case the engine narrows AWAY from `class_name`
|
|
51
52
|
# (`Narrowing.narrow_not_class`) instead of toward it.
|
|
52
|
-
|
|
53
|
+
#
|
|
54
|
+
# `refinement_type` is non-nil when the right-hand side is
|
|
55
|
+
# a kebab-case refinement name (`non-empty-string`,
|
|
56
|
+
# `lowercase-string`, …) instead of a Capitalised class
|
|
57
|
+
# name. The narrowing tier substitutes the carrier for the
|
|
58
|
+
# current local type; `class_name` is then nil and
|
|
59
|
+
# `negative` is false (refinement-form directives do not
|
|
60
|
+
# support `~T` negation in v0.0.4).
|
|
61
|
+
PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
|
|
53
62
|
def truthy_only? = edge == :truthy_only
|
|
54
63
|
def falsey_only? = edge == :falsey_only
|
|
55
64
|
def negative? = negative == true
|
|
65
|
+
def refinement? = !refinement_type.nil?
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
# Returned for `assert` / `assert-if-true` /
|
|
@@ -70,11 +80,12 @@ module Rigor
|
|
|
70
80
|
#
|
|
71
81
|
# `negative` mirrors `PredicateEffect`: true when the
|
|
72
82
|
# directive uses `~ClassName` syntax.
|
|
73
|
-
AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative) do
|
|
83
|
+
AssertEffect = Data.define(:condition, :target_kind, :target_name, :class_name, :negative, :refinement_type) do
|
|
74
84
|
def always? = condition == :always
|
|
75
85
|
def if_truthy_return? = condition == :if_truthy_return
|
|
76
86
|
def if_falsey_return? = condition == :if_falsey_return
|
|
77
87
|
def negative? = negative == true
|
|
88
|
+
def refinement? = !refinement_type.nil?
|
|
78
89
|
end
|
|
79
90
|
|
|
80
91
|
module_function
|
|
@@ -99,14 +110,26 @@ module Rigor
|
|
|
99
110
|
effects.uniq
|
|
100
111
|
end
|
|
101
112
|
|
|
113
|
+
# The right-hand side accepts either a Capitalised class
|
|
114
|
+
# name (with optional `~` negation, optional `::` prefix,
|
|
115
|
+
# qualified names) OR a kebab-case refinement payload
|
|
116
|
+
# routed through `Builtins::ImportedRefinements::Parser`
|
|
117
|
+
# (bare names, `name[T]`, `name<min, max>`). The two arms
|
|
118
|
+
# share the same overall directive shape; the parser
|
|
119
|
+
# detects which form matched by looking at the `class_name`
|
|
120
|
+
# vs `refinement` capture groups.
|
|
102
121
|
PREDICATE_DIRECTIVE_PATTERN = /
|
|
103
122
|
\A
|
|
104
123
|
rigor:v1:(?<directive>predicate-if-(?:true|false))
|
|
105
124
|
\s+
|
|
106
125
|
(?<target>self|[a-z_][a-zA-Z0-9_]*)
|
|
107
126
|
\s+is\s+
|
|
108
|
-
(
|
|
109
|
-
|
|
127
|
+
(?:
|
|
128
|
+
(?<negation>~?)
|
|
129
|
+
(?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
|
|
130
|
+
|
|
|
131
|
+
(?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
|
|
132
|
+
)
|
|
110
133
|
\s*
|
|
111
134
|
\z
|
|
112
135
|
/x
|
|
@@ -118,16 +141,18 @@ module Rigor
|
|
|
118
141
|
|
|
119
142
|
directive = match[:directive].to_s
|
|
120
143
|
target = match[:target].to_s
|
|
121
|
-
class_name = match[:class_name].to_s.sub(/\A::/, "")
|
|
122
144
|
edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
|
|
123
|
-
target_kind = target
|
|
124
|
-
|
|
145
|
+
target_kind, target_name = target_fields(target)
|
|
146
|
+
class_name, refinement_type, negative = resolve_directive_rhs(match)
|
|
147
|
+
return nil if class_name.nil? && refinement_type.nil?
|
|
148
|
+
|
|
125
149
|
PredicateEffect.new(
|
|
126
150
|
edge: edge,
|
|
127
151
|
target_kind: target_kind,
|
|
128
152
|
target_name: target_name,
|
|
129
153
|
class_name: class_name,
|
|
130
|
-
negative:
|
|
154
|
+
negative: negative,
|
|
155
|
+
refinement_type: refinement_type
|
|
131
156
|
)
|
|
132
157
|
end
|
|
133
158
|
|
|
@@ -156,8 +181,12 @@ module Rigor
|
|
|
156
181
|
\s+
|
|
157
182
|
(?<target>self|[a-z_][a-zA-Z0-9_]*)
|
|
158
183
|
\s+is\s+
|
|
159
|
-
(
|
|
160
|
-
|
|
184
|
+
(?:
|
|
185
|
+
(?<negation>~?)
|
|
186
|
+
(?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
|
|
187
|
+
|
|
|
188
|
+
(?<refinement>[a-z][a-z0-9-]*(?:[\[<][^\]>]*[\]>])?)
|
|
189
|
+
)
|
|
161
190
|
\s*
|
|
162
191
|
\z
|
|
163
192
|
/x
|
|
@@ -179,16 +208,202 @@ module Rigor
|
|
|
179
208
|
return nil if condition.nil?
|
|
180
209
|
|
|
181
210
|
target = match[:target].to_s
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
target_kind, target_name = target_fields(target)
|
|
212
|
+
class_name, refinement_type, negative = resolve_directive_rhs(match)
|
|
213
|
+
return nil if class_name.nil? && refinement_type.nil?
|
|
214
|
+
|
|
185
215
|
AssertEffect.new(
|
|
186
216
|
condition: condition,
|
|
187
217
|
target_kind: target_kind,
|
|
188
218
|
target_name: target_name,
|
|
189
219
|
class_name: class_name,
|
|
190
|
-
negative:
|
|
220
|
+
negative: negative,
|
|
221
|
+
refinement_type: refinement_type
|
|
191
222
|
)
|
|
192
223
|
end
|
|
224
|
+
|
|
225
|
+
# Resolves the `class_name` / `refinement` alternation in
|
|
226
|
+
# the assert / predicate directive patterns. Returns
|
|
227
|
+
# `[class_name, refinement_type, negative]`:
|
|
228
|
+
#
|
|
229
|
+
# - Class-name arm matched: `class_name` is the resolved
|
|
230
|
+
# string (leading `::` stripped), `refinement_type` is
|
|
231
|
+
# nil, `negative` reflects the optional `~` prefix.
|
|
232
|
+
# - Refinement arm matched: `class_name` is nil,
|
|
233
|
+
# `refinement_type` is the resolved `Rigor::Type`,
|
|
234
|
+
# `negative` is `false` (refinement-form directives do
|
|
235
|
+
# not support `~` negation in v0.0.4).
|
|
236
|
+
# - Refinement payload unparseable: returns
|
|
237
|
+
# `[nil, nil, false]` so callers can drop the directive
|
|
238
|
+
# silently (fail-soft policy).
|
|
239
|
+
def resolve_directive_rhs(match)
|
|
240
|
+
class_capture = match[:class_name]
|
|
241
|
+
return [class_capture.to_s.sub(/\A::/, ""), nil, match[:negation].to_s == "~"] if class_capture
|
|
242
|
+
|
|
243
|
+
refinement_capture = match[:refinement]
|
|
244
|
+
return [nil, nil, false] if refinement_capture.nil?
|
|
245
|
+
|
|
246
|
+
type = Builtins::ImportedRefinements.parse(refinement_capture)
|
|
247
|
+
return [nil, nil, false] if type.nil?
|
|
248
|
+
|
|
249
|
+
[nil, type, false]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def target_fields(target)
|
|
253
|
+
if target == "self"
|
|
254
|
+
%i[self self]
|
|
255
|
+
else
|
|
256
|
+
[:parameter, target.to_sym]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Reads the `rigor:v1:return: <kebab-name>` directive off
|
|
261
|
+
# `RBS::Definition::Method#annotations`. The directive
|
|
262
|
+
# overrides a method's RBS-declared return type with one of
|
|
263
|
+
# the imported-built-in refinements registered in
|
|
264
|
+
# `Rigor::Builtins::ImportedRefinements`. The override is the
|
|
265
|
+
# primary integration path for refinement carriers
|
|
266
|
+
# (`non-empty-string`, `positive-int`, `non-empty-array`, …)
|
|
267
|
+
# in v0.0 — annotation-driven, opt-in per method, and never
|
|
268
|
+
# silently rewrites a hand-authored RBS signature outside the
|
|
269
|
+
# annotation.
|
|
270
|
+
#
|
|
271
|
+
# Example annotation in an RBS file:
|
|
272
|
+
#
|
|
273
|
+
# class User
|
|
274
|
+
# %a{rigor:v1:return: non-empty-string}
|
|
275
|
+
# def name: () -> String
|
|
276
|
+
# end
|
|
277
|
+
#
|
|
278
|
+
# The RBS-declared return is `String`. The override
|
|
279
|
+
# tightens it to `non-empty-string` (i.e.
|
|
280
|
+
# `Difference[String, ""]`) for callers; RBS erasure of the
|
|
281
|
+
# tightened return goes back to `String` so the round-trip
|
|
282
|
+
# to ordinary RBS is unaffected.
|
|
283
|
+
#
|
|
284
|
+
# Returns the resolved `Rigor::Type` value, or `nil` when:
|
|
285
|
+
# - the method has no annotations,
|
|
286
|
+
# - none of the annotations match the `rigor:v1:return:`
|
|
287
|
+
# directive,
|
|
288
|
+
# - the directive's payload names a refinement not
|
|
289
|
+
# registered in `Rigor::Builtins::ImportedRefinements`
|
|
290
|
+
# (the analyzer prefers a silent miss over crashing on a
|
|
291
|
+
# typo; future slices MAY surface the miss as a
|
|
292
|
+
# `:warning` self-diagnostic).
|
|
293
|
+
def read_return_type_override(method_def)
|
|
294
|
+
return nil if method_def.nil?
|
|
295
|
+
|
|
296
|
+
annotations = method_def.annotations
|
|
297
|
+
return nil if annotations.nil? || annotations.empty?
|
|
298
|
+
|
|
299
|
+
annotations.each do |annotation|
|
|
300
|
+
type = parse_return_type_override(annotation.string)
|
|
301
|
+
return type if type
|
|
302
|
+
end
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The trailing payload supports the full refinement
|
|
307
|
+
# grammar in `Builtins::ImportedRefinements::Parser` —
|
|
308
|
+
# bare kebab-case names plus parameterised forms like
|
|
309
|
+
# `non-empty-array[Integer]`, `non-empty-hash[Symbol,
|
|
310
|
+
# Integer]`, and `int<5, 10>`. The directive head is
|
|
311
|
+
# consumed by the regex; the rest is forwarded to the
|
|
312
|
+
# refinement parser. Anything the parser cannot resolve
|
|
313
|
+
# falls back to nil so the call site keeps the
|
|
314
|
+
# RBS-declared return type.
|
|
315
|
+
RETURN_DIRECTIVE_PATTERN = /
|
|
316
|
+
\A
|
|
317
|
+
rigor:v1:return:
|
|
318
|
+
\s+
|
|
319
|
+
(?<payload>\S(?:.*\S)?)
|
|
320
|
+
\s*
|
|
321
|
+
\z
|
|
322
|
+
/x
|
|
323
|
+
private_constant :RETURN_DIRECTIVE_PATTERN
|
|
324
|
+
|
|
325
|
+
def parse_return_type_override(string)
|
|
326
|
+
match = RETURN_DIRECTIVE_PATTERN.match(string)
|
|
327
|
+
return nil if match.nil?
|
|
328
|
+
|
|
329
|
+
Builtins::ImportedRefinements.parse(match[:payload])
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Returned for `rigor:v1:param: <name> <refinement>`. The
|
|
333
|
+
# parameter name is a Ruby identifier (Symbol); the type
|
|
334
|
+
# is any `Rigor::Type` the refinement parser resolves
|
|
335
|
+
# (bare kebab-case name, parameterised form, or `int<...>`
|
|
336
|
+
# range — the same grammar the `return:` directive
|
|
337
|
+
# accepts).
|
|
338
|
+
ParamOverride = Data.define(:param_name, :type)
|
|
339
|
+
|
|
340
|
+
# Reads every `rigor:v1:param: <name> <refinement>`
|
|
341
|
+
# directive off `RBS::Definition::Method#annotations` and
|
|
342
|
+
# returns the resolved `ParamOverride` list. Annotations
|
|
343
|
+
# the parser cannot resolve (typo, unknown refinement, no
|
|
344
|
+
# `param:` directive at all) are silently dropped — the
|
|
345
|
+
# call site keeps the RBS-declared parameter type for
|
|
346
|
+
# those parameters. The reader accepts a nil method
|
|
347
|
+
# definition so call sites can pass through optional
|
|
348
|
+
# method lookups without a guard.
|
|
349
|
+
#
|
|
350
|
+
# Example annotation in an RBS file:
|
|
351
|
+
#
|
|
352
|
+
# class Slug
|
|
353
|
+
# %a{rigor:v1:param: id is non-empty-string}
|
|
354
|
+
# def normalise: (::String id) -> String
|
|
355
|
+
# end
|
|
356
|
+
#
|
|
357
|
+
# The RBS-declared type of `id` is `String`. The override
|
|
358
|
+
# tightens it to `non-empty-string` for argument-check
|
|
359
|
+
# purposes; passing a too-wide `Nominal[String]` argument
|
|
360
|
+
# is flagged as an argument-type mismatch at the call
|
|
361
|
+
# site.
|
|
362
|
+
def read_param_type_overrides(method_def)
|
|
363
|
+
return [] if method_def.nil?
|
|
364
|
+
|
|
365
|
+
annotations = method_def.annotations
|
|
366
|
+
return [] if annotations.nil? || annotations.empty?
|
|
367
|
+
|
|
368
|
+
annotations.filter_map { |annotation| parse_param_annotation(annotation.string) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Convenience reader for call sites that want to look up
|
|
372
|
+
# a single override by parameter name. Returns a frozen
|
|
373
|
+
# Hash<Symbol, Rigor::Type>; missing keys mean "use the
|
|
374
|
+
# RBS-declared type". Callers MUST treat the hash as
|
|
375
|
+
# read-only.
|
|
376
|
+
def param_type_override_map(method_def)
|
|
377
|
+
read_param_type_overrides(method_def).to_h { |o| [o.param_name, o.type] }.freeze
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# The `is` glue word is optional so authors can write
|
|
381
|
+
# either `param: id is non-empty-string` (consistent with
|
|
382
|
+
# the existing `assert` / `predicate-if-*` directives) or
|
|
383
|
+
# the terser `param: id non-empty-string`. The trailing
|
|
384
|
+
# payload accepts the full refinement grammar in
|
|
385
|
+
# `Builtins::ImportedRefinements::Parser`.
|
|
386
|
+
PARAM_DIRECTIVE_PATTERN = /
|
|
387
|
+
\A
|
|
388
|
+
rigor:v1:param:
|
|
389
|
+
\s+
|
|
390
|
+
(?<param>[a-z_][a-zA-Z0-9_]*)
|
|
391
|
+
\s+
|
|
392
|
+
(?:is\s+)?
|
|
393
|
+
(?<payload>\S(?:.*\S)?)
|
|
394
|
+
\s*
|
|
395
|
+
\z
|
|
396
|
+
/x
|
|
397
|
+
private_constant :PARAM_DIRECTIVE_PATTERN
|
|
398
|
+
|
|
399
|
+
def parse_param_annotation(string)
|
|
400
|
+
match = PARAM_DIRECTIVE_PATTERN.match(string)
|
|
401
|
+
return nil if match.nil?
|
|
402
|
+
|
|
403
|
+
type = Builtins::ImportedRefinements.parse(match[:payload])
|
|
404
|
+
return nil if type.nil?
|
|
405
|
+
|
|
406
|
+
ParamOverride.new(param_name: match[:param].to_sym, type: type)
|
|
407
|
+
end
|
|
193
408
|
end
|
|
194
409
|
end
|
data/lib/rigor/scope.rb
CHANGED
|
@@ -250,6 +250,20 @@ module Rigor
|
|
|
250
250
|
table[method_name.to_sym]
|
|
251
251
|
end
|
|
252
252
|
|
|
253
|
+
# v0.0.3 A — top-level def lookup for implicit-self
|
|
254
|
+
# calls. Returns the `Prism::DefNode` for a top-level
|
|
255
|
+
# (or DSL-block-nested, outside any class body) `def
|
|
256
|
+
# <method_name>` in the file, or nil. The sentinel key
|
|
257
|
+
# is owned by `Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY`;
|
|
258
|
+
# consumers should treat its presence as an opaque
|
|
259
|
+
# implementation detail and go through this accessor.
|
|
260
|
+
def top_level_def_for(method_name)
|
|
261
|
+
table = @discovered_def_nodes[Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
|
|
262
|
+
return nil unless table
|
|
263
|
+
|
|
264
|
+
table[method_name.to_sym]
|
|
265
|
+
end
|
|
266
|
+
|
|
253
267
|
def with_discovered_def_nodes(table)
|
|
254
268
|
rebuild(discovered_def_nodes: table)
|
|
255
269
|
end
|