rigortype 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/docs/handbook/11-sig-gen.md +12 -5
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +6 -4
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +16 -9
- data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +23 -22
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +89 -22
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +119 -15
- data/lib/rigor/analysis/reachability/scan.rb +332 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/run_cache_probe.rb +1 -1
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner.rb +29 -11
- data/lib/rigor/analysis/worker_session.rb +1 -1
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +36 -8
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +33 -0
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/expression_typer.rb +58 -25
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer.rb +140 -5
- data/lib/rigor/inference/statement_evaluator.rb +223 -31
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +118 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +433 -0
- data/lib/rigor/lens/members.rb +153 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +280 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +40 -0
- data/lib/rigor/project_environment.rb +20 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +22 -11
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +18 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "anchor"
|
|
6
|
+
require_relative "slot"
|
|
7
|
+
require_relative "parameters"
|
|
8
|
+
require_relative "../inference/def_return_typer"
|
|
9
|
+
require_relative "../inference/origin_lookup"
|
|
10
|
+
require_relative "../sig_gen/type_elaborator"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
module Lens
|
|
14
|
+
# Reads one analysed file into sites and omissions (docs/internal-spec/lens.md § Files, § Sites). Runs inside
|
|
15
|
+
# the check run, on the seeded scope index the check rules read, so a body return is the type `check` gives
|
|
16
|
+
# that body. Every declaration the walk meets is either a site or an omission; none is dropped.
|
|
17
|
+
class FileReader # rubocop:disable Metrics/ClassLength
|
|
18
|
+
# A site plus what the member pass needs from it: the declaration node and the contract comments above it.
|
|
19
|
+
Site = Data.define(:data, :node, :contract_comments)
|
|
20
|
+
Result = Data.define(:sites, :omissions, :digest)
|
|
21
|
+
# The running visibility of one class body: Ruby's default for its later `def`s, and whether a toggle the
|
|
22
|
+
# walk cannot place (under an `if`, a `begin`, a block) leaves that default unknown.
|
|
23
|
+
# A `detached` body stands for code that is not the class body (a block, a method body, a superclass
|
|
24
|
+
# expression, an omitted class): its toggles and named calls stay local and change no site.
|
|
25
|
+
Body = Struct.new(:current, :uncertain, :detached)
|
|
26
|
+
|
|
27
|
+
ATTRIBUTE_CALLS = { attr_reader: "reader", attr_writer: "writer", attr_accessor: "accessor" }.freeze
|
|
28
|
+
OMITTED_CALLS = {
|
|
29
|
+
alias_method: "alias", define_method: "define_method", module_function: "module_function",
|
|
30
|
+
refine: "refine"
|
|
31
|
+
}.freeze
|
|
32
|
+
VISIBILITY_CALLS = %i[private protected public].freeze
|
|
33
|
+
CLASS_VISIBILITY_CALLS = { private_class_method: "private", public_class_method: "public" }.freeze
|
|
34
|
+
BLOCK_NODES = [Prism::BlockNode, Prism::LambdaNode].freeze
|
|
35
|
+
CONSTANT_WRITES = [
|
|
36
|
+
Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
|
|
37
|
+
Prism::ConstantPathOrWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantPathAndWriteNode,
|
|
38
|
+
Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# @param path — the file, relative to the project root
|
|
42
|
+
# @param spell — callable `(type, owner) -> String` spelling a type as sig-gen writes it
|
|
43
|
+
def initialize(path:, parse_result:, index:, spell:)
|
|
44
|
+
@path = path
|
|
45
|
+
@root = parse_result.value
|
|
46
|
+
@source = parse_result.source.source
|
|
47
|
+
@index = index
|
|
48
|
+
@spell = spell
|
|
49
|
+
@anchor = Anchor.new(@source, parse_result.comments)
|
|
50
|
+
@bodies = []
|
|
51
|
+
@node_visibility = {}.compare_by_identity
|
|
52
|
+
@sites = []
|
|
53
|
+
@omissions = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read
|
|
57
|
+
walk(@root, [], singleton: false, in_block: false)
|
|
58
|
+
Result.new(sites: @sites, omissions: @omissions, digest: Lens.digest(@source.b))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def walk(node, nesting, singleton:, in_block:)
|
|
64
|
+
case node
|
|
65
|
+
when Prism::ClassNode, Prism::ModuleNode then walk_namespace(node, nesting, in_block)
|
|
66
|
+
when Prism::SingletonClassNode then walk_singleton_class(node, nesting, singleton, in_block)
|
|
67
|
+
when Prism::DefNode then read_def(node, nesting, singleton, in_block)
|
|
68
|
+
when Prism::CallNode then walk_call(node, nesting, singleton, in_block)
|
|
69
|
+
when Prism::AliasMethodNode
|
|
70
|
+
omit("alias", node.new_name.slice.delete_prefix(":"), node, owner: nesting.last, singleton: singleton)
|
|
71
|
+
when Prism::MultiWriteNode then walk_multi_write(node, nesting, singleton, in_block)
|
|
72
|
+
when *CONSTANT_WRITES then walk_constant(node, nesting)
|
|
73
|
+
else walk_children(node, nesting, singleton, in_block)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def walk_children(node, nesting, singleton, in_block)
|
|
78
|
+
node.compact_child_nodes.each do |child|
|
|
79
|
+
if BLOCK_NODES.include?(child.class)
|
|
80
|
+
detached { walk(child, nesting, singleton: singleton, in_block: true) }
|
|
81
|
+
else
|
|
82
|
+
walk(child, nesting, singleton: singleton, in_block: in_block)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def detached
|
|
88
|
+
@bodies.push(Body.new("public", false, true))
|
|
89
|
+
yield
|
|
90
|
+
ensure
|
|
91
|
+
@bodies.pop
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def walk_namespace(node, nesting, in_block)
|
|
95
|
+
kind = node.is_a?(Prism::ClassNode) ? "class" : "module"
|
|
96
|
+
name = qualify(nesting, node.constant_path)
|
|
97
|
+
if node.is_a?(Prism::ClassNode) && node.superclass
|
|
98
|
+
detached { walk(node.superclass, nesting, singleton: false, in_block: true) }
|
|
99
|
+
end
|
|
100
|
+
if in_block || name.nil?
|
|
101
|
+
omit(kind, name || node.constant_path.slice, node)
|
|
102
|
+
detached { walk(node.body, nesting, singleton: false, in_block: true) } if node.body
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
add_site(node, { "kind" => kind, "name" => name, "owner" => nil, "scope" => nil, "attribute" => nil,
|
|
107
|
+
"members" => [], "visibility" => nil, "params" => nil, "block" => nil,
|
|
108
|
+
"body_return" => nil })
|
|
109
|
+
walk_body(node.body, nesting + [name], singleton: false)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `class << self` directly in a class or module body opens its singleton class. Any other `class << x`, one
|
|
113
|
+
# nested in another, or one at the top level (which opens `main`'s) is not modelled.
|
|
114
|
+
def walk_singleton_class(node, nesting, singleton, in_block)
|
|
115
|
+
if in_block || singleton || nesting.empty? || !node.expression.is_a?(Prism::SelfNode)
|
|
116
|
+
omit("singleton_class", nil, node)
|
|
117
|
+
return detached { walk(node.body, nesting, singleton: singleton, in_block: true) } if node.body
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
walk_body(node.body, nesting, singleton: true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A class body's statements, tracking the running visibility and `module_function`. A bare `module_function`
|
|
124
|
+
# makes every later `def` of the body a method on both sides: one site, two members, which revision 1 does
|
|
125
|
+
# not model.
|
|
126
|
+
def walk_body(body, nesting, singleton:)
|
|
127
|
+
return if body.nil?
|
|
128
|
+
|
|
129
|
+
@bodies.push(Body.new("public", false, false))
|
|
130
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
131
|
+
module_function_on = false
|
|
132
|
+
statements.each do |child|
|
|
133
|
+
if module_function_on && child.is_a?(Prism::DefNode)
|
|
134
|
+
omit("module_function", child.name.to_s, child, owner: nesting.last, singleton: singleton)
|
|
135
|
+
next
|
|
136
|
+
end
|
|
137
|
+
module_function_on = true if bare_call?(child, :module_function)
|
|
138
|
+
next if toggle_visibility?(child)
|
|
139
|
+
|
|
140
|
+
visibility_call(child, nesting, singleton, direct: true) if visibility_call?(child)
|
|
141
|
+
walk(child, nesting, singleton: singleton, in_block: false)
|
|
142
|
+
end
|
|
143
|
+
ensure
|
|
144
|
+
@bodies.pop if body
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A bare `private` / `protected` / `public` written directly in the body sets the default for later defs.
|
|
148
|
+
def toggle_visibility?(child)
|
|
149
|
+
return false unless VISIBILITY_CALLS.any? { |name| bare_call?(child, name) }
|
|
150
|
+
|
|
151
|
+
@bodies.last.current = child.name.to_s
|
|
152
|
+
@bodies.last.uncertain = false
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def walk_call(node, nesting, singleton, in_block)
|
|
157
|
+
if node.receiver.nil? && ATTRIBUTE_CALLS.key?(node.name)
|
|
158
|
+
read_attribute(node, nesting, singleton, in_block)
|
|
159
|
+
elsif node.receiver.nil? && OMITTED_CALLS.key?(node.name)
|
|
160
|
+
return if bare_call?(node, :module_function)
|
|
161
|
+
|
|
162
|
+
omit(OMITTED_CALLS.fetch(node.name), first_argument_name(node), node,
|
|
163
|
+
owner: node.name == :refine ? nil : nesting.last, singleton: singleton)
|
|
164
|
+
walk_children(node, nesting, singleton, true) unless node.name == :refine
|
|
165
|
+
else
|
|
166
|
+
@visited_visibility_calls ||= {}.compare_by_identity
|
|
167
|
+
if visibility_call?(node) && !@visited_visibility_calls.key?(node)
|
|
168
|
+
visibility_call(node, nesting, singleton, direct: false)
|
|
169
|
+
end
|
|
170
|
+
walk_children(node, nesting, singleton, in_block)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def visibility_call?(node)
|
|
175
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
176
|
+
(VISIBILITY_CALLS.include?(node.name) || CLASS_VISIBILITY_CALLS.key?(node.name))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# `private :x`, `private %i[x]`, `private def x`, `private attr_reader :x`, `private_class_method :x`, and a
|
|
180
|
+
# bare toggle the walk cannot place in the body's statement list. A named call changes members only when it
|
|
181
|
+
# is written directly in the body (`direct`); one under an `if`, a block or a method body runs at a time
|
|
182
|
+
# Rigor does not know, so the members it names read unknown.
|
|
183
|
+
def visibility_call(node, nesting, singleton, direct:)
|
|
184
|
+
@visited_visibility_calls ||= {}.compare_by_identity
|
|
185
|
+
@visited_visibility_calls[node] = true
|
|
186
|
+
direct &&= !@bodies.last&.detached
|
|
187
|
+
if VISIBILITY_CALLS.include?(node.name)
|
|
188
|
+
return mark_uncertain if node.arguments.nil?
|
|
189
|
+
|
|
190
|
+
apply_visibility(node, node.name.to_s, nesting.last, singleton, direct)
|
|
191
|
+
else
|
|
192
|
+
apply_visibility(node, CLASS_VISIBILITY_CALLS.fetch(node.name), nesting.last, true, direct)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def mark_uncertain
|
|
197
|
+
@bodies.last&.uncertain = true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def apply_visibility(node, visibility, owner, singleton, direct)
|
|
201
|
+
scope = singleton ? "singleton" : "instance"
|
|
202
|
+
node.arguments.arguments.each do |argument|
|
|
203
|
+
names = argument_names(argument)
|
|
204
|
+
if wrapped_declaration?(argument, singleton)
|
|
205
|
+
@node_visibility[argument] = visibility
|
|
206
|
+
elsif argument.is_a?(Prism::DefNode)
|
|
207
|
+
# `private def self.x`: Ruby looks `x` up among instance methods and rejects it, so the result is unknown.
|
|
208
|
+
@node_visibility[argument] = nil
|
|
209
|
+
elsif names.nil?
|
|
210
|
+
# `private *NAMES`: which members change is unknown.
|
|
211
|
+
unknown_visibility(owner, scope)
|
|
212
|
+
else
|
|
213
|
+
names.each { |name| name_visibility(owner, scope, name, direct ? visibility : nil) }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A `def` or `attr_*` the visibility call wraps. `private def self.x` is not one: `private` looks the
|
|
219
|
+
# name up among instance methods.
|
|
220
|
+
def wrapped_declaration?(argument, singleton)
|
|
221
|
+
case argument
|
|
222
|
+
when Prism::DefNode then argument.receiver.nil? || singleton
|
|
223
|
+
when Prism::CallNode then argument.receiver.nil? && ATTRIBUTE_CALLS.key?(argument.name)
|
|
224
|
+
else false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The names a visibility call's argument lists, or nil when it is not a literal list of them.
|
|
229
|
+
def argument_names(argument)
|
|
230
|
+
case argument
|
|
231
|
+
when Prism::SymbolNode, Prism::StringNode then [argument.unescaped]
|
|
232
|
+
when Prism::ArrayNode
|
|
233
|
+
names = argument.elements.map { |element| argument_names(element)&.first }
|
|
234
|
+
names.include?(nil) ? nil : names
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def unknown_visibility(owner, scope)
|
|
239
|
+
@sites.each do |site|
|
|
240
|
+
site.data["visibility"] = nil if site.data["owner"] == owner && site.data["scope"] == scope
|
|
241
|
+
end
|
|
242
|
+
mark_uncertain
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# `private :x` changes the visibility of the members already defined under that name in this file.
|
|
246
|
+
def name_visibility(owner, scope, name, visibility)
|
|
247
|
+
@sites.each do |site|
|
|
248
|
+
data = site.data
|
|
249
|
+
next unless data["owner"] == owner && data["scope"] == scope && data["members"].include?(name)
|
|
250
|
+
|
|
251
|
+
data["visibility"] = data["members"] == [name] ? visibility : nil
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def walk_multi_write(node, nesting, singleton, in_block)
|
|
256
|
+
(node.lefts + [node.rest&.expression].compact + node.rights).each do |target|
|
|
257
|
+
next unless target.is_a?(Prism::ConstantTargetNode) || target.is_a?(Prism::ConstantPathTargetNode)
|
|
258
|
+
|
|
259
|
+
omit("constant", qualify(nesting, target) || target.slice, target)
|
|
260
|
+
end
|
|
261
|
+
walk(node.value, nesting, singleton: singleton, in_block: in_block)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def walk_constant(node, nesting)
|
|
265
|
+
target = node.respond_to?(:target) ? node.target : node
|
|
266
|
+
name = node.respond_to?(:target) ? qualify(nesting, node.target) : qualify_name(nesting, node.name.to_s)
|
|
267
|
+
kind = case data_or_struct(node.value)
|
|
268
|
+
when :data then "data_class"
|
|
269
|
+
when :struct then "struct_class"
|
|
270
|
+
else "constant"
|
|
271
|
+
end
|
|
272
|
+
omit(kind, name || target.slice, node)
|
|
273
|
+
walk(node.value, nesting, singleton: false, in_block: true)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def read_def(node, nesting, singleton, in_block)
|
|
277
|
+
scope = def_scope(node, nesting, singleton)
|
|
278
|
+
if in_block || scope.nil?
|
|
279
|
+
omit("method", node.name.to_s, node)
|
|
280
|
+
else
|
|
281
|
+
owner = nesting.last || "Object"
|
|
282
|
+
data = { "kind" => "method", "name" => node.name.to_s, "owner" => owner, "scope" => scope.to_s,
|
|
283
|
+
"attribute" => nil, "members" => [node.name.to_s],
|
|
284
|
+
"visibility" => visibility(node, nesting, scope) }
|
|
285
|
+
params, block, = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
|
|
286
|
+
shape_params, shape_block = Parameters.shape(params, block)
|
|
287
|
+
data.merge!("params" => shape_params, "block" => shape_block,
|
|
288
|
+
"body_return" => body_return(node, owner, scope))
|
|
289
|
+
add_site(node, data)
|
|
290
|
+
end
|
|
291
|
+
# A declaration inside a method body runs only when the method does: not modelled.
|
|
292
|
+
detached { walk(node.body, nesting, singleton: singleton, in_block: true) } if node.body
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def read_attribute(node, nesting, singleton, in_block)
|
|
296
|
+
arguments = node.arguments&.arguments || []
|
|
297
|
+
names = arguments.map { |arg| arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode) ? arg.unescaped : nil }
|
|
298
|
+
if in_block || nesting.empty? || names.empty? || names.include?(nil)
|
|
299
|
+
return omit("attribute", names.compact.first, node)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
owner = nesting.last
|
|
303
|
+
scope = singleton ? :singleton : :instance
|
|
304
|
+
attribute = ATTRIBUTE_CALLS.fetch(node.name)
|
|
305
|
+
names.each_with_index do |name, ordinal|
|
|
306
|
+
add_site(node, { "kind" => "attribute", "name" => name, "owner" => owner, "scope" => scope.to_s,
|
|
307
|
+
"attribute" => attribute, "members" => attribute_members(name, attribute),
|
|
308
|
+
"visibility" => visibility(node, nesting, scope),
|
|
309
|
+
"params" => nil, "block" => nil, "body_return" => nil }, ordinal: ordinal)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def add_site(node, data, ordinal: 0)
|
|
314
|
+
location = node.location
|
|
315
|
+
anchor = @anchor.for_span(location.start_offset, location.end_offset, location.start_line,
|
|
316
|
+
location.end_line)
|
|
317
|
+
key = "#{@path}:#{location.start_offset}-#{location.end_offset}:#{ordinal}"
|
|
318
|
+
tail = { "ordinal" => ordinal, "span" => Lens.span(location),
|
|
319
|
+
"contract_span" => Lens.contract_span(anchor.contract_comments), "anchor" => anchor.token,
|
|
320
|
+
"contract" => nil }
|
|
321
|
+
@sites << Site.new(data: { "key" => key }.merge(data).merge(tail), node: node,
|
|
322
|
+
contract_comments: anchor.contract_comments)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# `owner` / `singleton` name the member the declaration would define, when Rigor can tell.
|
|
326
|
+
def omit(kind, name, node, owner: nil, singleton: false)
|
|
327
|
+
member = owner && name
|
|
328
|
+
@omissions << { "kind" => kind, "owner" => member ? owner : nil,
|
|
329
|
+
"scope" => member ? scope_name(singleton) : nil, "name" => name,
|
|
330
|
+
"span" => Lens.span(node.location), "reason" => "unsupported_kind" }
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def scope_name(singleton)
|
|
335
|
+
singleton ? "singleton" : "instance"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def attribute_members(name, attribute)
|
|
339
|
+
case attribute
|
|
340
|
+
when "reader" then [name]
|
|
341
|
+
when "writer" then ["#{name}="]
|
|
342
|
+
else [name, "#{name}="]
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# The member's visibility as Ruby gives it: a `private def` / `private attr_reader` wrapper, else the running
|
|
347
|
+
# default of the body (unknown after a toggle the walk could not place). A class body's toggles do not reach
|
|
348
|
+
# a `def self.x`. A later `private :x` revises it ({#name_visibility}). A top-level `def` is private.
|
|
349
|
+
def visibility(node, nesting, scope)
|
|
350
|
+
return "private" if nesting.empty?
|
|
351
|
+
return @node_visibility[node] if @node_visibility.key?(node)
|
|
352
|
+
return "public" if scope == :singleton && node.is_a?(Prism::DefNode) && node.receiver
|
|
353
|
+
|
|
354
|
+
body = @bodies.last
|
|
355
|
+
return nil if body.nil? || body.uncertain
|
|
356
|
+
|
|
357
|
+
body.current
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# `def x` in a class body is an instance method, in `class << self` a singleton one; `def self.x` is a
|
|
361
|
+
# singleton method of a class body. A `def self.x` inside `class << self`, a top-level `def self.x`, and any
|
|
362
|
+
# other receiver define methods revision 1 does not model.
|
|
363
|
+
def def_scope(node, nesting, singleton)
|
|
364
|
+
return (singleton ? :singleton : :instance) if node.receiver.nil?
|
|
365
|
+
return nil unless node.receiver.is_a?(Prism::SelfNode)
|
|
366
|
+
return nil if singleton || nesting.empty?
|
|
367
|
+
|
|
368
|
+
:singleton
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The body's return under the seeded index, with its parameters as declared (§ Sites).
|
|
372
|
+
def body_return(node, owner, scope)
|
|
373
|
+
return Slot.typed("void", "inferred") if scope == :instance && node.name == :initialize
|
|
374
|
+
return Slot.typed("nil", "inferred") if node.body.nil?
|
|
375
|
+
|
|
376
|
+
type = Inference::DefReturnTyper.call(node, @index)
|
|
377
|
+
return Slot.unavailable("not_computed") if type.nil?
|
|
378
|
+
return Slot.dynamic("inferred", return_cause(node)) if type.is_a?(Type::Dynamic)
|
|
379
|
+
|
|
380
|
+
spelled = @spell.call(type, owner)
|
|
381
|
+
spelled == "untyped" ? Slot.dynamic("inferred", return_cause(node)) : Slot.typed(spelled, "inferred")
|
|
382
|
+
rescue StandardError
|
|
383
|
+
Slot.unavailable("not_computed")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def return_cause(node)
|
|
387
|
+
last = Inference::DefReturnTyper.body_last_expression(node.body)
|
|
388
|
+
scope = last && (@index[last] || @index[node.body])
|
|
389
|
+
scope && Inference::OriginLookup.origin_for(scope, last)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The qualified name a constant path declares under `nesting`, or nil when it is not a plain constant path
|
|
393
|
+
# (`self::Foo`, `expr::Foo`).
|
|
394
|
+
def qualify(nesting, path)
|
|
395
|
+
case path
|
|
396
|
+
when Prism::ConstantReadNode, Prism::ConstantTargetNode then qualify_name(nesting, path.name.to_s)
|
|
397
|
+
when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
|
|
398
|
+
return path.name.to_s if path.parent.nil?
|
|
399
|
+
|
|
400
|
+
parent = qualify(nesting, path.parent)
|
|
401
|
+
parent && "#{parent}::#{path.name}"
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def qualify_name(nesting, name)
|
|
406
|
+
nesting.empty? ? name : "#{nesting.last}::#{name}"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def bare_call?(node, name)
|
|
410
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == name && node.arguments.nil? &&
|
|
411
|
+
node.block.nil?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def first_argument_name(node)
|
|
415
|
+
argument = node.arguments&.arguments&.first
|
|
416
|
+
case argument
|
|
417
|
+
when Prism::SymbolNode, Prism::StringNode then argument.unescaped
|
|
418
|
+
when Prism::DefNode then argument.name.to_s
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def data_or_struct(value)
|
|
423
|
+
return nil unless value.is_a?(Prism::CallNode) && value.receiver
|
|
424
|
+
|
|
425
|
+
receiver = value.receiver.slice.delete_prefix("::")
|
|
426
|
+
return :data if receiver == "Data" && value.name == :define
|
|
427
|
+
return :struct if receiver == "Struct" && value.name == :new
|
|
428
|
+
|
|
429
|
+
nil
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "slot"
|
|
4
|
+
require_relative "parameters"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Lens
|
|
8
|
+
# Groups the sites of the requested files into members and fills their signatures (docs/internal-spec/lens.md
|
|
9
|
+
# § Members). A member is one `(owner, scope, Ruby method name)`; an `attr_accessor :a` site defines `a` and
|
|
10
|
+
# `a=`. An inferred slot comes from a site's body only when the member has exactly one project definition.
|
|
11
|
+
class Members
|
|
12
|
+
# @param sites — every {FileReader::Site} of the requested files
|
|
13
|
+
# @param omissions — every omission of the requested files
|
|
14
|
+
# @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
|
|
15
|
+
# @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
|
|
16
|
+
# @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
|
|
17
|
+
def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:)
|
|
18
|
+
@sites = sites
|
|
19
|
+
@omissions = omissions
|
|
20
|
+
@contracts = contracts
|
|
21
|
+
@elsewhere = elsewhere
|
|
22
|
+
@site_paths = site_paths
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build
|
|
26
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
27
|
+
@sites.each do |site|
|
|
28
|
+
site.data["members"].each { |name| groups[[site.data["owner"], site.data["scope"], name]] << site }
|
|
29
|
+
end
|
|
30
|
+
result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
|
|
31
|
+
mark_inline_sites
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def member(key, sites)
|
|
38
|
+
owner, scope, name = key
|
|
39
|
+
contract = @contracts[key]
|
|
40
|
+
also = @elsewhere.call(owner, scope, name)
|
|
41
|
+
omitted = @omissions.count { |omission| [omission["owner"], omission["scope"], omission["name"]] == key }
|
|
42
|
+
single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
|
|
43
|
+
{ "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
|
|
44
|
+
"visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
|
|
45
|
+
"also_defined_at" => also, "basis" => basis(contract, single),
|
|
46
|
+
"overloads" => overloads(contract, sites.first, name, single) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
|
|
50
|
+
def member_kind(sites)
|
|
51
|
+
sites.any? { |site| site.data["kind"] == "method" } ? "method" : "attribute"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def basis(contract, single)
|
|
55
|
+
return "contract" if contract
|
|
56
|
+
return "single_site" if single
|
|
57
|
+
|
|
58
|
+
"contested"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def common_visibility(sites)
|
|
62
|
+
values = sites.map { |site| site.data["visibility"] }.uniq
|
|
63
|
+
values.size == 1 ? values.first : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A contested member without a contract has no overloads: its definitions may differ in arity, and each
|
|
67
|
+
# method site carries its own parameter list.
|
|
68
|
+
def overloads(contract, site, name, single)
|
|
69
|
+
return [] if contract.nil? && single.nil?
|
|
70
|
+
|
|
71
|
+
overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
|
|
72
|
+
overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
|
|
76
|
+
# contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
|
|
77
|
+
# defaulted one.
|
|
78
|
+
def contract_overloads(contract, single)
|
|
79
|
+
contract.overloads.map do |overload|
|
|
80
|
+
next overload.except("return_rbs") unless contract.return_inferred
|
|
81
|
+
|
|
82
|
+
slot = inferred_return(single)
|
|
83
|
+
params = overload["params"]&.map do |param|
|
|
84
|
+
param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
|
|
85
|
+
end
|
|
86
|
+
rbs = overload["rbs"]
|
|
87
|
+
suffix = overload["return_rbs"]
|
|
88
|
+
rbs = rbs.delete_suffix(suffix) + return_spelling(slot) if rbs && suffix && rbs.end_with?(suffix)
|
|
89
|
+
overload.except("return_rbs").merge("rbs" => rbs, "params" => params, "return" => slot)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
|
|
94
|
+
# cause, and the return from its one body.
|
|
95
|
+
def body_overload(site, name, single)
|
|
96
|
+
return attribute_overload(name, single) if site.data["kind"] == "attribute"
|
|
97
|
+
|
|
98
|
+
params, block, head = Parameters.read(site.node.parameters, yields: Parameters.yields?(site.node.body))
|
|
99
|
+
slot = inferred_return(single)
|
|
100
|
+
block_rbs = block ? " ?{ (?) -> untyped }" : ""
|
|
101
|
+
{ "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
|
|
102
|
+
"return" => slot }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# An attribute without a contract: Rigor does not report the ivar's type in revision 1.
|
|
106
|
+
def attribute_overload(name, _single)
|
|
107
|
+
unknown = Slot.unavailable("not_computed")
|
|
108
|
+
if name.end_with?("=")
|
|
109
|
+
param = { "name" => nil, "kind" => "required", "type" => Slot.unavailable("not_computed") }
|
|
110
|
+
{ "rbs" => nil, "params" => [param], "block" => nil, "return" => unknown }
|
|
111
|
+
else
|
|
112
|
+
{ "rbs" => nil, "params" => [], "block" => nil, "return" => unknown }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def inferred_return(single)
|
|
117
|
+
return Slot.unavailable("contested") unless single
|
|
118
|
+
|
|
119
|
+
body = single.data["body_return"]
|
|
120
|
+
return Slot.unavailable("not_computed") if body.nil?
|
|
121
|
+
return body if Slot.unavailable?(body)
|
|
122
|
+
|
|
123
|
+
body.merge("from_site" => single.data["key"])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def return_spelling(slot)
|
|
127
|
+
spelled = Slot.rbs_spelling(slot)
|
|
128
|
+
spelled.include?(" | ") ? "(#{spelled})" : spelled
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# `rbs` is null when any slot of the overload is unavailable: an `untyped` there would read as an assertion.
|
|
132
|
+
def rbs_unless_unavailable(overload)
|
|
133
|
+
slots = [overload["return"], overload.dig("block", "type")] +
|
|
134
|
+
(overload["params"] || []).map { |param| param["type"] }
|
|
135
|
+
slots.any? { |slot| Slot.unavailable?(slot) } ? nil : overload["rbs"]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A site reads `contract: inline` when its own contract comments produced an inline declaration of a member
|
|
139
|
+
# it defines.
|
|
140
|
+
def mark_inline_sites
|
|
141
|
+
@sites.each do |site|
|
|
142
|
+
path = @site_paths.fetch(site.data["key"])
|
|
143
|
+
asserted = site.contract_comments.any? { |comment| !comment.location.slice.match?(/\A#\s*@extrbs\b/) }
|
|
144
|
+
inline = asserted && site.data["members"].any? do |name|
|
|
145
|
+
contract = @contracts[[site.data["owner"], site.data["scope"], name]]
|
|
146
|
+
contract&.provenance == "inline" && contract.buffers.any? { |buffer| buffer.end_with?(":#{path}") }
|
|
147
|
+
end
|
|
148
|
+
site.data["contract"] = inline ? "inline" : nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "slot"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Lens
|
|
9
|
+
# A `def`'s parameter list as slots and as the RBS head sig-gen would write for an undeclared method.
|
|
10
|
+
module Parameters
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
UNDECLARED = "inferred_return_untyped"
|
|
14
|
+
|
|
15
|
+
# [params (nil for an argument-forwarding list), block or nil, "(…)" head]. A definition takes a block when
|
|
16
|
+
# it declares `&blk` or `yields` (its body yields).
|
|
17
|
+
def read(node, yields: false)
|
|
18
|
+
block = yields ? undeclared_block : nil
|
|
19
|
+
return [[], block, "()"] if node.nil?
|
|
20
|
+
return [nil, block, "(?)"] if node.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
21
|
+
|
|
22
|
+
entries = entries(node)
|
|
23
|
+
params = entries.map { |kind, name| { "name" => name&.to_s, "kind" => kind, "type" => undeclared } }
|
|
24
|
+
block = undeclared_block if node.block
|
|
25
|
+
[params, block, "(#{entries.map { |kind, name| spell(kind, name) }.join(', ')})"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `[kind, name]` per parameter, in declaration order.
|
|
29
|
+
def entries(node)
|
|
30
|
+
node.requireds.map { |param| ["required", name_of(param)] } +
|
|
31
|
+
node.optionals.map { |param| ["optional", param.name] } +
|
|
32
|
+
rest(node.rest) +
|
|
33
|
+
node.posts.map { |param| ["post", name_of(param)] } +
|
|
34
|
+
node.keywords.map { |param| [keyword_kind(param), param.name] } +
|
|
35
|
+
keyword_rest(node.keyword_rest)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def undeclared
|
|
39
|
+
Slot.dynamic("inferred", UNDECLARED)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def undeclared_block
|
|
43
|
+
{ "required" => false, "type" => undeclared }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether a `def` body yields, outside the bodies whose `yield` belongs elsewhere: a nested `def`, a lambda,
|
|
47
|
+
# a `define_method` block.
|
|
48
|
+
def yields?(node)
|
|
49
|
+
return false if node.nil?
|
|
50
|
+
return true if node.is_a?(Prism::YieldNode)
|
|
51
|
+
return false if node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode)
|
|
52
|
+
return false if node.is_a?(Prism::CallNode) && node.name == :define_method && node.receiver.nil?
|
|
53
|
+
|
|
54
|
+
node.compact_child_nodes.any? { |child| yields?(child) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The shape of a parameter list: names and kinds, with every type null (lens.md § Sites).
|
|
58
|
+
def shape(params, block)
|
|
59
|
+
[params&.map { |param| param.merge("type" => nil) }, block&.merge("type" => nil)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def name_of(param)
|
|
63
|
+
param.respond_to?(:name) ? param.name : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def keyword_kind(param)
|
|
67
|
+
param.is_a?(Prism::RequiredKeywordParameterNode) ? "keyreq" : "key"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rest(node)
|
|
71
|
+
node.is_a?(Prism::RestParameterNode) ? [["rest", node.name]] : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def keyword_rest(node)
|
|
75
|
+
node.is_a?(Prism::KeywordRestParameterNode) ? [["keyrest", node.name]] : []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def spell(kind, name)
|
|
79
|
+
label = name ? " #{name}" : ""
|
|
80
|
+
case kind
|
|
81
|
+
when "required", "post" then "untyped#{label}"
|
|
82
|
+
when "optional" then "?untyped#{label}"
|
|
83
|
+
when "rest" then "*untyped#{label}"
|
|
84
|
+
when "keyreq" then "#{name}: untyped"
|
|
85
|
+
when "key" then "?#{name}: untyped"
|
|
86
|
+
when "keyrest" then "**untyped#{label}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|