rigortype 0.0.1
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 +7 -0
- data/LICENSE +373 -0
- data/README.md +152 -0
- data/exe/rigor +9 -0
- data/lib/rigor/analysis/check_rules.rb +503 -0
- data/lib/rigor/analysis/diagnostic.rb +35 -0
- data/lib/rigor/analysis/fact_store.rb +133 -0
- data/lib/rigor/analysis/result.rb +29 -0
- data/lib/rigor/analysis/runner.rb +119 -0
- data/lib/rigor/ast/type_node.rb +41 -0
- data/lib/rigor/ast.rb +22 -0
- data/lib/rigor/cli/type_of_command.rb +160 -0
- data/lib/rigor/cli/type_of_renderer.rb +88 -0
- data/lib/rigor/cli/type_scan_command.rb +160 -0
- data/lib/rigor/cli/type_scan_renderer.rb +165 -0
- data/lib/rigor/cli/type_scan_report.rb +32 -0
- data/lib/rigor/cli.rb +195 -0
- data/lib/rigor/configuration.rb +49 -0
- data/lib/rigor/environment/class_registry.rb +141 -0
- data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
- data/lib/rigor/environment/rbs_loader.rb +244 -0
- data/lib/rigor/environment.rb +177 -0
- data/lib/rigor/inference/acceptance.rb +444 -0
- data/lib/rigor/inference/block_parameter_binder.rb +198 -0
- data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
- data/lib/rigor/inference/coverage_scanner.rb +85 -0
- data/lib/rigor/inference/expression_typer.rb +831 -0
- data/lib/rigor/inference/fallback.rb +35 -0
- data/lib/rigor/inference/fallback_tracer.rb +64 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
- data/lib/rigor/inference/method_dispatcher.rb +213 -0
- data/lib/rigor/inference/method_parameter_binder.rb +257 -0
- data/lib/rigor/inference/multi_target_binder.rb +143 -0
- data/lib/rigor/inference/narrowing.rb +1008 -0
- data/lib/rigor/inference/rbs_type_translator.rb +219 -0
- data/lib/rigor/inference/scope_indexer.rb +468 -0
- data/lib/rigor/inference/statement_evaluator.rb +1017 -0
- data/lib/rigor/rbs_extended.rb +98 -0
- data/lib/rigor/scope.rb +340 -0
- data/lib/rigor/source/node_locator.rb +104 -0
- data/lib/rigor/source/node_walker.rb +37 -0
- data/lib/rigor/source.rb +15 -0
- data/lib/rigor/testing.rb +65 -0
- data/lib/rigor/trinary.rb +108 -0
- data/lib/rigor/type/accepts_result.rb +109 -0
- data/lib/rigor/type/bot.rb +57 -0
- data/lib/rigor/type/combinator.rb +148 -0
- data/lib/rigor/type/constant.rb +90 -0
- data/lib/rigor/type/dynamic.rb +60 -0
- data/lib/rigor/type/hash_shape.rb +246 -0
- data/lib/rigor/type/nominal.rb +83 -0
- data/lib/rigor/type/singleton.rb +65 -0
- data/lib/rigor/type/top.rb +56 -0
- data/lib/rigor/type/tuple.rb +84 -0
- data/lib/rigor/type/union.rb +65 -0
- data/lib/rigor/type.rb +23 -0
- data/lib/rigor/version.rb +5 -0
- data/lib/rigor.rb +29 -0
- data/sig/rigor/analysis/fact_store.rbs +51 -0
- data/sig/rigor/ast.rbs +11 -0
- data/sig/rigor/environment.rbs +59 -0
- data/sig/rigor/inference.rbs +151 -0
- data/sig/rigor/rbs_extended.rbs +22 -0
- data/sig/rigor/scope.rbs +49 -0
- data/sig/rigor/source.rbs +20 -0
- data/sig/rigor/testing.rbs +9 -0
- data/sig/rigor/trinary.rbs +29 -0
- data/sig/rigor/type.rbs +171 -0
- data/sig/rigor.rbs +70 -0
- metadata +260 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "type"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
# Slice 7 phase 15 — first-preview reader for the
|
|
7
|
+
# `RBS::Extended` annotation surface described in
|
|
8
|
+
# `docs/type-specification/rbs-extended.md`.
|
|
9
|
+
#
|
|
10
|
+
# This module reads `%a{rigor:v1:<directive> <payload>}`
|
|
11
|
+
# annotations off RBS method definitions and returns
|
|
12
|
+
# well-typed effect objects the inference engine can
|
|
13
|
+
# consume. The first preview ships only the **type
|
|
14
|
+
# predicate** directives:
|
|
15
|
+
#
|
|
16
|
+
# - `rigor:v1:predicate-if-true <target> is <ClassName>`
|
|
17
|
+
# - `rigor:v1:predicate-if-false <target> is <ClassName>`
|
|
18
|
+
#
|
|
19
|
+
# Other directives in the spec (`assert`, `assert-if-true`,
|
|
20
|
+
# `assert-if-false`, `param`, `return`, `conforms-to`, ...)
|
|
21
|
+
# are intentionally deferred. Annotations whose key is in
|
|
22
|
+
# the `rigor:v1:` namespace but whose directive is
|
|
23
|
+
# unrecognised are silently ignored at first-preview
|
|
24
|
+
# quality (a future slice MAY surface them as
|
|
25
|
+
# diagnostics-on-Rigor-itself per the spec's "unsupported
|
|
26
|
+
# metadata" guidance).
|
|
27
|
+
#
|
|
28
|
+
# The parser is minimal: it accepts a strict shape
|
|
29
|
+
# `<target> is <ClassName>` where `<target>` is a Ruby
|
|
30
|
+
# identifier (parameter name) or `self`, and `<ClassName>`
|
|
31
|
+
# is a single non-namespaced class identifier or a
|
|
32
|
+
# `::Foo::Bar` style constant path. Negative refinements
|
|
33
|
+
# (`~T`), intersections, and unions are deferred to the
|
|
34
|
+
# next iteration.
|
|
35
|
+
module RbsExtended
|
|
36
|
+
DIRECTIVE_PREFIX = "rigor:v1:"
|
|
37
|
+
|
|
38
|
+
# Returned for `predicate-if-true` / `predicate-if-false`.
|
|
39
|
+
# `target_kind` is `:parameter` (with `target_name` the
|
|
40
|
+
# Ruby parameter symbol) or `:self`.
|
|
41
|
+
PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name) do
|
|
42
|
+
def truthy_only? = edge == :truthy_only
|
|
43
|
+
def falsey_only? = edge == :falsey_only
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# Reads RBS::Extended predicate effects off
|
|
49
|
+
# `RBS::Definition::Method#annotations`. Returns the
|
|
50
|
+
# effects in source order; duplicates and unrecognised
|
|
51
|
+
# `rigor:v1:` directives are dropped. Returns an empty
|
|
52
|
+
# array (NEVER `nil`) for a method with no recognised
|
|
53
|
+
# annotations so callers can iterate unconditionally.
|
|
54
|
+
def read_predicate_effects(method_def)
|
|
55
|
+
return [] if method_def.nil?
|
|
56
|
+
|
|
57
|
+
annotations = method_def.annotations
|
|
58
|
+
return [] if annotations.nil? || annotations.empty?
|
|
59
|
+
|
|
60
|
+
effects = []
|
|
61
|
+
annotations.each do |annotation|
|
|
62
|
+
effect = parse_predicate_annotation(annotation.string)
|
|
63
|
+
effects << effect if effect
|
|
64
|
+
end
|
|
65
|
+
effects.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
PREDICATE_DIRECTIVE_PATTERN = /
|
|
69
|
+
\A
|
|
70
|
+
rigor:v1:(?<directive>predicate-if-(?:true|false))
|
|
71
|
+
\s+
|
|
72
|
+
(?<target>self|[a-z_][a-zA-Z0-9_]*)
|
|
73
|
+
\s+is\s+
|
|
74
|
+
(?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
|
|
75
|
+
\s*
|
|
76
|
+
\z
|
|
77
|
+
/x
|
|
78
|
+
private_constant :PREDICATE_DIRECTIVE_PATTERN
|
|
79
|
+
|
|
80
|
+
def parse_predicate_annotation(string)
|
|
81
|
+
match = PREDICATE_DIRECTIVE_PATTERN.match(string)
|
|
82
|
+
return nil if match.nil?
|
|
83
|
+
|
|
84
|
+
directive = match[:directive].to_s
|
|
85
|
+
target = match[:target].to_s
|
|
86
|
+
class_name = match[:class_name].to_s.sub(/\A::/, "")
|
|
87
|
+
edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
|
|
88
|
+
target_kind = target == "self" ? :self : :parameter
|
|
89
|
+
target_name = target == "self" ? :self : target.to_sym
|
|
90
|
+
PredicateEffect.new(
|
|
91
|
+
edge: edge,
|
|
92
|
+
target_kind: target_kind,
|
|
93
|
+
target_name: target_name,
|
|
94
|
+
class_name: class_name
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/rigor/scope.rb
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "type"
|
|
4
|
+
require_relative "environment"
|
|
5
|
+
require_relative "analysis/fact_store"
|
|
6
|
+
require_relative "inference/expression_typer"
|
|
7
|
+
require_relative "inference/statement_evaluator"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
# Immutable analyzer scope: holds local-variable bindings and a reference
|
|
11
|
+
# to the surrounding Environment. State changes return new scopes through
|
|
12
|
+
# explicit transition methods (#with_local). The central query is
|
|
13
|
+
# #type_of(node), the Rigor counterpart of PHPStan's
|
|
14
|
+
# $scope->getType($node).
|
|
15
|
+
#
|
|
16
|
+
# See docs/internal-spec/inference-engine.md for the binding contract.
|
|
17
|
+
# rubocop:disable Metrics/ClassLength,Metrics/ParameterLists
|
|
18
|
+
class Scope
|
|
19
|
+
attr_reader :environment, :locals, :fact_store, :self_type, :declared_types,
|
|
20
|
+
:ivars, :cvars, :globals,
|
|
21
|
+
:class_ivars, :class_cvars, :program_globals,
|
|
22
|
+
:discovered_classes, :in_source_constants, :discovered_methods
|
|
23
|
+
|
|
24
|
+
EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
|
|
25
|
+
EMPTY_VAR_BINDINGS = {}.freeze
|
|
26
|
+
EMPTY_CLASS_BINDINGS = {}.freeze
|
|
27
|
+
private_constant :EMPTY_DECLARED_TYPES, :EMPTY_VAR_BINDINGS, :EMPTY_CLASS_BINDINGS
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def empty(environment: Environment.default)
|
|
31
|
+
new(environment: environment, locals: {}.freeze, fact_store: Analysis::FactStore.empty)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(
|
|
36
|
+
environment:, locals:,
|
|
37
|
+
fact_store: Analysis::FactStore.empty,
|
|
38
|
+
self_type: nil,
|
|
39
|
+
declared_types: EMPTY_DECLARED_TYPES,
|
|
40
|
+
ivars: EMPTY_VAR_BINDINGS,
|
|
41
|
+
cvars: EMPTY_VAR_BINDINGS,
|
|
42
|
+
globals: EMPTY_VAR_BINDINGS,
|
|
43
|
+
class_ivars: EMPTY_CLASS_BINDINGS,
|
|
44
|
+
class_cvars: EMPTY_CLASS_BINDINGS,
|
|
45
|
+
program_globals: EMPTY_VAR_BINDINGS,
|
|
46
|
+
discovered_classes: EMPTY_VAR_BINDINGS,
|
|
47
|
+
in_source_constants: EMPTY_VAR_BINDINGS,
|
|
48
|
+
discovered_methods: EMPTY_CLASS_BINDINGS
|
|
49
|
+
)
|
|
50
|
+
@environment = environment
|
|
51
|
+
@locals = locals
|
|
52
|
+
@fact_store = fact_store
|
|
53
|
+
@self_type = self_type
|
|
54
|
+
@declared_types = declared_types
|
|
55
|
+
@ivars = ivars
|
|
56
|
+
@cvars = cvars
|
|
57
|
+
@globals = globals
|
|
58
|
+
@class_ivars = class_ivars
|
|
59
|
+
@class_cvars = class_cvars
|
|
60
|
+
@program_globals = program_globals
|
|
61
|
+
@discovered_classes = discovered_classes
|
|
62
|
+
@in_source_constants = in_source_constants
|
|
63
|
+
@discovered_methods = discovered_methods
|
|
64
|
+
freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def local(name)
|
|
68
|
+
@locals[name.to_sym]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def with_local(name, type)
|
|
72
|
+
new_locals = @locals.merge(name.to_sym => type).freeze
|
|
73
|
+
new_fact_store = fact_store.invalidate_target(Analysis::FactStore::Target.local(name))
|
|
74
|
+
rebuild(locals: new_locals, fact_store: new_fact_store)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def with_fact(fact)
|
|
78
|
+
rebuild(fact_store: fact_store.with_fact(fact))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Slice A-engine. Returns a scope with `self_type` set to `type`,
|
|
82
|
+
# preserving locals and facts. `StatementEvaluator` injects this
|
|
83
|
+
# at class-body and method-body boundaries; `ExpressionTyper`
|
|
84
|
+
# consults it when typing `Prism::SelfNode` and implicit-self
|
|
85
|
+
# `Prism::CallNode` receivers.
|
|
86
|
+
def with_self_type(type)
|
|
87
|
+
rebuild(self_type: type)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Slice A-declarations. Returns a scope that carries an
|
|
91
|
+
# identity-comparing Hash of `Prism::Node => Rigor::Type`
|
|
92
|
+
# overrides. `ExpressionTyper#type_of(node)` MUST consult
|
|
93
|
+
# `declared_types[node]` before any other dispatch and
|
|
94
|
+
# return the recorded type as-is when present. The table is
|
|
95
|
+
# populated by `ScopeIndexer` for declaration-position
|
|
96
|
+
# nodes (the `constant_path` of `Prism::ModuleNode` and
|
|
97
|
+
# `Prism::ClassNode`) so a `module Foo` / `class Bar`
|
|
98
|
+
# header types as `Singleton[<qualified path>]` instead of
|
|
99
|
+
# falling through to `Dynamic[Top]`. The table is shared
|
|
100
|
+
# by structural reference across every derived scope so
|
|
101
|
+
# `with_local` / `with_fact` / `with_self_type` carry it
|
|
102
|
+
# transparently.
|
|
103
|
+
def with_declared_types(table)
|
|
104
|
+
rebuild(declared_types: table)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Slice 7 phase 1 — instance/class/global variable bindings.
|
|
108
|
+
# `ivar(name)` / `cvar(name)` / `global(name)` return the
|
|
109
|
+
# type currently bound for the named variable, or `nil` when
|
|
110
|
+
# the variable has not been written in the analyzed slice of
|
|
111
|
+
# the program. The first cut tracks bindings only within a
|
|
112
|
+
# single method body (each `def` enters with a fresh binding
|
|
113
|
+
# map), so reads in other methods of the same class fall
|
|
114
|
+
# through to `Dynamic[Top]`. Cross-method ivar/cvar inference
|
|
115
|
+
# is a follow-up slice.
|
|
116
|
+
def ivar(name)
|
|
117
|
+
@ivars[name.to_sym]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cvar(name)
|
|
121
|
+
@cvars[name.to_sym]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def global(name)
|
|
125
|
+
@globals[name.to_sym]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def with_ivar(name, type)
|
|
129
|
+
rebuild(ivars: @ivars.merge(name.to_sym => type).freeze)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def with_cvar(name, type)
|
|
133
|
+
rebuild(cvars: @cvars.merge(name.to_sym => type).freeze)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def with_global(name, type)
|
|
137
|
+
rebuild(globals: @globals.merge(name.to_sym => type).freeze)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Slice 7 phase 2 — class-level ivar accumulator. Keyed by
|
|
141
|
+
# the qualified class name (e.g. `"Rigor::Scope"`); the
|
|
142
|
+
# value is a `Hash[Symbol, Type::t]` of every ivar that
|
|
143
|
+
# appears as a write target inside any def body of that
|
|
144
|
+
# class. `StatementEvaluator#build_method_entry_scope`
|
|
145
|
+
# seeds the method body's `ivars` map from this table so a
|
|
146
|
+
# `def get; @x; end` reads the type written in a sibling
|
|
147
|
+
# `def init; @x = 1; end`.
|
|
148
|
+
#
|
|
149
|
+
# `ScopeIndexer` populates the table once at index time
|
|
150
|
+
# through a separate pre-pass over the program. The map is
|
|
151
|
+
# frozen and shared by structural reference across every
|
|
152
|
+
# derived scope.
|
|
153
|
+
def class_ivars_for(class_name)
|
|
154
|
+
return EMPTY_VAR_BINDINGS if class_name.nil?
|
|
155
|
+
|
|
156
|
+
@class_ivars[class_name.to_s] || EMPTY_VAR_BINDINGS
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def with_class_ivars(table)
|
|
160
|
+
rebuild(class_ivars: table)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Slice 7 phase 6 — class-level cvar accumulator (same shape
|
|
164
|
+
# as `class_ivars` but populated from `Prism::ClassVariableWriteNode`
|
|
165
|
+
# writes, and seeded on BOTH instance and singleton method
|
|
166
|
+
# bodies because Ruby cvars are visible from each).
|
|
167
|
+
def class_cvars_for(class_name)
|
|
168
|
+
return EMPTY_VAR_BINDINGS if class_name.nil?
|
|
169
|
+
|
|
170
|
+
@class_cvars[class_name.to_s] || EMPTY_VAR_BINDINGS
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def with_class_cvars(table)
|
|
174
|
+
rebuild(class_cvars: table)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Slice 7 phase 6 — program-level globals accumulator.
|
|
178
|
+
# Globals are process-wide in Ruby, so the analyzer carries a
|
|
179
|
+
# single map (`Hash[Symbol, Type]`) keyed by the variable name
|
|
180
|
+
# and seeded into every method body (instance and singleton)
|
|
181
|
+
# plus the top-level program scope. `ScopeIndexer` populates
|
|
182
|
+
# it from a single program-wide pre-pass.
|
|
183
|
+
def with_program_globals(table)
|
|
184
|
+
rebuild(program_globals: table)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Slice 7 phase 7 — in-source class discovery. Maps a
|
|
188
|
+
# qualified class name (e.g. `"Account"`) to its
|
|
189
|
+
# `Type::Singleton` so references to user-defined classes
|
|
190
|
+
# in the analyzed files resolve through
|
|
191
|
+
# `ExpressionTyper#resolve_constant_name` even when no RBS
|
|
192
|
+
# decl exists. Populated once at index time by
|
|
193
|
+
# `ScopeIndexer` from every `Prism::ClassNode` and
|
|
194
|
+
# `Prism::ModuleNode` it walks.
|
|
195
|
+
def with_discovered_classes(table)
|
|
196
|
+
rebuild(discovered_classes: table)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Slice 7 phase 9 — in-source constant-value tracking.
|
|
200
|
+
# Maps a qualified constant name (e.g. `"BUCKETS"` or
|
|
201
|
+
# `"Rigor::Analysis::FactStore::BUCKETS"`) to the type of
|
|
202
|
+
# the rvalue assigned at its `Prism::ConstantWriteNode` /
|
|
203
|
+
# `Prism::ConstantPathWriteNode`. Populated by
|
|
204
|
+
# `ScopeIndexer` once at index time. `ExpressionTyper#resolve_constant_name`
|
|
205
|
+
# consults this map after class lookups so an in-source
|
|
206
|
+
# constant assignment overrides any RBS-declared constant
|
|
207
|
+
# of the same qualified name (matching Ruby's runtime
|
|
208
|
+
# precedence: a constant defined in user code is the
|
|
209
|
+
# authoritative value).
|
|
210
|
+
def with_in_source_constants(table)
|
|
211
|
+
rebuild(in_source_constants: table)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Slice 7 phase 12 — in-source method discovery. Maps a
|
|
215
|
+
# qualified class name to a `Hash[Symbol, Symbol]` of
|
|
216
|
+
# `method_name => :instance | :singleton`. Populated by
|
|
217
|
+
# `ScopeIndexer` from every `Prism::DefNode` and recognised
|
|
218
|
+
# `define_method` invocation inside class/module bodies. The
|
|
219
|
+
# `rigor check` undefined-method and wrong-arity rules
|
|
220
|
+
# consult this map to suppress diagnostics for methods the
|
|
221
|
+
# user has defined dynamically, even when no RBS sig
|
|
222
|
+
# describes them.
|
|
223
|
+
def discovered_method?(class_name, method_name, kind)
|
|
224
|
+
table = @discovered_methods[class_name.to_s]
|
|
225
|
+
return false unless table
|
|
226
|
+
|
|
227
|
+
table[method_name.to_sym] == kind
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def with_discovered_methods(table)
|
|
231
|
+
rebuild(discovered_methods: table)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def facts_for(target: nil, bucket: nil)
|
|
235
|
+
fact_store.facts_for(target: target, bucket: bucket)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def local_facts(name, bucket: nil)
|
|
239
|
+
facts_for(target: Analysis::FactStore::Target.local(name), bucket: bucket)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def type_of(node, tracer: nil)
|
|
243
|
+
Inference::ExpressionTyper.new(scope: self, tracer: tracer).type_of(node)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Statement-level evaluation: returns the pair `[type, scope']`
|
|
247
|
+
# where `type` is what the node produces and `scope'` is the
|
|
248
|
+
# scope observable after the node has run. The receiver scope is
|
|
249
|
+
# never mutated. See {Rigor::Inference::StatementEvaluator} for
|
|
250
|
+
# the catalogue of nodes that thread scope; everything else
|
|
251
|
+
# defers to {#type_of} and returns the receiver scope unchanged.
|
|
252
|
+
def evaluate(node, tracer: nil)
|
|
253
|
+
Inference::StatementEvaluator.new(scope: self, tracer: tracer).evaluate(node)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Joins this scope with another at a control-flow merge point. The
|
|
257
|
+
# joined scope is bound to every local that BOTH branches bind, with
|
|
258
|
+
# the type widened to the union of both sides. Names bound in only
|
|
259
|
+
# one branch are dropped from the joined scope; the eventual
|
|
260
|
+
# statement-level evaluator (Slice 3 phase 2) is responsible for
|
|
261
|
+
# nil-injecting half-bound names where the language semantics demand
|
|
262
|
+
# it. The two scopes MUST share the same Environment.
|
|
263
|
+
def join(other)
|
|
264
|
+
raise ArgumentError, "join requires a Rigor::Scope, got #{other.class}" unless other.is_a?(Scope)
|
|
265
|
+
|
|
266
|
+
unless environment.equal?(other.environment)
|
|
267
|
+
raise ArgumentError, "join requires both scopes to share the same Environment"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
joined_locals = join_bindings(locals, other.locals)
|
|
271
|
+
joined_ivars = join_bindings(ivars, other.ivars)
|
|
272
|
+
joined_cvars = join_bindings(cvars, other.cvars)
|
|
273
|
+
joined_globals = join_bindings(globals, other.globals)
|
|
274
|
+
build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def ==(other) # rubocop:disable Metrics/CyclomaticComplexity
|
|
278
|
+
other.is_a?(Scope) &&
|
|
279
|
+
environment.equal?(other.environment) &&
|
|
280
|
+
@locals == other.locals &&
|
|
281
|
+
fact_store == other.fact_store &&
|
|
282
|
+
self_type == other.self_type &&
|
|
283
|
+
@ivars == other.ivars &&
|
|
284
|
+
@cvars == other.cvars &&
|
|
285
|
+
@globals == other.globals
|
|
286
|
+
end
|
|
287
|
+
alias eql? ==
|
|
288
|
+
|
|
289
|
+
def hash
|
|
290
|
+
[Scope, environment.object_id, @locals, fact_store, self_type, @ivars, @cvars, @globals].hash
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def rebuild(
|
|
296
|
+
locals: @locals, fact_store: @fact_store, self_type: @self_type,
|
|
297
|
+
declared_types: @declared_types, ivars: @ivars, cvars: @cvars, globals: @globals,
|
|
298
|
+
class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
|
|
299
|
+
discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
|
|
300
|
+
discovered_methods: @discovered_methods
|
|
301
|
+
)
|
|
302
|
+
self.class.new(
|
|
303
|
+
environment: environment, locals: locals,
|
|
304
|
+
fact_store: fact_store, self_type: self_type,
|
|
305
|
+
declared_types: declared_types,
|
|
306
|
+
ivars: ivars, cvars: cvars, globals: globals,
|
|
307
|
+
class_ivars: class_ivars, class_cvars: class_cvars,
|
|
308
|
+
program_globals: program_globals,
|
|
309
|
+
discovered_classes: discovered_classes,
|
|
310
|
+
in_source_constants: in_source_constants,
|
|
311
|
+
discovered_methods: discovered_methods
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def join_bindings(left, right)
|
|
316
|
+
shared = left.keys & right.keys
|
|
317
|
+
shared.to_h { |name| [name, Type::Combinator.union(left[name], right[name])] }.freeze
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
|
|
321
|
+
self.class.new(
|
|
322
|
+
environment: environment,
|
|
323
|
+
locals: joined_locals.freeze,
|
|
324
|
+
fact_store: fact_store.join(other.fact_store),
|
|
325
|
+
self_type: self_type == other.self_type ? self_type : nil,
|
|
326
|
+
declared_types: declared_types,
|
|
327
|
+
ivars: joined_ivars,
|
|
328
|
+
cvars: joined_cvars,
|
|
329
|
+
globals: joined_globals,
|
|
330
|
+
class_ivars: class_ivars,
|
|
331
|
+
class_cvars: class_cvars,
|
|
332
|
+
program_globals: program_globals,
|
|
333
|
+
discovered_classes: discovered_classes,
|
|
334
|
+
in_source_constants: in_source_constants,
|
|
335
|
+
discovered_methods: discovered_methods
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
# rubocop:enable Metrics/ClassLength,Metrics/ParameterLists
|
|
340
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Source
|
|
7
|
+
# Locates the deepest Prism AST node enclosing a given source position.
|
|
8
|
+
#
|
|
9
|
+
# The locator works on byte offsets internally so that multibyte source
|
|
10
|
+
# text behaves consistently with Prism, which itself reports offsets in
|
|
11
|
+
# bytes. Convenience constructors translate from `(line, column)` pairs.
|
|
12
|
+
#
|
|
13
|
+
# Lines are 1-indexed (matching editor / Prism / gcc conventions).
|
|
14
|
+
# Columns are 1-indexed when supplied via the `(line, column)` API; this
|
|
15
|
+
# matches the canonical `file.rb:line:col` form most tools emit. Internal
|
|
16
|
+
# offsets remain 0-indexed bytes.
|
|
17
|
+
#
|
|
18
|
+
# The locator is read-only: a single instance binds to one source buffer
|
|
19
|
+
# and AST root, and queries are pure functions of the byte offset.
|
|
20
|
+
class NodeLocator
|
|
21
|
+
class OutOfRangeError < StandardError; end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# @param source [String]
|
|
25
|
+
# @param root [Prism::Node]
|
|
26
|
+
# @param line [Integer] 1-indexed line number
|
|
27
|
+
# @param column [Integer] 1-indexed column number (byte index within the line)
|
|
28
|
+
# @return [Prism::Node, nil]
|
|
29
|
+
def at_position(source:, root:, line:, column:)
|
|
30
|
+
new(source: source, root: root).at_position(line: line, column: column)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param root [Prism::Node]
|
|
34
|
+
# @param offset [Integer] 0-indexed byte offset
|
|
35
|
+
# @return [Prism::Node, nil]
|
|
36
|
+
def at_offset(root:, offset:)
|
|
37
|
+
new(source: nil, root: root).at_offset(offset)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param source [String, nil] used by `#at_position`; may be omitted when only `#at_offset` is needed.
|
|
42
|
+
# @param root [Prism::Node]
|
|
43
|
+
def initialize(source:, root:)
|
|
44
|
+
@source = source
|
|
45
|
+
@root = root
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve a `(line, column)` pair (1-indexed) to the deepest enclosing node.
|
|
49
|
+
#
|
|
50
|
+
# @raise [OutOfRangeError] if the line or column falls outside the source buffer.
|
|
51
|
+
def at_position(line:, column:)
|
|
52
|
+
offset = position_to_offset(line, column)
|
|
53
|
+
at_offset(offset)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolve a byte offset (0-indexed) to the deepest enclosing node.
|
|
57
|
+
def at_offset(offset)
|
|
58
|
+
descend(@root, offset)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Translate a `(line, column)` pair into a 0-indexed byte offset for the
|
|
62
|
+
# bound source buffer.
|
|
63
|
+
def position_to_offset(line, column)
|
|
64
|
+
raise ArgumentError, "source buffer required for position lookup" if @source.nil?
|
|
65
|
+
raise OutOfRangeError, "line must be >= 1, got #{line}" if line < 1
|
|
66
|
+
raise OutOfRangeError, "column must be >= 1, got #{column}" if column < 1
|
|
67
|
+
|
|
68
|
+
offset = 0
|
|
69
|
+
current_line = 1
|
|
70
|
+
@source.each_line do |chunk|
|
|
71
|
+
break if current_line == line
|
|
72
|
+
|
|
73
|
+
offset += chunk.bytesize
|
|
74
|
+
current_line += 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise OutOfRangeError, "line #{line} is past the end of the source buffer" if current_line != line
|
|
78
|
+
|
|
79
|
+
offset + (column - 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def descend(node, offset)
|
|
85
|
+
return nil unless node.is_a?(Prism::Node)
|
|
86
|
+
return nil unless contains?(node, offset)
|
|
87
|
+
|
|
88
|
+
node.compact_child_nodes.each do |child|
|
|
89
|
+
deeper = descend(child, offset)
|
|
90
|
+
return deeper if deeper
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
node
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def contains?(node, offset)
|
|
97
|
+
location = node.location
|
|
98
|
+
return false if location.nil?
|
|
99
|
+
|
|
100
|
+
location.start_offset <= offset && offset < location.end_offset
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Source
|
|
7
|
+
# Yields every `Prism::Node` reachable from a root in DFS pre-order.
|
|
8
|
+
#
|
|
9
|
+
# The walker is the source-positioning analogue to `NodeLocator`: where the
|
|
10
|
+
# locator answers "what node is at this point?", the walker enumerates the
|
|
11
|
+
# full set of Prism nodes for tooling that needs to operate on each one
|
|
12
|
+
# (coverage probes, lint passes, IDE outlines).
|
|
13
|
+
#
|
|
14
|
+
# Non-Prism children (literals embedded in node attributes, virtual nodes,
|
|
15
|
+
# or `nil` slots) are silently skipped so callers can rely on every yielded
|
|
16
|
+
# value responding to the `Prism::Node` API.
|
|
17
|
+
module NodeWalker
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @yieldparam node [Prism::Node]
|
|
21
|
+
# @return [Enumerator] when no block is given.
|
|
22
|
+
def each(root, &)
|
|
23
|
+
return to_enum(__method__, root) unless block_given?
|
|
24
|
+
|
|
25
|
+
walk(root, &)
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def walk(node, &)
|
|
30
|
+
return unless node.is_a?(Prism::Node)
|
|
31
|
+
|
|
32
|
+
yield node
|
|
33
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/rigor/source.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Source-text and AST positioning utilities.
|
|
4
|
+
#
|
|
5
|
+
# Anything that maps between a Ruby source buffer and Prism AST nodes belongs
|
|
6
|
+
# here. The contents of this namespace deliberately stay independent of the
|
|
7
|
+
# inference engine so that future tooling (LSP, refactoring helpers, doc
|
|
8
|
+
# extractors) can reuse the same primitives without dragging in `Rigor::Type`.
|
|
9
|
+
module Rigor
|
|
10
|
+
module Source
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "source/node_locator"
|
|
15
|
+
require_relative "source/node_walker"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# Slice 7 phase 19 — PHPStan-style typing helpers.
|
|
5
|
+
#
|
|
6
|
+
# `Rigor::Testing` ships two runtime no-op helpers that serve
|
|
7
|
+
# as anchors for static-analysis diagnostics:
|
|
8
|
+
#
|
|
9
|
+
# - `dump_type(value)` — returns `value` unchanged at runtime.
|
|
10
|
+
# The Rigor analyzer surfaces an `:info`-severity diagnostic
|
|
11
|
+
# at the call site showing the inferred type of `value` so
|
|
12
|
+
# the user can see what the engine sees at that program point.
|
|
13
|
+
# - `assert_type(expected, value)` — returns `value` unchanged
|
|
14
|
+
# at runtime. The analyzer compares `value`'s inferred type
|
|
15
|
+
# (rendered through `Rigor::Type#describe(:short)`) against
|
|
16
|
+
# the literal `expected` String; a mismatch produces an
|
|
17
|
+
# `:error`-severity diagnostic. This lets a user-written
|
|
18
|
+
# fixture be self-asserting: `rigor check fixture.rb` exits
|
|
19
|
+
# non-zero exactly when the engine's inference drifts from
|
|
20
|
+
# what the fixture documents.
|
|
21
|
+
#
|
|
22
|
+
# Three usage shapes are recognised by the static rules:
|
|
23
|
+
#
|
|
24
|
+
# require "rigor/testing"
|
|
25
|
+
# include Rigor::Testing
|
|
26
|
+
# dump_type(x)
|
|
27
|
+
# assert_type("Constant[1]", x)
|
|
28
|
+
#
|
|
29
|
+
# ... or fully qualified:
|
|
30
|
+
#
|
|
31
|
+
# Rigor::Testing.dump_type(x)
|
|
32
|
+
# Rigor::Testing.assert_type("String | nil", x)
|
|
33
|
+
#
|
|
34
|
+
# ... or via the convenience top-level alias `Rigor` itself:
|
|
35
|
+
#
|
|
36
|
+
# Rigor.dump_type(x)
|
|
37
|
+
# Rigor.assert_type("Constant[\"hello\"]", x)
|
|
38
|
+
#
|
|
39
|
+
# All three resolve to the same no-op runtime body, so a
|
|
40
|
+
# fixture may freely run under MRI without depending on the
|
|
41
|
+
# analyzer being present.
|
|
42
|
+
module Testing
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def dump_type(value)
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def assert_type(_expected, value)
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Convenience aliases on `Rigor` itself, so fixtures can
|
|
56
|
+
# write `Rigor.dump_type(x)` without an `include` line.
|
|
57
|
+
def dump_type(value)
|
|
58
|
+
Testing.dump_type(value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def assert_type(expected, value)
|
|
62
|
+
Testing.assert_type(expected, value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|