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,1017 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../type"
|
|
6
|
+
require_relative "../analysis/fact_store"
|
|
7
|
+
require_relative "../source/node_walker"
|
|
8
|
+
require_relative "block_parameter_binder"
|
|
9
|
+
require_relative "closure_escape_analyzer"
|
|
10
|
+
require_relative "method_dispatcher"
|
|
11
|
+
require_relative "method_parameter_binder"
|
|
12
|
+
require_relative "multi_target_binder"
|
|
13
|
+
require_relative "narrowing"
|
|
14
|
+
|
|
15
|
+
module Rigor
|
|
16
|
+
module Inference
|
|
17
|
+
# Statement-level evaluator that complements `Rigor::Inference::ExpressionTyper`
|
|
18
|
+
# by threading an immutable {Rigor::Scope} through control-flow constructs.
|
|
19
|
+
# The output is the pair `[Rigor::Type, Rigor::Scope]`: the type that the
|
|
20
|
+
# evaluated node produces, and the scope that callers should observe
|
|
21
|
+
# AFTER the node has run.
|
|
22
|
+
#
|
|
23
|
+
# Slice 3 phase 2 ships the evaluator surface and the scope-threading
|
|
24
|
+
# rules for the canonical statement-y nodes:
|
|
25
|
+
#
|
|
26
|
+
# - sequential evaluation across `Prism::StatementsNode`/`ProgramNode`,
|
|
27
|
+
# - local-variable assignment (`Prism::LocalVariableWriteNode`) binding
|
|
28
|
+
# the rvalue's type into the post-scope,
|
|
29
|
+
# - branching constructs (`IfNode`, `UnlessNode`, `CaseNode`,
|
|
30
|
+
# `CaseMatchNode`, `BeginNode`/`RescueNode`/`EnsureNode`,
|
|
31
|
+
# `WhileNode`/`UntilNode`, `AndNode`/`OrNode`) that evaluate each
|
|
32
|
+
# branch under a forked scope and merge the results with
|
|
33
|
+
# nil-injection on half-bound names,
|
|
34
|
+
# - pass-through helpers for `ParenthesesNode`, `ElseNode`,
|
|
35
|
+
# `WhenNode`/`InNode`, and `RescueNode`.
|
|
36
|
+
#
|
|
37
|
+
# Anything outside the catalogue defers to `Rigor::Scope#type_of` and
|
|
38
|
+
# returns the receiver scope unchanged. This matches the Slice 1
|
|
39
|
+
# fail-soft policy: an unrecognised statement-level node MUST NOT
|
|
40
|
+
# raise and MUST keep the scope intact.
|
|
41
|
+
#
|
|
42
|
+
# The class is stateful (`@scope`, `@tracer`) but every public call
|
|
43
|
+
# returns fresh values; the receiver scope MUST never be mutated.
|
|
44
|
+
# Recursive evaluation always allocates a new instance with the
|
|
45
|
+
# forked scope so different branches stay isolated.
|
|
46
|
+
#
|
|
47
|
+
# See docs/internal-spec/inference-engine.md for the public contract
|
|
48
|
+
# and docs/adr/4-type-inference-engine.md for the slice rationale.
|
|
49
|
+
# rubocop:disable Metrics/ClassLength
|
|
50
|
+
class StatementEvaluator
|
|
51
|
+
# Hash-based dispatch keeps `evaluate` linear and lets future slices
|
|
52
|
+
# add control-flow node kinds without growing a single case
|
|
53
|
+
# statement past RuboCop's cyclomatic budget. Anonymous Prism
|
|
54
|
+
# subclasses are not expected.
|
|
55
|
+
HANDLERS = {
|
|
56
|
+
Prism::StatementsNode => :eval_statements,
|
|
57
|
+
Prism::ProgramNode => :eval_program,
|
|
58
|
+
Prism::LocalVariableWriteNode => :eval_local_write,
|
|
59
|
+
Prism::LocalVariableOrWriteNode => :eval_local_or_write,
|
|
60
|
+
Prism::LocalVariableAndWriteNode => :eval_local_and_write,
|
|
61
|
+
Prism::LocalVariableOperatorWriteNode => :eval_local_operator_write,
|
|
62
|
+
Prism::InstanceVariableWriteNode => :eval_ivar_write,
|
|
63
|
+
Prism::InstanceVariableOrWriteNode => :eval_ivar_or_write,
|
|
64
|
+
Prism::InstanceVariableAndWriteNode => :eval_ivar_and_write,
|
|
65
|
+
Prism::InstanceVariableOperatorWriteNode => :eval_ivar_operator_write,
|
|
66
|
+
Prism::ClassVariableWriteNode => :eval_cvar_write,
|
|
67
|
+
Prism::ClassVariableOrWriteNode => :eval_cvar_or_write,
|
|
68
|
+
Prism::ClassVariableAndWriteNode => :eval_cvar_and_write,
|
|
69
|
+
Prism::ClassVariableOperatorWriteNode => :eval_cvar_operator_write,
|
|
70
|
+
Prism::GlobalVariableWriteNode => :eval_global_write,
|
|
71
|
+
Prism::GlobalVariableOrWriteNode => :eval_global_or_write,
|
|
72
|
+
Prism::GlobalVariableAndWriteNode => :eval_global_and_write,
|
|
73
|
+
Prism::GlobalVariableOperatorWriteNode => :eval_global_operator_write,
|
|
74
|
+
Prism::MultiWriteNode => :eval_multi_write,
|
|
75
|
+
Prism::IfNode => :eval_if,
|
|
76
|
+
Prism::UnlessNode => :eval_unless,
|
|
77
|
+
Prism::ElseNode => :eval_else,
|
|
78
|
+
Prism::CaseNode => :eval_case,
|
|
79
|
+
Prism::CaseMatchNode => :eval_case,
|
|
80
|
+
Prism::WhenNode => :eval_when_or_in,
|
|
81
|
+
Prism::InNode => :eval_when_or_in,
|
|
82
|
+
Prism::BeginNode => :eval_begin,
|
|
83
|
+
Prism::RescueNode => :eval_rescue,
|
|
84
|
+
Prism::EnsureNode => :eval_ensure,
|
|
85
|
+
Prism::WhileNode => :eval_loop,
|
|
86
|
+
Prism::UntilNode => :eval_loop,
|
|
87
|
+
Prism::AndNode => :eval_and_or,
|
|
88
|
+
Prism::OrNode => :eval_and_or,
|
|
89
|
+
Prism::ParenthesesNode => :eval_parentheses,
|
|
90
|
+
Prism::DefNode => :eval_def,
|
|
91
|
+
Prism::ClassNode => :eval_class_or_module,
|
|
92
|
+
Prism::ModuleNode => :eval_class_or_module,
|
|
93
|
+
Prism::SingletonClassNode => :eval_singleton_class,
|
|
94
|
+
Prism::CallNode => :eval_call,
|
|
95
|
+
Prism::BlockNode => :eval_block
|
|
96
|
+
}.freeze
|
|
97
|
+
private_constant :HANDLERS
|
|
98
|
+
|
|
99
|
+
# Lexical class frame: the `name:` field is the qualified class
|
|
100
|
+
# name as it would render in Ruby (e.g., `"Foo::Bar"`); the
|
|
101
|
+
# `singleton:` field is `true` for `class << self` frames so
|
|
102
|
+
# nested defs resolve to singleton-method RBS lookups.
|
|
103
|
+
ClassFrame = Data.define(:name, :singleton)
|
|
104
|
+
|
|
105
|
+
# @param scope [Rigor::Scope]
|
|
106
|
+
# @param tracer [Rigor::Inference::FallbackTracer, nil]
|
|
107
|
+
# @param on_enter [#call, nil] optional `(node, scope) ->` callable
|
|
108
|
+
# invoked once at the start of every {#evaluate} call (the node
|
|
109
|
+
# itself, *before* its handler runs). Threaded through every
|
|
110
|
+
# recursive `sub_eval` so the tooling that builds a per-node
|
|
111
|
+
# scope index (`Rigor::Inference::ScopeIndexer`) can record the
|
|
112
|
+
# entry scope for every Prism node the evaluator visits without
|
|
113
|
+
# the StatementEvaluator carrying any additional state itself.
|
|
114
|
+
# @param class_context [Array<ClassFrame>] lexical class scope used
|
|
115
|
+
# by {#eval_def} to look up the method's RBS signature. Each
|
|
116
|
+
# `ClassNode`/`ModuleNode` entry pushes a frame; `SingletonClassNode`
|
|
117
|
+
# over `self` flips the innermost frame to singleton mode.
|
|
118
|
+
def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze)
|
|
119
|
+
@scope = scope
|
|
120
|
+
@tracer = tracer
|
|
121
|
+
@on_enter = on_enter
|
|
122
|
+
@class_context = class_context.freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Evaluate `node` under the receiver scope. Returns `[type, scope']`
|
|
126
|
+
# where `type` is the value the node produces and `scope'` is the
|
|
127
|
+
# scope observable after the node has run. The receiver scope is
|
|
128
|
+
# never mutated.
|
|
129
|
+
#
|
|
130
|
+
# @param node [Prism::Node]
|
|
131
|
+
# @return [Array(Rigor::Type, Rigor::Scope)]
|
|
132
|
+
def evaluate(node)
|
|
133
|
+
@on_enter&.call(node, @scope)
|
|
134
|
+
|
|
135
|
+
handler = HANDLERS[node.class]
|
|
136
|
+
return send(handler, node) if handler
|
|
137
|
+
|
|
138
|
+
# Default: the node is treated as a pure expression. Type it
|
|
139
|
+
# through the existing expression typer (which observes the
|
|
140
|
+
# current scope's locals) and leave the scope unchanged.
|
|
141
|
+
[@scope.type_of(node, tracer: @tracer), @scope]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
attr_reader :scope, :tracer
|
|
147
|
+
|
|
148
|
+
# Thread the scope through every child statement in declaration
|
|
149
|
+
# order. The body's value is the type of the last statement (or
|
|
150
|
+
# `Constant[nil]` for an empty body); intermediate statements'
|
|
151
|
+
# types are discarded, but their scope effects are preserved.
|
|
152
|
+
def eval_statements(node)
|
|
153
|
+
result_type = Type::Combinator.constant_of(nil)
|
|
154
|
+
current = scope
|
|
155
|
+
node.body.each do |stmt|
|
|
156
|
+
result_type, current = sub_eval(stmt, current)
|
|
157
|
+
end
|
|
158
|
+
[result_type, current]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def eval_program(node)
|
|
162
|
+
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
163
|
+
|
|
164
|
+
sub_eval(node.statements, scope)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# `name = rvalue` evaluates the rvalue under the entry scope (so
|
|
168
|
+
# earlier assignments in a chained `a = b = expr` propagate
|
|
169
|
+
# left-to-right) and binds `name` to the result type. Compound
|
|
170
|
+
# assignment forms (`+=` etc.) are deferred to a follow-up; for
|
|
171
|
+
# now they degrade to "type the rhs, do not rebind" via the
|
|
172
|
+
# default branch in {#evaluate}.
|
|
173
|
+
def eval_local_write(node)
|
|
174
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
175
|
+
[rhs_type, post_rhs.with_local(node.name, rhs_type)]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Slice 7 phase 1 — instance/class/global variable
|
|
179
|
+
# writes. Each handler evaluates the rvalue under the
|
|
180
|
+
# entry scope and binds the named variable into the
|
|
181
|
+
# post-scope's per-kind binding map. The expression value
|
|
182
|
+
# is the rvalue type, matching Ruby's semantics. Bindings
|
|
183
|
+
# are method-local: a fresh scope is built at every `def`
|
|
184
|
+
# entry through `build_method_entry_scope`, so writes do
|
|
185
|
+
# not leak across method boundaries until cross-method
|
|
186
|
+
# ivar/cvar tracking lands.
|
|
187
|
+
def eval_ivar_write(node)
|
|
188
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
189
|
+
[rhs_type, post_rhs.with_ivar(node.name, rhs_type)]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def eval_cvar_write(node)
|
|
193
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
194
|
+
[rhs_type, post_rhs.with_cvar(node.name, rhs_type)]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def eval_global_write(node)
|
|
198
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
199
|
+
[rhs_type, post_rhs.with_global(node.name, rhs_type)]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Slice 7 phase 3 — compound writes (||=, &&=, +=/-=/...)
|
|
203
|
+
# for every variable kind. Each handler:
|
|
204
|
+
# 1. Reads the current type from the appropriate scope
|
|
205
|
+
# binding map (or `Dynamic[Top]` when unbound).
|
|
206
|
+
# 2. Evaluates the rvalue under the entry scope and
|
|
207
|
+
# threads any scope effects (rare for compound RHS,
|
|
208
|
+
# but matches Ruby evaluation order).
|
|
209
|
+
# 3. Computes the result type via `compound_result_type`:
|
|
210
|
+
# `||=` → `union(narrow_truthy(current), rhs)`;
|
|
211
|
+
# `&&=` → `union(narrow_falsey(current), rhs)`;
|
|
212
|
+
# operator forms (`+=`, `-=`, `*=`, ...) dispatch
|
|
213
|
+
# `current.send(op, rhs)` through `MethodDispatcher`,
|
|
214
|
+
# falling back to `Dynamic[Top]` on a miss.
|
|
215
|
+
# 4. Rebinds the variable into the post-scope through
|
|
216
|
+
# the same `with_*` builder used by the plain write
|
|
217
|
+
# handler, so subsequent reads observe the result.
|
|
218
|
+
def eval_local_or_write(node)
|
|
219
|
+
compound_eval(node, kind: :local, op: :or)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def eval_local_and_write(node)
|
|
223
|
+
compound_eval(node, kind: :local, op: :and)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def eval_local_operator_write(node)
|
|
227
|
+
compound_eval(node, kind: :local, op: node.binary_operator)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def eval_ivar_or_write(node)
|
|
231
|
+
compound_eval(node, kind: :ivar, op: :or)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def eval_ivar_and_write(node)
|
|
235
|
+
compound_eval(node, kind: :ivar, op: :and)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def eval_ivar_operator_write(node)
|
|
239
|
+
compound_eval(node, kind: :ivar, op: node.binary_operator)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def eval_cvar_or_write(node)
|
|
243
|
+
compound_eval(node, kind: :cvar, op: :or)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def eval_cvar_and_write(node)
|
|
247
|
+
compound_eval(node, kind: :cvar, op: :and)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def eval_cvar_operator_write(node)
|
|
251
|
+
compound_eval(node, kind: :cvar, op: node.binary_operator)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def eval_global_or_write(node)
|
|
255
|
+
compound_eval(node, kind: :global, op: :or)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def eval_global_and_write(node)
|
|
259
|
+
compound_eval(node, kind: :global, op: :and)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def eval_global_operator_write(node)
|
|
263
|
+
compound_eval(node, kind: :global, op: node.binary_operator)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
|
|
267
|
+
current_type = current_type_for(kind, node.name)
|
|
268
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
269
|
+
result_type = compound_result_type(current_type, rhs_type, op)
|
|
270
|
+
[result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
VAR_KIND_GETTERS = {
|
|
274
|
+
local: :local, ivar: :ivar, cvar: :cvar, global: :global
|
|
275
|
+
}.freeze
|
|
276
|
+
VAR_KIND_BUILDERS = {
|
|
277
|
+
local: :with_local, ivar: :with_ivar, cvar: :with_cvar, global: :with_global
|
|
278
|
+
}.freeze
|
|
279
|
+
private_constant :VAR_KIND_GETTERS, :VAR_KIND_BUILDERS
|
|
280
|
+
|
|
281
|
+
def current_type_for(kind, name)
|
|
282
|
+
scope.public_send(VAR_KIND_GETTERS.fetch(kind), name) || Type::Combinator.untyped
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def rebind_variable(target_scope, kind, name, type)
|
|
286
|
+
target_scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, type)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def compound_result_type(current, rhs, operator)
|
|
290
|
+
case operator
|
|
291
|
+
when :or
|
|
292
|
+
Type::Combinator.union(Narrowing.narrow_truthy(current), rhs)
|
|
293
|
+
when :and
|
|
294
|
+
Type::Combinator.union(Narrowing.narrow_falsey(current), rhs)
|
|
295
|
+
else
|
|
296
|
+
dispatch_operator(current, rhs, operator)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def dispatch_operator(current, rhs, operator)
|
|
301
|
+
result = MethodDispatcher.dispatch(
|
|
302
|
+
receiver_type: current,
|
|
303
|
+
method_name: operator.to_sym,
|
|
304
|
+
arg_types: [rhs],
|
|
305
|
+
environment: scope.environment
|
|
306
|
+
)
|
|
307
|
+
result || Type::Combinator.untyped
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring.
|
|
311
|
+
# Evaluates the right-hand side under the entry scope, then
|
|
312
|
+
# decomposes its type against the multi-write target tree
|
|
313
|
+
# (Prism::MultiWriteNode#lefts/rest/rights, including nested
|
|
314
|
+
# Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped
|
|
315
|
+
# right-hand sides produce per-slot types element-wise; other
|
|
316
|
+
# carriers fall back to `Dynamic[Top]` per slot. The expression
|
|
317
|
+
# value is the right-hand side type (matching Ruby's semantics:
|
|
318
|
+
# `(a, b = [1, 2])` evaluates to `[1, 2]`).
|
|
319
|
+
def eval_multi_write(node)
|
|
320
|
+
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
321
|
+
bindings = MultiTargetBinder.bind(node, rhs_type)
|
|
322
|
+
post = bindings.reduce(post_rhs) { |acc, (name, type)| acc.with_local(name, type) }
|
|
323
|
+
[rhs_type, post]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# `if pred; t; (elsif/else)?` runs the predicate first (its
|
|
327
|
+
# post-scope is shared by both branches), then asks
|
|
328
|
+
# `Rigor::Inference::Narrowing` for the truthy and falsey edge
|
|
329
|
+
# scopes derived from the predicate. Slice 6 phase 1 narrows
|
|
330
|
+
# local-variable bindings on truthiness, `nil?`, `!`, and `&&`/
|
|
331
|
+
# `||` predicate composition; predicates the analyser does not
|
|
332
|
+
# specialise return the post-predicate scope unchanged on both
|
|
333
|
+
# edges, preserving the Slice 3 phase 2 behaviour. The branches'
|
|
334
|
+
# result types are unioned; their post-scopes are joined with
|
|
335
|
+
# nil-injection on half-bound names so a name set in one branch
|
|
336
|
+
# but not the other is observable as `T | nil` after the if.
|
|
337
|
+
def eval_if(node)
|
|
338
|
+
_pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
339
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
340
|
+
then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
|
|
341
|
+
else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
|
|
342
|
+
# Slice 7 phase 14 — early-return narrowing. When the
|
|
343
|
+
# then-branch unconditionally exits (return / next /
|
|
344
|
+
# break / raise) and there is no else, the post-scope
|
|
345
|
+
# is the falsey edge of the predicate (subsequent
|
|
346
|
+
# statements observe the predicate-was-false world).
|
|
347
|
+
return [Type::Combinator.union(then_type, else_type), falsey_scope] \
|
|
348
|
+
if branch_unconditionally_exits?(node.statements) && node.subsequent.nil?
|
|
349
|
+
return [Type::Combinator.union(then_type, else_type), truthy_scope] \
|
|
350
|
+
if branch_unconditionally_exits?(node.subsequent) && node.statements
|
|
351
|
+
|
|
352
|
+
[
|
|
353
|
+
Type::Combinator.union(then_type, else_type),
|
|
354
|
+
join_with_nil_injection(then_scope, else_scope)
|
|
355
|
+
]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# `unless pred; t; else; e; end`. Same shape as `if`, but Prism
|
|
359
|
+
# exposes the else-branch as `else_clause` (no elsif chain). The
|
|
360
|
+
# narrower's truthy/falsey edges are routed in swapped form
|
|
361
|
+
# because `unless` runs its body when the predicate is falsey.
|
|
362
|
+
def eval_unless(node)
|
|
363
|
+
_pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
364
|
+
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
365
|
+
then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
|
|
366
|
+
else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
|
|
367
|
+
# Slice 7 phase 14 — same early-return narrowing as
|
|
368
|
+
# `if`: when the body unconditionally exits and there
|
|
369
|
+
# is no else, the post-scope is the truthy edge.
|
|
370
|
+
return [Type::Combinator.union(then_type, else_type), truthy_scope] \
|
|
371
|
+
if branch_unconditionally_exits?(node.statements) && node.else_clause.nil?
|
|
372
|
+
return [Type::Combinator.union(then_type, else_type), falsey_scope] \
|
|
373
|
+
if branch_unconditionally_exits?(node.else_clause) && node.statements
|
|
374
|
+
|
|
375
|
+
[
|
|
376
|
+
Type::Combinator.union(then_type, else_type),
|
|
377
|
+
join_with_nil_injection(then_scope, else_scope)
|
|
378
|
+
]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def eval_else(node)
|
|
382
|
+
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
383
|
+
|
|
384
|
+
sub_eval(node.statements, scope)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# `case pred; when ...; when ...; else; end` and the pattern-
|
|
388
|
+
# matching variant. The predicate's post-scope is shared with
|
|
389
|
+
# every branch (including the else); branches are evaluated
|
|
390
|
+
# independently and merged with nil-injection so half-bound
|
|
391
|
+
# names degrade to `T | nil`.
|
|
392
|
+
def eval_case(node)
|
|
393
|
+
post_pred = node.predicate ? sub_eval(node.predicate, scope).last : scope
|
|
394
|
+
branch_results, falsey_scope = eval_case_when_branches(node.predicate, node.conditions, post_pred)
|
|
395
|
+
else_result = eval_case_else(node.else_clause, falsey_scope)
|
|
396
|
+
|
|
397
|
+
all_results = [*branch_results, else_result]
|
|
398
|
+
[
|
|
399
|
+
Type::Combinator.union(*all_results.map(&:first)),
|
|
400
|
+
reduce_scopes_with_nil_injection(all_results.map(&:last))
|
|
401
|
+
]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def eval_case_when_branches(subject, conditions, entry_scope)
|
|
405
|
+
results = []
|
|
406
|
+
falsey_scope = entry_scope
|
|
407
|
+
conditions.each do |branch|
|
|
408
|
+
when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
|
|
409
|
+
body_scope, falsey_scope = Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
|
|
410
|
+
results << sub_eval(branch, body_scope)
|
|
411
|
+
end
|
|
412
|
+
[results, falsey_scope]
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def eval_case_else(else_clause, falsey_scope)
|
|
416
|
+
return sub_eval(else_clause, falsey_scope) if else_clause
|
|
417
|
+
|
|
418
|
+
[Type::Combinator.constant_of(nil), falsey_scope]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def eval_when_or_in(node)
|
|
422
|
+
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
423
|
+
|
|
424
|
+
sub_eval(node.statements, scope)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# `begin; body; rescue ...; else; ensure; end`. The body and the
|
|
428
|
+
# rescue chain are alternative exit paths whose scopes are joined
|
|
429
|
+
# with nil-injection. The else-clause replaces the body's value
|
|
430
|
+
# when present (matching Ruby semantics: else runs only if the
|
|
431
|
+
# body raises no exception). The ensure-clause runs but does not
|
|
432
|
+
# contribute to the value; its scope effects are layered on the
|
|
433
|
+
# joined exit scope so locals bound exclusively in `ensure` stay
|
|
434
|
+
# observable.
|
|
435
|
+
def eval_begin(node)
|
|
436
|
+
primary_type, primary_scope = eval_begin_primary(node)
|
|
437
|
+
rescue_chain = collect_rescue_chain_results(node.rescue_clause, scope)
|
|
438
|
+
|
|
439
|
+
if rescue_chain.empty?
|
|
440
|
+
exit_type = primary_type
|
|
441
|
+
exit_scope = primary_scope
|
|
442
|
+
else
|
|
443
|
+
exit_type = Type::Combinator.union(primary_type, *rescue_chain.map(&:first))
|
|
444
|
+
exit_scope = reduce_scopes_with_nil_injection([primary_scope, *rescue_chain.map(&:last)])
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
if node.ensure_clause
|
|
448
|
+
_ensure_type, ensure_scope = sub_eval(node.ensure_clause, exit_scope)
|
|
449
|
+
exit_scope = ensure_scope
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
[exit_type, exit_scope]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# `BeginNode#statements` is the primary body; when an else-clause
|
|
456
|
+
# is present, its value replaces the body's per Ruby semantics
|
|
457
|
+
# (the else runs only when no exception was raised), but the
|
|
458
|
+
# body's scope effects still apply because the body did run
|
|
459
|
+
# before the else.
|
|
460
|
+
def eval_begin_primary(node)
|
|
461
|
+
body_type, body_scope =
|
|
462
|
+
if node.statements
|
|
463
|
+
sub_eval(node.statements, scope)
|
|
464
|
+
else
|
|
465
|
+
[Type::Combinator.constant_of(nil), scope]
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
if node.else_clause
|
|
469
|
+
else_type, else_scope = sub_eval(node.else_clause, body_scope)
|
|
470
|
+
[else_type, else_scope]
|
|
471
|
+
else
|
|
472
|
+
[body_type, body_scope]
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def collect_rescue_chain_results(rescue_node, entry_scope)
|
|
477
|
+
results = []
|
|
478
|
+
current = rescue_node
|
|
479
|
+
while current
|
|
480
|
+
results << eval_branch_or_nil(current.statements, entry_scope)
|
|
481
|
+
current = current.subsequent
|
|
482
|
+
end
|
|
483
|
+
results
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def eval_rescue(node)
|
|
487
|
+
eval_branch_or_nil(node.statements, scope)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def eval_ensure(node)
|
|
491
|
+
eval_branch_or_nil(node.statements, scope)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# `while pred; body; end` / `until pred; body; end`. The body
|
|
495
|
+
# might run zero or more times, so half-bound names degrade to
|
|
496
|
+
# `T | nil` in the post-loop scope. The loop expression itself
|
|
497
|
+
# types as `Constant[nil]` (Slice 3 phase 1), reflecting the
|
|
498
|
+
# common case where no `break VALUE` is observed.
|
|
499
|
+
def eval_loop(node)
|
|
500
|
+
_pred_type, post_pred = sub_eval(node.predicate, scope)
|
|
501
|
+
return [Type::Combinator.constant_of(nil), post_pred] if node.statements.nil?
|
|
502
|
+
|
|
503
|
+
_body_type, body_scope = sub_eval(node.statements, post_pred)
|
|
504
|
+
[
|
|
505
|
+
Type::Combinator.constant_of(nil),
|
|
506
|
+
join_with_nil_injection(post_pred, body_scope)
|
|
507
|
+
]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# `a && b` / `a || b`. The LHS always runs, the RHS only
|
|
511
|
+
# sometimes runs. Slice 6 phase 1 narrows the RHS evaluation:
|
|
512
|
+
# `a && b` evaluates `b` under the truthy edge of `a`, and
|
|
513
|
+
# `a || b` evaluates `b` under the falsey edge of `a`. The
|
|
514
|
+
# narrowed RHS post-scope is joined with the LHS post-scope
|
|
515
|
+
# (RHS skipped) using nil-injection so half-bound names from
|
|
516
|
+
# the RHS still degrade to `T | nil`. The result type is
|
|
517
|
+
# edge-aware: `a && b` can only produce the falsey fragment of
|
|
518
|
+
# `a` when the RHS is skipped, while `a || b` can only produce
|
|
519
|
+
# the truthy fragment of `a` when the RHS is skipped.
|
|
520
|
+
def eval_and_or(node)
|
|
521
|
+
left_type, left_scope = sub_eval(node.left, scope)
|
|
522
|
+
truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
|
|
523
|
+
rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
|
|
524
|
+
right_type, right_scope = sub_eval(node.right, rhs_entry)
|
|
525
|
+
skipped_type =
|
|
526
|
+
if node.is_a?(Prism::AndNode)
|
|
527
|
+
Narrowing.narrow_falsey(left_type)
|
|
528
|
+
else
|
|
529
|
+
Narrowing.narrow_truthy(left_type)
|
|
530
|
+
end
|
|
531
|
+
[
|
|
532
|
+
Type::Combinator.union(skipped_type, right_type),
|
|
533
|
+
join_with_nil_injection(left_scope, right_scope)
|
|
534
|
+
]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# `(body)`. Threads scope through the inner expression so
|
|
538
|
+
# `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
|
|
539
|
+
def eval_parentheses(node)
|
|
540
|
+
return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
|
|
541
|
+
|
|
542
|
+
sub_eval(node.body, scope)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# `class Foo; body; end` and `module Foo; body; end`. The class
|
|
546
|
+
# body runs in a fresh scope (Ruby's class scope does not see
|
|
547
|
+
# the outer locals), and the StatementEvaluator pushes a new
|
|
548
|
+
# `ClassFrame` so nested `def`s know their lexical owner. The
|
|
549
|
+
# outer scope is unchanged on exit because Ruby's class
|
|
550
|
+
# definition does not bind any local in the enclosing scope.
|
|
551
|
+
# The class body's value is the value of its last statement
|
|
552
|
+
# (`Constant[nil]` for an empty body); we discard the body's
|
|
553
|
+
# post-scope.
|
|
554
|
+
def eval_class_or_module(node)
|
|
555
|
+
name = qualified_name_for(node.constant_path)
|
|
556
|
+
new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
|
|
557
|
+
body_type, _body_scope = eval_class_body(node, new_context)
|
|
558
|
+
[body_type, scope]
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# `class << expr; body; end`. When `expr` is `self`, the body
|
|
562
|
+
# defines class methods on the immediate enclosing class — the
|
|
563
|
+
# innermost frame flips to `singleton: true` so a nested
|
|
564
|
+
# `def foo` resolves through `singleton_method` rather than
|
|
565
|
+
# `instance_method`. For non-`self` expressions we cannot
|
|
566
|
+
# statically resolve the receiver, so we keep the existing
|
|
567
|
+
# context and accept that nested defs degrade to the
|
|
568
|
+
# `Dynamic[Top]` default.
|
|
569
|
+
def eval_singleton_class(node)
|
|
570
|
+
new_context = singleton_context_for(node)
|
|
571
|
+
body_type, _body_scope = eval_class_body(node, new_context)
|
|
572
|
+
[body_type, scope]
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# `def name(params); body; end`. Builds the method-entry scope
|
|
576
|
+
# by binding the parameter list (RBS-driven where available, or
|
|
577
|
+
# `Dynamic[Top]` for the slice 3 phase 2 fallback) into a fresh
|
|
578
|
+
# scope, then evaluates the body under that scope. The outer
|
|
579
|
+
# scope is left unchanged: a `def` does not introduce a binding
|
|
580
|
+
# in its enclosing scope. Ruby evaluates `def` to the method's
|
|
581
|
+
# name as a Symbol, so the produced type is `Constant[:name]`.
|
|
582
|
+
def eval_def(node)
|
|
583
|
+
body_scope = build_method_entry_scope(node)
|
|
584
|
+
sub_eval(node.body, body_scope, class_context: @class_context) if node.body
|
|
585
|
+
[Type::Combinator.constant_of(node.name), scope]
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# `recv.foo(args) { |params| body }` and friends. The call
|
|
589
|
+
# type comes from `Scope#type_of` (which routes through
|
|
590
|
+
# `ExpressionTyper#call_type_for` and is itself block-aware
|
|
591
|
+
# since Slice 6 phase C sub-phase 2: it builds the block-entry
|
|
592
|
+
# scope from the receiving method's RBS signature, types the
|
|
593
|
+
# block body, and threads the body's type into
|
|
594
|
+
# `MethodDispatcher.dispatch`'s `block_type:` so generic
|
|
595
|
+
# methods like `Array#map { |n| n.to_s }` resolve to
|
|
596
|
+
# `Array[String]`).
|
|
597
|
+
#
|
|
598
|
+
# The handler still re-evaluates the block under its entry
|
|
599
|
+
# scope so the per-node scope index sees the bindings on the
|
|
600
|
+
# `on_enter` callback path. Block effects do NOT leak into the
|
|
601
|
+
# post-call scope: a block-local write is observed only
|
|
602
|
+
# inside the block body. The receiver and arguments still
|
|
603
|
+
# observe the outer scope, matching Ruby evaluation order.
|
|
604
|
+
def eval_call(node)
|
|
605
|
+
call_type = scope.type_of(node, tracer: tracer)
|
|
606
|
+
evaluate_block_if_present(node)
|
|
607
|
+
post_scope = record_closure_escape_if_any(node)
|
|
608
|
+
[call_type, post_scope]
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def evaluate_block_if_present(node)
|
|
612
|
+
block = node.block
|
|
613
|
+
return unless block.is_a?(Prism::BlockNode)
|
|
614
|
+
|
|
615
|
+
block_entry = build_block_entry_scope(node, block)
|
|
616
|
+
sub_eval(block, block_entry)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Slice 6 phase C sub-phase 3b/3c. When the call carries a
|
|
620
|
+
# block whose receiving method is NOT proven non-escaping:
|
|
621
|
+
#
|
|
622
|
+
# - 3b: attach a `dynamic_origin` `closure_escape` fact to the
|
|
623
|
+
# post-call scope so consumers can see that the closure may
|
|
624
|
+
# have been retained past the call.
|
|
625
|
+
# - 3c: drop the narrowed type of every captured outer local
|
|
626
|
+
# that the block body can rebind, replacing it with
|
|
627
|
+
# `Dynamic[Top]` through `Scope#with_local` (which also
|
|
628
|
+
# invalidates the local's `local_binding` facts). Locals
|
|
629
|
+
# shadowed by a block parameter or a `;`-prefixed
|
|
630
|
+
# block-local declaration are untouched. Locals the block
|
|
631
|
+
# only reads (without writing) are also untouched: read-only
|
|
632
|
+
# captures cannot rebind the outer variable.
|
|
633
|
+
#
|
|
634
|
+
# A `:non_escaping` classification (or any block-less call)
|
|
635
|
+
# leaves the post-call scope unchanged.
|
|
636
|
+
def record_closure_escape_if_any(node)
|
|
637
|
+
return scope unless node.block.is_a?(Prism::BlockNode)
|
|
638
|
+
|
|
639
|
+
classification = classify_closure_escape(node)
|
|
640
|
+
return scope if classification == :non_escaping
|
|
641
|
+
|
|
642
|
+
post_scope = drop_captured_narrowing(node.block, scope)
|
|
643
|
+
post_scope.with_fact(
|
|
644
|
+
Analysis::FactStore::Fact.new(
|
|
645
|
+
bucket: :dynamic_origin,
|
|
646
|
+
target: Analysis::FactStore::Target.new(kind: :closure, name: node.name.to_sym),
|
|
647
|
+
predicate: :closure_escape,
|
|
648
|
+
payload: { method_name: node.name.to_sym, classification: classification },
|
|
649
|
+
stability: :unstable
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def classify_closure_escape(call_node)
|
|
655
|
+
receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
|
|
656
|
+
ClosureEscapeAnalyzer.classify(
|
|
657
|
+
receiver_type: receiver_type,
|
|
658
|
+
method_name: call_node.name,
|
|
659
|
+
environment: scope.environment
|
|
660
|
+
)
|
|
661
|
+
rescue StandardError
|
|
662
|
+
:unknown
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Sub-phase 3c. Replace the outer-local types that the block
|
|
666
|
+
# body can rebind with `Dynamic[Top]`. The conservative drop
|
|
667
|
+
# matches the spec line "facts about locals it can write
|
|
668
|
+
# become unstable after the escape point": rather than
|
|
669
|
+
# synthesise the union of the block's write types (which the
|
|
670
|
+
# current pass does not yet expose), we discard the narrowed
|
|
671
|
+
# binding altogether. A future sub-phase MAY refine this to
|
|
672
|
+
# the union of the block's actual writes.
|
|
673
|
+
def drop_captured_narrowing(block_node, base_scope)
|
|
674
|
+
names = captured_local_writes(block_node, base_scope)
|
|
675
|
+
return base_scope if names.empty?
|
|
676
|
+
|
|
677
|
+
names.reduce(base_scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def captured_local_writes(block_node, base_scope)
|
|
681
|
+
body = block_node.body
|
|
682
|
+
return [] if body.nil?
|
|
683
|
+
|
|
684
|
+
introduced = block_introduced_locals(block_node)
|
|
685
|
+
outer_writes = []
|
|
686
|
+
Source::NodeWalker.each(body) do |descendant|
|
|
687
|
+
next unless descendant.is_a?(Prism::LocalVariableWriteNode)
|
|
688
|
+
next if introduced.include?(descendant.name)
|
|
689
|
+
next unless base_scope.locals.key?(descendant.name)
|
|
690
|
+
|
|
691
|
+
outer_writes << descendant.name
|
|
692
|
+
end
|
|
693
|
+
outer_writes.uniq
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Names introduced by the block itself (parameters, numbered
|
|
697
|
+
# parameters via `BlockParameterBinder`, plus explicit
|
|
698
|
+
# `;`-prefixed block-locals on `BlockParametersNode`). Writes
|
|
699
|
+
# to these names are local to the block and MUST NOT be
|
|
700
|
+
# treated as captured rebinds of an outer local.
|
|
701
|
+
def block_introduced_locals(block_node)
|
|
702
|
+
introduced = Set.new(BlockParameterBinder.new.bind(block_node).keys)
|
|
703
|
+
params_root = block_node.parameters
|
|
704
|
+
params_root.locals.each { |loc| introduced << loc.name } if params_root.is_a?(Prism::BlockParametersNode)
|
|
705
|
+
introduced
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# `Prism::BlockNode` is reached through {#eval_call}; the
|
|
709
|
+
# handler runs the body under `scope`, which the caller has
|
|
710
|
+
# already augmented with the block's parameter bindings. Effects
|
|
711
|
+
# do not leak past the block (the outer eval_call returns the
|
|
712
|
+
# caller's scope unchanged), but the body's local writes are
|
|
713
|
+
# threaded through subsequent statements *inside* the block so
|
|
714
|
+
# `each { |x| sum = x; sum.succ }` types `sum.succ` under the
|
|
715
|
+
# `sum: x` binding.
|
|
716
|
+
def eval_block(node)
|
|
717
|
+
return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
|
|
718
|
+
|
|
719
|
+
sub_eval(node.body, scope)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Builds the entry scope for a block body. The block sees the
|
|
723
|
+
# outer scope's locals (Ruby's lexical scoping rule) and adds
|
|
724
|
+
# bindings for every named block parameter on top. Parameter
|
|
725
|
+
# types come from the receiving method's RBS signature when
|
|
726
|
+
# one is available; the rest default to `Dynamic[Top]`.
|
|
727
|
+
def build_block_entry_scope(call_node, block_node)
|
|
728
|
+
expected = expected_block_param_types_for(call_node)
|
|
729
|
+
bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
|
|
730
|
+
bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def expected_block_param_types_for(call_node)
|
|
734
|
+
receiver_type = call_node.receiver ? scope.type_of(call_node.receiver, tracer: tracer) : nil
|
|
735
|
+
return [] if receiver_type.nil?
|
|
736
|
+
|
|
737
|
+
arg_types = call_arg_types_for(call_node)
|
|
738
|
+
MethodDispatcher.expected_block_param_types(
|
|
739
|
+
receiver_type: receiver_type,
|
|
740
|
+
method_name: call_node.name,
|
|
741
|
+
arg_types: arg_types,
|
|
742
|
+
environment: scope.environment
|
|
743
|
+
)
|
|
744
|
+
rescue StandardError
|
|
745
|
+
[]
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def call_arg_types_for(call_node)
|
|
749
|
+
arguments = call_node.arguments
|
|
750
|
+
return [] if arguments.nil?
|
|
751
|
+
|
|
752
|
+
arguments.arguments.map { |arg| scope.type_of(arg, tracer: tracer) }
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# ----- def/class helpers -----
|
|
756
|
+
|
|
757
|
+
def eval_class_body(node, new_context)
|
|
758
|
+
return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
|
|
759
|
+
|
|
760
|
+
# Class/module bodies run in a fresh scope: the outer scope's
|
|
761
|
+
# locals are NOT visible inside `class Foo; ... end`. We keep
|
|
762
|
+
# the same Environment so RBS lookups continue to work, and
|
|
763
|
+
# simply drop the locals. Slice A-engine: `self` inside a
|
|
764
|
+
# class body is the class object itself, so we set
|
|
765
|
+
# `self_type` to `Singleton[<qualified>]`.
|
|
766
|
+
fresh = build_fresh_body_scope
|
|
767
|
+
body_self = self_type_for_class_body(new_context)
|
|
768
|
+
fresh = fresh.with_self_type(body_self) if body_self
|
|
769
|
+
sub_eval(node.body, fresh, class_context: new_context)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def build_method_entry_scope(def_node)
|
|
773
|
+
singleton = singleton_def?(def_node)
|
|
774
|
+
binder = MethodParameterBinder.new(
|
|
775
|
+
environment: scope.environment,
|
|
776
|
+
class_path: current_class_path,
|
|
777
|
+
singleton: singleton
|
|
778
|
+
)
|
|
779
|
+
bindings = binder.bind(def_node)
|
|
780
|
+
|
|
781
|
+
# Method bodies do NOT see the outer scope's locals. They start
|
|
782
|
+
# from a fresh scope with the same environment, then receive
|
|
783
|
+
# the parameter bindings. Slice 7 phase 2: instance defs ALSO
|
|
784
|
+
# seed their `ivars` map from the class-level accumulator so
|
|
785
|
+
# `def get; @x; end` reads the type that a sibling
|
|
786
|
+
# `def init; @x = 1; end` wrote.
|
|
787
|
+
fresh = build_fresh_body_scope
|
|
788
|
+
body_self = self_type_for_method_body(singleton: singleton)
|
|
789
|
+
fresh = fresh.with_self_type(body_self) if body_self
|
|
790
|
+
fresh = seed_instance_ivars(fresh, singleton: singleton)
|
|
791
|
+
fresh = seed_class_cvars(fresh)
|
|
792
|
+
fresh = seed_program_globals(fresh)
|
|
793
|
+
bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def seed_instance_ivars(body_scope, singleton:)
|
|
797
|
+
return body_scope if singleton
|
|
798
|
+
|
|
799
|
+
path = current_class_path
|
|
800
|
+
return body_scope if path.nil?
|
|
801
|
+
|
|
802
|
+
seeded = scope.class_ivars_for(path)
|
|
803
|
+
return body_scope if seeded.empty?
|
|
804
|
+
|
|
805
|
+
seeded.reduce(body_scope) { |acc, (name, type)| acc.with_ivar(name, type) }
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# Cvars are visible from BOTH instance and singleton method
|
|
809
|
+
# bodies of the enclosing class, so this seed is unconditional
|
|
810
|
+
# (no `singleton:` gate). At the top-level (no class context)
|
|
811
|
+
# the accumulator is empty and the seed is a no-op.
|
|
812
|
+
def seed_class_cvars(body_scope)
|
|
813
|
+
path = current_class_path
|
|
814
|
+
return body_scope if path.nil?
|
|
815
|
+
|
|
816
|
+
seeded = scope.class_cvars_for(path)
|
|
817
|
+
return body_scope if seeded.empty?
|
|
818
|
+
|
|
819
|
+
seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Globals are process-wide. The body scope already inherited
|
|
823
|
+
# the program-globals accumulator through `with_program_globals`;
|
|
824
|
+
# seeding here just materialises each entry into the body's
|
|
825
|
+
# `globals` map so reads observe a precise type without
|
|
826
|
+
# consulting the accumulator on every lookup.
|
|
827
|
+
def seed_program_globals(body_scope)
|
|
828
|
+
seeded = scope.program_globals
|
|
829
|
+
return body_scope if seeded.empty?
|
|
830
|
+
|
|
831
|
+
seeded.reduce(body_scope) { |acc, (name, type)| acc.with_global(name, type) }
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# Slice A-declarations. Class- and method-bodies start from a
|
|
835
|
+
# fresh local-empty scope, but they MUST keep the
|
|
836
|
+
# `declared_types` table visible at the outer scope so the
|
|
837
|
+
# ScopeIndexer-populated declaration overrides
|
|
838
|
+
# (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
|
|
839
|
+
# remain reachable from inside nested bodies.
|
|
840
|
+
def build_fresh_body_scope
|
|
841
|
+
Scope.empty(environment: scope.environment)
|
|
842
|
+
.with_declared_types(scope.declared_types)
|
|
843
|
+
.with_discovered_classes(scope.discovered_classes)
|
|
844
|
+
.with_in_source_constants(scope.in_source_constants)
|
|
845
|
+
.with_class_ivars(scope.class_ivars)
|
|
846
|
+
.with_class_cvars(scope.class_cvars)
|
|
847
|
+
.with_program_globals(scope.program_globals)
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def singleton_def?(def_node)
|
|
851
|
+
def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# Slice A-engine. Inside a class body `class Foo; ...; end`,
|
|
855
|
+
# `self` is the class object — `Singleton[Foo]`. Returns nil
|
|
856
|
+
# at the top level (no enclosing class).
|
|
857
|
+
def self_type_for_class_body(class_context)
|
|
858
|
+
return nil if class_context.empty?
|
|
859
|
+
|
|
860
|
+
Type::Combinator.singleton_of(class_context.map(&:name).join("::"))
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Slice A-engine. Inside a method body, `self` depends on
|
|
864
|
+
# whether the def is on the singleton or instance side of the
|
|
865
|
+
# surrounding class:
|
|
866
|
+
#
|
|
867
|
+
# - `def self.foo` or any def inside `class << self`: self is
|
|
868
|
+
# the class object → `Singleton[Foo]`.
|
|
869
|
+
# - ordinary instance `def foo`: self is an instance →
|
|
870
|
+
# `Nominal[Foo]`.
|
|
871
|
+
#
|
|
872
|
+
# Returns nil for top-level defs that have no enclosing class.
|
|
873
|
+
def self_type_for_method_body(singleton:)
|
|
874
|
+
path = current_class_path
|
|
875
|
+
return nil if path.nil?
|
|
876
|
+
|
|
877
|
+
if singleton
|
|
878
|
+
Type::Combinator.singleton_of(path)
|
|
879
|
+
else
|
|
880
|
+
Type::Combinator.nominal_of(path)
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def qualified_name_for(constant_path_node)
|
|
885
|
+
case constant_path_node
|
|
886
|
+
when Prism::ConstantReadNode
|
|
887
|
+
constant_path_node.name.to_s
|
|
888
|
+
when Prism::ConstantPathNode
|
|
889
|
+
render_constant_path(constant_path_node)
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def render_constant_path(node)
|
|
894
|
+
prefix =
|
|
895
|
+
case node.parent
|
|
896
|
+
when Prism::ConstantReadNode then "#{node.parent.name}::"
|
|
897
|
+
when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
|
|
898
|
+
else ""
|
|
899
|
+
end
|
|
900
|
+
"#{prefix}#{node.name}"
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def singleton_context_for(node)
|
|
904
|
+
return @class_context unless node.expression.is_a?(Prism::SelfNode)
|
|
905
|
+
return @class_context if @class_context.empty?
|
|
906
|
+
|
|
907
|
+
outer = @class_context[0..-2]
|
|
908
|
+
last = @class_context.last
|
|
909
|
+
outer + [ClassFrame.new(name: last.name, singleton: true)]
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# The qualified name of the immediately-enclosing class (joining
|
|
913
|
+
# every nested `ClassFrame` with `::`). Returns `nil` for a
|
|
914
|
+
# top-level def with no enclosing class, which routes the
|
|
915
|
+
# parameter binder past RBS lookup.
|
|
916
|
+
def current_class_path
|
|
917
|
+
return nil if @class_context.empty?
|
|
918
|
+
|
|
919
|
+
@class_context.map(&:name).join("::")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def current_frame_singleton?
|
|
923
|
+
@class_context.last&.singleton == true
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# ----- helpers -----
|
|
927
|
+
|
|
928
|
+
def sub_eval(node, with_scope, class_context: @class_context)
|
|
929
|
+
StatementEvaluator.new(
|
|
930
|
+
scope: with_scope,
|
|
931
|
+
tracer: tracer,
|
|
932
|
+
on_enter: @on_enter,
|
|
933
|
+
class_context: class_context
|
|
934
|
+
).evaluate(node)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Slice 7 phase 14 — branch exit detection. Returns true
|
|
938
|
+
# when the branch's body unconditionally exits the
|
|
939
|
+
# surrounding control flow through a `return`, `next`,
|
|
940
|
+
# `break`, or `raise`. Used by `eval_if` / `eval_unless`
|
|
941
|
+
# to narrow the post-scope: when one branch exits, the
|
|
942
|
+
# surrounding scope can carry the OTHER branch's edge
|
|
943
|
+
# forward without nil-injection.
|
|
944
|
+
#
|
|
945
|
+
# The detection is intentionally conservative — it
|
|
946
|
+
# recognises only the most common patterns:
|
|
947
|
+
# - A `Prism::ReturnNode`, `NextNode`, `BreakNode`.
|
|
948
|
+
# - A `Prism::CallNode` whose name is `:raise` or `:throw`.
|
|
949
|
+
# - A `Prism::StatementsNode`, `Prism::ParenthesesNode`, or
|
|
950
|
+
# `Prism::IfNode`/`UnlessNode` whose final / both
|
|
951
|
+
# branches recursively exit.
|
|
952
|
+
EXIT_CALL_NAMES = %i[raise throw exit abort fail].freeze
|
|
953
|
+
private_constant :EXIT_CALL_NAMES
|
|
954
|
+
|
|
955
|
+
def branch_unconditionally_exits?(node) # rubocop:disable Metrics/CyclomaticComplexity
|
|
956
|
+
return false if node.nil?
|
|
957
|
+
|
|
958
|
+
case node
|
|
959
|
+
when Prism::ReturnNode, Prism::NextNode, Prism::BreakNode
|
|
960
|
+
true
|
|
961
|
+
when Prism::CallNode
|
|
962
|
+
node.receiver.nil? && EXIT_CALL_NAMES.include?(node.name)
|
|
963
|
+
when Prism::StatementsNode
|
|
964
|
+
last = node.body.last
|
|
965
|
+
branch_unconditionally_exits?(last)
|
|
966
|
+
when Prism::ParenthesesNode
|
|
967
|
+
branch_unconditionally_exits?(node.body)
|
|
968
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
969
|
+
branch_unconditionally_exits?(node.statements) &&
|
|
970
|
+
branch_unconditionally_exits?(node_else_branch(node))
|
|
971
|
+
else
|
|
972
|
+
false
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def node_else_branch(node)
|
|
977
|
+
case node
|
|
978
|
+
when Prism::IfNode then node.subsequent
|
|
979
|
+
when Prism::UnlessNode then node.else_clause
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def eval_branch_or_nil(branch_node, branch_scope)
|
|
984
|
+
return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
|
|
985
|
+
|
|
986
|
+
sub_eval(branch_node, branch_scope)
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Joins two branch scopes at a control-flow merge point. Names
|
|
990
|
+
# bound in only one branch are nil-injected into the other side
|
|
991
|
+
# so the joined scope sees them as `T | nil` rather than dropping
|
|
992
|
+
# them outright. This implements the contract the Slice 3 phase 1
|
|
993
|
+
# `Scope#join` documentation defers to the statement-level
|
|
994
|
+
# evaluator.
|
|
995
|
+
def join_with_nil_injection(scope_a, scope_b)
|
|
996
|
+
nil_const = Type::Combinator.constant_of(nil)
|
|
997
|
+
a_keys = scope_a.locals.keys
|
|
998
|
+
b_keys = scope_b.locals.keys
|
|
999
|
+
a_only = a_keys - b_keys
|
|
1000
|
+
b_only = b_keys - a_keys
|
|
1001
|
+
|
|
1002
|
+
aug_a = b_only.reduce(scope_a) { |acc, name| acc.with_local(name, nil_const) }
|
|
1003
|
+
aug_b = a_only.reduce(scope_b) { |acc, name| acc.with_local(name, nil_const) }
|
|
1004
|
+
aug_a.join(aug_b)
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# Generalises {#join_with_nil_injection} to N branches (case/when,
|
|
1008
|
+
# begin/rescue chain). The reduce order does not affect the
|
|
1009
|
+
# result because nil-injection commutes with union under
|
|
1010
|
+
# `Scope#join`.
|
|
1011
|
+
def reduce_scopes_with_nil_injection(scopes)
|
|
1012
|
+
scopes.reduce { |a, b| join_with_nil_injection(a, b) }
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
# rubocop:enable Metrics/ClassLength
|
|
1016
|
+
end
|
|
1017
|
+
end
|