autotype 0.1.0
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/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/Rakefile +21 -0
- data/autotype.example.yml +19 -0
- data/autotype.gemspec +38 -0
- data/exe/autotype +6 -0
- data/ext/autotype/Makefile +273 -0
- data/ext/autotype/autotype.bundle.dSYM/Contents/Info.plist +20 -0
- data/ext/autotype/autotype.bundle.dSYM/Contents/Resources/Relocations/aarch64/autotype.bundle.yml +5 -0
- data/ext/autotype/autotype.c +277 -0
- data/ext/autotype/extconf.rb +15 -0
- data/lib/autotype/discovery_profile.rb +527 -0
- data/lib/autotype/engine.rb +4613 -0
- data/lib/autotype/native.rb +34 -0
- data/lib/autotype/native_bridge.rb +121 -0
- data/lib/autotype/profile.rb +46 -0
- data/lib/autotype/type_string.rb +48 -0
- data/lib/autotype/version.rb +5 -0
- data/lib/autotype.rb +15 -0
- data/native/autotype/Makefile +23 -0
- data/native/autotype/include/stc.h +107 -0
- data/native/autotype/src/main.c +50 -0
- data/native/autotype/src/solver.c +296 -0
- data/native/autotype/src/type.c +223 -0
- data/spec/autotype_config_spec.rb +91 -0
- data/spec/autotype_native_spec.rb +71 -0
- data/spec/autotype_spec.rb +1028 -0
- data/spec/examples.txt +72 -0
- data/spec/fixtures/autotype.yml +9 -0
- data/spec/fixtures/entities/tool_call.rb +7 -0
- data/spec/fixtures/entities/tool_result.rb +7 -0
- data/spec/fixtures/pipeline/actors/demo_actor.rb +8 -0
- data/spec/spec_helper.rb +25 -0
- metadata +149 -0
|
@@ -0,0 +1,4613 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Syntax-less Ruby type inference engine.
|
|
4
|
+
#
|
|
5
|
+
# This is deliberately intraprocedural. Each method is analyzed independently:
|
|
6
|
+
# parameters and unknown values receive fresh type variables, message sends add
|
|
7
|
+
# structural capabilities, and the method boundary generalizes the variables.
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "prism"
|
|
12
|
+
require "set"
|
|
13
|
+
require "cgi"
|
|
14
|
+
require "digest"
|
|
15
|
+
begin
|
|
16
|
+
require "dotenv/load"
|
|
17
|
+
rescue LoadError
|
|
18
|
+
end
|
|
19
|
+
require "faraday"
|
|
20
|
+
require "async"
|
|
21
|
+
require "async/semaphore"
|
|
22
|
+
|
|
23
|
+
module Autotype
|
|
24
|
+
Named = Data.define(:name)
|
|
25
|
+
Generic = Data.define(:name, :arguments)
|
|
26
|
+
Union = Data.define(:members)
|
|
27
|
+
Function = Data.define(:parameters, :result)
|
|
28
|
+
Capability = Data.define(:receiver, :message, :arguments, :keywords, :result, :line)
|
|
29
|
+
Parameter = Data.define(:name, :type, :kind)
|
|
30
|
+
# A helper-type argument for consolidated definitions: a normalized slot
|
|
31
|
+
# string whose T-references are filled from the occurrence's own arguments.
|
|
32
|
+
TemplateArgument = Data.define(:template, :arguments)
|
|
33
|
+
MethodResult = Data.define(
|
|
34
|
+
:name, :owner, :kind, :method_name, :superclass, :line, :end_line,
|
|
35
|
+
:parameters, :result, :capabilities, :self_type, :ivars, :locals, :port_assignments,
|
|
36
|
+
:case_narrowings
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class InferenceMetadata
|
|
40
|
+
PortWiring = Data.define(:handler_method, :dispatch_param, :dispatch_param_type, :entity_param)
|
|
41
|
+
OutputEmitWiring = Data.define(:message, :port_keyword, :argument_index)
|
|
42
|
+
ConfigHashWiring = Data.define(:init_param, :reader_method, :ivar)
|
|
43
|
+
|
|
44
|
+
attr_reader :member_types, :ports, :config_options, :structured_owners, :referenced_types,
|
|
45
|
+
:member_type_hints, :option_type_hints, :side_effect_methods,
|
|
46
|
+
:structured_type_prefixes, :port_wiring, :output_emit, :config_hash,
|
|
47
|
+
:framework_self_fallbacks, :type_locators
|
|
48
|
+
|
|
49
|
+
def self.empty
|
|
50
|
+
new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from_collector(collector, **overrides)
|
|
54
|
+
new(
|
|
55
|
+
member_types: deep_copy_nested(collector.declared_member_types),
|
|
56
|
+
ports: deep_copy_ports(collector.declared_ports),
|
|
57
|
+
config_options: deep_copy_nested(collector.declared_config_options),
|
|
58
|
+
structured_owners: collector.structured_owners.dup,
|
|
59
|
+
referenced_types: collector.referenced_types.dup,
|
|
60
|
+
**overrides
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.deep_copy_nested(source)
|
|
65
|
+
source.transform_values(&:dup)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.deep_copy_ports(source)
|
|
69
|
+
source.transform_values do |ports|
|
|
70
|
+
{ inputs: ports[:inputs].dup, outputs: ports[:outputs].dup }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(
|
|
75
|
+
member_types: nil,
|
|
76
|
+
ports: nil,
|
|
77
|
+
config_options: nil,
|
|
78
|
+
structured_owners: nil,
|
|
79
|
+
referenced_types: nil,
|
|
80
|
+
member_type_hints: {},
|
|
81
|
+
option_type_hints: {},
|
|
82
|
+
side_effect_methods: nil,
|
|
83
|
+
structured_type_prefixes: [],
|
|
84
|
+
port_wiring: nil,
|
|
85
|
+
output_emit: nil,
|
|
86
|
+
config_hash: nil,
|
|
87
|
+
framework_self_fallbacks: {},
|
|
88
|
+
type_locators: []
|
|
89
|
+
)
|
|
90
|
+
@member_types = member_types || Hash.new { |hash, key| hash[key] = {} }
|
|
91
|
+
@ports = ports || Hash.new { |hash, key| hash[key] = { inputs: {}, outputs: {} } }
|
|
92
|
+
@config_options = config_options || Hash.new { |hash, key| hash[key] = {} }
|
|
93
|
+
@structured_owners = structured_owners || Set.new
|
|
94
|
+
@referenced_types = referenced_types || Set.new
|
|
95
|
+
@member_type_hints = member_type_hints
|
|
96
|
+
@option_type_hints = option_type_hints
|
|
97
|
+
@side_effect_methods = side_effect_methods || Set.new
|
|
98
|
+
@structured_type_prefixes = structured_type_prefixes
|
|
99
|
+
@port_wiring = port_wiring
|
|
100
|
+
@output_emit = output_emit
|
|
101
|
+
@config_hash = config_hash
|
|
102
|
+
@framework_self_fallbacks = framework_self_fallbacks
|
|
103
|
+
@type_locators = type_locators
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_ports!(source)
|
|
107
|
+
source.each do |owner, ports|
|
|
108
|
+
@ports[owner] ||= { inputs: {}, outputs: {} }
|
|
109
|
+
@ports[owner][:inputs].merge!(ports[:inputs])
|
|
110
|
+
@ports[owner][:outputs].merge!(ports[:outputs])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def merge_member_types!(source)
|
|
115
|
+
source.each { |owner, fields| @member_types[owner].merge!(fields) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def merge_config_options!(source)
|
|
119
|
+
source.each { |owner, options| @config_options[owner].merge!(options) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def port_wiring? = @port_wiring && @ports.values.any? { |ports| ports[:inputs].any? }
|
|
123
|
+
def output_emit? = @output_emit && @ports.values.any? { |ports| ports[:outputs].any? }
|
|
124
|
+
def config_hash? = @config_hash && @config_options.values.any?(&:any?)
|
|
125
|
+
|
|
126
|
+
def structured_type?(name)
|
|
127
|
+
return true if @structured_owners.include?(name)
|
|
128
|
+
return true if @member_types.key?(name) && @member_types[name].any?
|
|
129
|
+
|
|
130
|
+
@structured_type_prefixes.any? { |prefix| name.start_with?(prefix) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def locate_type_file(type_name)
|
|
134
|
+
@type_locators.lazy.filter_map { |locator| locator.call(type_name) }.first
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Heuristic member-name -> type mappings used when no explicit declaration
|
|
139
|
+
# exists. Common field names across Ruby codebases, not framework-specific.
|
|
140
|
+
CORE_MEMBER_NAME_TYPES = {
|
|
141
|
+
name: "String",
|
|
142
|
+
id: "String",
|
|
143
|
+
text: "String",
|
|
144
|
+
content: "String",
|
|
145
|
+
title: "String",
|
|
146
|
+
url: "String",
|
|
147
|
+
uri: "String",
|
|
148
|
+
email: "String",
|
|
149
|
+
description: "String",
|
|
150
|
+
status: "String",
|
|
151
|
+
type: "String",
|
|
152
|
+
kind: "String",
|
|
153
|
+
key: "String",
|
|
154
|
+
label: "String",
|
|
155
|
+
path: "String",
|
|
156
|
+
token: "String",
|
|
157
|
+
query: "String",
|
|
158
|
+
message: "String",
|
|
159
|
+
body: "String",
|
|
160
|
+
uuid: "String",
|
|
161
|
+
code: "String",
|
|
162
|
+
role: "String",
|
|
163
|
+
object: "String",
|
|
164
|
+
source: "String",
|
|
165
|
+
step: "Integer",
|
|
166
|
+
sequence_id: "Integer",
|
|
167
|
+
count: "Integer",
|
|
168
|
+
size: "Integer",
|
|
169
|
+
page: "Integer",
|
|
170
|
+
per_page: "Integer",
|
|
171
|
+
timeout: "Integer",
|
|
172
|
+
iteration: "Integer",
|
|
173
|
+
status_code: "Integer",
|
|
174
|
+
total_count: "Integer",
|
|
175
|
+
concept_type: "String",
|
|
176
|
+
core_phrase: "String",
|
|
177
|
+
hashed_id: "String",
|
|
178
|
+
collection: "String",
|
|
179
|
+
distance: "Float"
|
|
180
|
+
}.freeze
|
|
181
|
+
|
|
182
|
+
# Kernel module functions invoked without an explicit receiver (`Array(x)`).
|
|
183
|
+
KERNEL_CLASS_CALLS = %i[Array Integer Float String Symbol Hash].freeze
|
|
184
|
+
|
|
185
|
+
# Type names that must not be treated as literal hash key strings.
|
|
186
|
+
RESERVED_TYPE_NAMES = %w[String Symbol Integer Float Rational Complex bool nil Object].freeze
|
|
187
|
+
|
|
188
|
+
class TypeVariable
|
|
189
|
+
attr_reader :scope, :id, :hint
|
|
190
|
+
|
|
191
|
+
def initialize(scope, id, hint = nil)
|
|
192
|
+
@scope = scope
|
|
193
|
+
@id = id
|
|
194
|
+
@hint = hint
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def eql?(other) = other.is_a?(TypeVariable) && scope.equal?(other.scope) && id == other.id
|
|
198
|
+
def hash = [scope.object_id, id].hash
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
class Analyzer
|
|
202
|
+
attr_reader :capabilities
|
|
203
|
+
|
|
204
|
+
def initialize
|
|
205
|
+
@next_id = 0
|
|
206
|
+
@environment = {}
|
|
207
|
+
@local_bindings = {}
|
|
208
|
+
@block_locals = {}
|
|
209
|
+
@ivars = {}
|
|
210
|
+
@capabilities = []
|
|
211
|
+
@return_types = []
|
|
212
|
+
@port_assignments = []
|
|
213
|
+
@scope = Object.new
|
|
214
|
+
@self_type = fresh("self")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def analyze(node, qualified_name, owner:, kind:, superclass:)
|
|
218
|
+
@infer_cache = {}
|
|
219
|
+
@case_narrowings = Hash.new { |hash, key| hash[key] = [] }
|
|
220
|
+
parameters = bind_parameters(node.parameters)
|
|
221
|
+
result = infer(node.body)
|
|
222
|
+
result = Named.new("nil") unless result
|
|
223
|
+
@return_types.each { |return_type| result = union(result, return_type) }
|
|
224
|
+
if @yield_function && parameters.none? { |parameter| parameter.kind == :block }
|
|
225
|
+
parameters += [Parameter.new(name: :block, type: @yield_function, kind: :block)]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
MethodResult.new(
|
|
229
|
+
name: qualified_name,
|
|
230
|
+
owner: owner,
|
|
231
|
+
kind: kind,
|
|
232
|
+
method_name: node.name,
|
|
233
|
+
superclass: superclass,
|
|
234
|
+
line: node.location.start_line,
|
|
235
|
+
end_line: node.location.end_line,
|
|
236
|
+
parameters: parameters,
|
|
237
|
+
result: result,
|
|
238
|
+
capabilities: capabilities,
|
|
239
|
+
self_type: @self_type,
|
|
240
|
+
ivars: @ivars.dup,
|
|
241
|
+
# A name bound both at method level and inside blocks (e.g.
|
|
242
|
+
# `x = nil` reassigned within an each) carries both types.
|
|
243
|
+
locals: @block_locals.merge(@environment) { |_name, inner, outer| union(inner, outer) },
|
|
244
|
+
port_assignments: @port_assignments.dup,
|
|
245
|
+
case_narrowings: @case_narrowings.transform_values { |types| types.uniq }
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def fresh(hint = nil)
|
|
252
|
+
variable = TypeVariable.new(@scope, @next_id, hint)
|
|
253
|
+
@next_id += 1
|
|
254
|
+
variable
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def bind_parameters(parameters_node)
|
|
258
|
+
return [] unless parameters_node
|
|
259
|
+
|
|
260
|
+
parameters = []
|
|
261
|
+
required = parameters_node.requireds + parameters_node.posts
|
|
262
|
+
required.each { |node| parameters << bind_parameter(node.name, :required) }
|
|
263
|
+
parameters_node.optionals.each do |node|
|
|
264
|
+
parameters << bind_parameter(node.name, :optional, default_node: node.value)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if parameters_node.rest
|
|
268
|
+
name = parameters_node.rest.name || :anonymous_rest
|
|
269
|
+
element = fresh(name)
|
|
270
|
+
@environment[name] = Generic.new("Array", [element])
|
|
271
|
+
parameters << Parameter.new(name: name, type: @environment[name], kind: :rest)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
parameters_node.keywords.each do |node|
|
|
275
|
+
kind = node.is_a?(Prism::OptionalKeywordParameterNode) ? :optional_keyword : :keyword
|
|
276
|
+
default_node = node.value if node.is_a?(Prism::OptionalKeywordParameterNode)
|
|
277
|
+
parameters << bind_parameter(node.name, kind, default_node: default_node)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if parameters_node.keyword_rest
|
|
281
|
+
if parameters_node.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
282
|
+
type = fresh("forwarding")
|
|
283
|
+
parameters << Parameter.new(name: :"...", type: type, kind: :forwarding)
|
|
284
|
+
else
|
|
285
|
+
name = parameters_node.keyword_rest.name || :anonymous_keywords
|
|
286
|
+
key = Named.new("Symbol")
|
|
287
|
+
value = fresh(name)
|
|
288
|
+
@environment[name] = Generic.new("Hash", [key, value])
|
|
289
|
+
parameters << Parameter.new(name: name, type: @environment[name], kind: :keyword_rest)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
if parameters_node.block
|
|
294
|
+
name = parameters_node.block.name || :anonymous_block
|
|
295
|
+
type = fresh(name)
|
|
296
|
+
@environment[name] = type
|
|
297
|
+
parameters << Parameter.new(name: name, type: type, kind: :block)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
parameters
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def bind_parameter(name, kind, default_node: nil)
|
|
304
|
+
type = if default_node
|
|
305
|
+
default_type = infer(default_node)
|
|
306
|
+
if nil_default_type?(default_type)
|
|
307
|
+
union(fresh(name), default_type)
|
|
308
|
+
else
|
|
309
|
+
default_type
|
|
310
|
+
end
|
|
311
|
+
else
|
|
312
|
+
fresh(name)
|
|
313
|
+
end
|
|
314
|
+
@environment[name] = type
|
|
315
|
+
Parameter.new(name: name, type: type, kind: kind)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def nil_default_type?(type)
|
|
319
|
+
type.is_a?(Named) && type.name == "nil"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def infer(node)
|
|
323
|
+
return Named.new("nil") unless node
|
|
324
|
+
return @infer_cache[node.object_id] if @infer_cache&.key?(node.object_id)
|
|
325
|
+
|
|
326
|
+
result = infer_node(node)
|
|
327
|
+
@infer_cache[node.object_id] = result if cache_infer_node?(node)
|
|
328
|
+
result
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def cache_infer_node?(node)
|
|
332
|
+
case node
|
|
333
|
+
when Prism::CallNode, Prism::ArrayNode, Prism::HashNode, Prism::KeywordHashNode,
|
|
334
|
+
Prism::StringNode, Prism::IntegerNode, Prism::FloatNode, Prism::SymbolNode,
|
|
335
|
+
Prism::InterpolatedSymbolNode, Prism::RegularExpressionNode,
|
|
336
|
+
Prism::TrueNode, Prism::FalseNode, Prism::NilNode, Prism::RangeNode,
|
|
337
|
+
Prism::RationalNode, Prism::ImaginaryNode
|
|
338
|
+
true
|
|
339
|
+
else
|
|
340
|
+
false
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def infer_node(node)
|
|
345
|
+
case node
|
|
346
|
+
when Prism::StatementsNode
|
|
347
|
+
infer_statements(node)
|
|
348
|
+
when Prism::LocalVariableReadNode
|
|
349
|
+
@environment[node.name] ||= @local_bindings[node.name] ||= fresh(node.name)
|
|
350
|
+
when Prism::LocalVariableWriteNode
|
|
351
|
+
value = infer(node.value)
|
|
352
|
+
@environment[node.name] = value
|
|
353
|
+
@local_bindings[node.name] = value
|
|
354
|
+
when Prism::LocalVariableAndWriteNode, Prism::LocalVariableOrWriteNode,
|
|
355
|
+
Prism::LocalVariableOperatorWriteNode
|
|
356
|
+
current = @environment[node.name] ||= @local_bindings[node.name] ||= fresh(node.name)
|
|
357
|
+
value = infer(node.value)
|
|
358
|
+
@environment[node.name] = union(current, value)
|
|
359
|
+
@local_bindings[node.name] = @environment[node.name]
|
|
360
|
+
when Prism::InstanceVariableReadNode
|
|
361
|
+
@ivars[node.name] ||= fresh(node.name)
|
|
362
|
+
when Prism::InstanceVariableWriteNode
|
|
363
|
+
@ivars[node.name] = infer(node.value)
|
|
364
|
+
when Prism::CallNode
|
|
365
|
+
infer_call(node)
|
|
366
|
+
when Prism::YieldNode
|
|
367
|
+
infer_yield(node)
|
|
368
|
+
when Prism::InterpolatedStringNode
|
|
369
|
+
infer_interpolated_string(node)
|
|
370
|
+
when Prism::StringNode
|
|
371
|
+
Named.new("String")
|
|
372
|
+
when Prism::IntegerNode
|
|
373
|
+
Named.new("Integer")
|
|
374
|
+
when Prism::FloatNode
|
|
375
|
+
Named.new("Float")
|
|
376
|
+
when Prism::RationalNode
|
|
377
|
+
Named.new("Rational")
|
|
378
|
+
when Prism::ImaginaryNode
|
|
379
|
+
Named.new("Complex")
|
|
380
|
+
when Prism::SymbolNode, Prism::InterpolatedSymbolNode
|
|
381
|
+
Named.new("Symbol")
|
|
382
|
+
when Prism::TrueNode, Prism::FalseNode
|
|
383
|
+
Named.new("bool")
|
|
384
|
+
when Prism::NilNode
|
|
385
|
+
Named.new("nil")
|
|
386
|
+
when Prism::ArrayNode
|
|
387
|
+
elements = node.elements.map { |element| infer(element) }
|
|
388
|
+
Generic.new("Array", [union_all(elements)])
|
|
389
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
390
|
+
infer_hash(node)
|
|
391
|
+
when Prism::RangeNode
|
|
392
|
+
Generic.new("Range", [union(infer(node.left), infer(node.right))])
|
|
393
|
+
when Prism::IfNode
|
|
394
|
+
infer(node.predicate)
|
|
395
|
+
union(infer(node.statements), infer(node.subsequent))
|
|
396
|
+
when Prism::UnlessNode
|
|
397
|
+
infer(node.predicate)
|
|
398
|
+
union(infer(node.statements), infer(node.else_clause&.statements))
|
|
399
|
+
when Prism::CaseNode
|
|
400
|
+
infer_case(node)
|
|
401
|
+
when Prism::AndNode, Prism::OrNode
|
|
402
|
+
union(infer(node.left), infer(node.right))
|
|
403
|
+
when Prism::ReturnNode
|
|
404
|
+
value = infer(node.arguments)
|
|
405
|
+
@return_types << value
|
|
406
|
+
value
|
|
407
|
+
when Prism::BreakNode, Prism::NextNode
|
|
408
|
+
infer(node.arguments)
|
|
409
|
+
when Prism::ArgumentsNode
|
|
410
|
+
values = node.arguments.map { |argument| infer(argument) }
|
|
411
|
+
values.length == 1 ? values.first : Generic.new("Tuple", values)
|
|
412
|
+
when Prism::ParenthesesNode
|
|
413
|
+
infer(node.body)
|
|
414
|
+
when Prism::BeginNode
|
|
415
|
+
infer_begin(node)
|
|
416
|
+
when Prism::BlockNode
|
|
417
|
+
infer_block(node)
|
|
418
|
+
when Prism::LambdaNode
|
|
419
|
+
infer_lambda(node)
|
|
420
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode,
|
|
421
|
+
Prism::ConstantPathTargetNode, Prism::ConstantTargetNode
|
|
422
|
+
Named.new("Class[#{node.slice}]")
|
|
423
|
+
when Prism::SelfNode
|
|
424
|
+
@self_type
|
|
425
|
+
when Prism::SourceFileNode, Prism::SourceLineNode, Prism::SourceEncodingNode
|
|
426
|
+
Named.new("String")
|
|
427
|
+
else
|
|
428
|
+
infer_children(node)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def infer_statements(node)
|
|
433
|
+
result = Named.new("nil")
|
|
434
|
+
node.body.each { |statement| result = infer(statement) }
|
|
435
|
+
result
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def infer_call(node)
|
|
439
|
+
receiver =
|
|
440
|
+
if node.receiver
|
|
441
|
+
infer(node.receiver)
|
|
442
|
+
elsif KERNEL_CLASS_CALLS.include?(node.name)
|
|
443
|
+
Named.new("Class[#{node.name}]")
|
|
444
|
+
else
|
|
445
|
+
@self_type
|
|
446
|
+
end
|
|
447
|
+
positional = []
|
|
448
|
+
keywords = {}
|
|
449
|
+
|
|
450
|
+
node.arguments&.arguments&.each do |argument|
|
|
451
|
+
if argument.is_a?(Prism::KeywordHashNode)
|
|
452
|
+
argument.elements.each do |element|
|
|
453
|
+
if element.is_a?(Prism::AssocNode)
|
|
454
|
+
key = element.key.respond_to?(:value) ? element.key.value : element.key.slice
|
|
455
|
+
keywords[key.to_sym] = infer_keyword_argument(element.value)
|
|
456
|
+
else
|
|
457
|
+
positional << infer(element)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
else
|
|
461
|
+
positional << infer_index_argument(node.name, argument)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
if node.block
|
|
466
|
+
block_type = if node.block.is_a?(Prism::BlockNode)
|
|
467
|
+
infer_block(node.block)
|
|
468
|
+
else
|
|
469
|
+
infer(node.block.expression)
|
|
470
|
+
end
|
|
471
|
+
positional << block_type
|
|
472
|
+
end
|
|
473
|
+
result = fresh(node.name)
|
|
474
|
+
capabilities << Capability.new(
|
|
475
|
+
receiver: receiver,
|
|
476
|
+
message: node.name,
|
|
477
|
+
arguments: positional,
|
|
478
|
+
keywords: keywords,
|
|
479
|
+
result: result,
|
|
480
|
+
line: node.location.start_line
|
|
481
|
+
)
|
|
482
|
+
result
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def infer_interpolated_string(node)
|
|
486
|
+
node.parts.each do |part|
|
|
487
|
+
next unless part.is_a?(Prism::EmbeddedStatementsNode)
|
|
488
|
+
|
|
489
|
+
value = infer(part.statements)
|
|
490
|
+
capabilities << Capability.new(
|
|
491
|
+
receiver: value,
|
|
492
|
+
message: :to_s,
|
|
493
|
+
arguments: [],
|
|
494
|
+
keywords: {},
|
|
495
|
+
result: Named.new("String"),
|
|
496
|
+
line: part.location.start_line
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
Named.new("String")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def infer_index_argument(message, node)
|
|
503
|
+
return infer(node) unless message == :[]
|
|
504
|
+
|
|
505
|
+
infer_literal_key(node)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def infer_literal_key(node)
|
|
509
|
+
case node
|
|
510
|
+
when Prism::StringNode then Named.new(node.unescaped)
|
|
511
|
+
when Prism::SymbolNode then Named.new(node.unescaped)
|
|
512
|
+
else infer(node)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def infer_keyword_argument(node)
|
|
517
|
+
case node
|
|
518
|
+
when Prism::SymbolNode then Named.new(node.unescaped)
|
|
519
|
+
else infer(node)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def infer_hash(node)
|
|
524
|
+
keys = []
|
|
525
|
+
values = []
|
|
526
|
+
node.elements.each do |element|
|
|
527
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
528
|
+
|
|
529
|
+
keys << infer(element.key)
|
|
530
|
+
values << infer(element.value)
|
|
531
|
+
end
|
|
532
|
+
Generic.new("Hash", [union_all(keys), union_all(values)])
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# `yield` gives the method an implicit block parameter; sharing one
|
|
536
|
+
# function type across yields lets call sites flow their block's return
|
|
537
|
+
# type into the yield expression.
|
|
538
|
+
def infer_yield(node)
|
|
539
|
+
arguments = Array(node.arguments&.arguments).map { |argument| infer(argument) }
|
|
540
|
+
@yield_function ||= Function.new(arguments, fresh("yield"))
|
|
541
|
+
@yield_function.result
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def infer_case(node)
|
|
545
|
+
predicate_name = case_predicate_name(node.predicate)
|
|
546
|
+
result = Named.new("nil")
|
|
547
|
+
node.conditions.each do |when_node|
|
|
548
|
+
next unless when_node.is_a?(Prism::WhenNode)
|
|
549
|
+
|
|
550
|
+
port = when_port_name(when_node)
|
|
551
|
+
record_port_assignment(when_node, port) if predicate_name == :from && port
|
|
552
|
+
saved_environment = @environment.dup
|
|
553
|
+
narrow_case_branch!(node.predicate, when_node)
|
|
554
|
+
result = union(result, infer(when_node.statements))
|
|
555
|
+
@environment = saved_environment
|
|
556
|
+
end
|
|
557
|
+
result = union(result, infer(node.consequent)) if node.consequent
|
|
558
|
+
result
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def narrow_case_branch!(predicate, when_node)
|
|
562
|
+
branch_type = type_from_when_conditions(when_node.conditions)
|
|
563
|
+
return unless branch_type
|
|
564
|
+
|
|
565
|
+
case predicate
|
|
566
|
+
when Prism::LocalVariableReadNode
|
|
567
|
+
@case_narrowings[predicate.name] << branch_type
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def type_from_when_conditions(conditions)
|
|
572
|
+
types = conditions.filter_map { |condition| type_from_when_condition(condition) }
|
|
573
|
+
return nil if types.empty?
|
|
574
|
+
|
|
575
|
+
types.reduce { |left, right| union(left, right) }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def type_from_when_condition(condition)
|
|
579
|
+
class_name =
|
|
580
|
+
case condition
|
|
581
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
582
|
+
condition.slice.to_s.delete_prefix("::")
|
|
583
|
+
end
|
|
584
|
+
return nil unless class_name
|
|
585
|
+
|
|
586
|
+
case class_name
|
|
587
|
+
when "Hash"
|
|
588
|
+
Generic.new("Hash", [union(Named.new("String"), Named.new("Symbol")), Named.new("Object")])
|
|
589
|
+
when "Array"
|
|
590
|
+
Generic.new("Array", [Named.new("Object")])
|
|
591
|
+
when "String"
|
|
592
|
+
Named.new("String")
|
|
593
|
+
when "Integer"
|
|
594
|
+
Named.new("Integer")
|
|
595
|
+
when "Float"
|
|
596
|
+
Named.new("Float")
|
|
597
|
+
when "Symbol"
|
|
598
|
+
Named.new("Symbol")
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def case_predicate_name(predicate)
|
|
603
|
+
case predicate
|
|
604
|
+
when Prism::LocalVariableReadNode
|
|
605
|
+
predicate.name
|
|
606
|
+
when Prism::CallNode
|
|
607
|
+
if predicate.name == :to_sym && predicate.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
608
|
+
predicate.receiver.name
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def when_port_name(when_node)
|
|
614
|
+
condition = when_node.conditions.first
|
|
615
|
+
case condition
|
|
616
|
+
when Prism::SymbolNode then condition.unescaped.to_sym
|
|
617
|
+
when Prism::StringNode then condition.unescaped.to_sym
|
|
618
|
+
when Prism::IntegerNode then condition.value
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def record_port_assignment(when_node, port)
|
|
623
|
+
assignment = port_assignment_from_statements(when_node.statements)
|
|
624
|
+
return unless assignment
|
|
625
|
+
|
|
626
|
+
@port_assignments << assignment.merge(port: port)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def port_assignment_from_statements(statements_node)
|
|
630
|
+
walk = lambda do |node|
|
|
631
|
+
return nil unless node
|
|
632
|
+
|
|
633
|
+
case node
|
|
634
|
+
when Prism::StatementsNode
|
|
635
|
+
node.body.each { |statement| (result = walk.call(statement)) && (return result) }
|
|
636
|
+
when Prism::InstanceVariableWriteNode
|
|
637
|
+
if node.value.is_a?(Prism::LocalVariableReadNode)
|
|
638
|
+
return { param: node.value.name, ivar: node.name }
|
|
639
|
+
end
|
|
640
|
+
when Prism::CallNode
|
|
641
|
+
return { param: node.receiver.name, ivar: nil } if node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
642
|
+
end
|
|
643
|
+
nil
|
|
644
|
+
end
|
|
645
|
+
walk.call(statements_node)
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def infer_begin(node)
|
|
649
|
+
result = infer(node.statements)
|
|
650
|
+
rescue_clause = node.rescue_clause
|
|
651
|
+
while rescue_clause
|
|
652
|
+
bind_rescue_reference(rescue_clause)
|
|
653
|
+
result = union(result, infer(rescue_clause.statements))
|
|
654
|
+
rescue_clause = rescue_clause.subsequent
|
|
655
|
+
end
|
|
656
|
+
infer(node.ensure_clause.statements) if node.ensure_clause
|
|
657
|
+
result
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# `rescue SomeError => e` names an exception whose class is written right
|
|
661
|
+
# there; binding it makes `e.message` and friends resolvable.
|
|
662
|
+
def bind_rescue_reference(clause)
|
|
663
|
+
reference = clause.reference
|
|
664
|
+
return unless reference.is_a?(Prism::LocalVariableTargetNode)
|
|
665
|
+
|
|
666
|
+
exceptions = clause.exceptions.filter_map do |exception|
|
|
667
|
+
case exception
|
|
668
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
669
|
+
Named.new(exception.slice.delete_prefix("::"))
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
@environment[reference.name] =
|
|
673
|
+
if exceptions.empty?
|
|
674
|
+
Named.new("StandardError")
|
|
675
|
+
elsif exceptions.length == 1
|
|
676
|
+
exceptions.first
|
|
677
|
+
else
|
|
678
|
+
Union.new(exceptions)
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def infer_block(node)
|
|
683
|
+
saved_environment = @environment.dup
|
|
684
|
+
parameter_types = []
|
|
685
|
+
|
|
686
|
+
if node.parameters&.parameters
|
|
687
|
+
block_parameters = node.parameters.parameters
|
|
688
|
+
(block_parameters.requireds + block_parameters.posts).each do |parameter|
|
|
689
|
+
parameter_types << bind_block_parameter(parameter)
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
result = infer(node.body)
|
|
694
|
+
capture_block_locals(saved_environment)
|
|
695
|
+
@environment = saved_environment
|
|
696
|
+
Function.new(parameter_types, result)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Block-scoped bindings vanish when the outer environment is restored,
|
|
700
|
+
# but they are exactly what editor hovers need. Only bindings the block
|
|
701
|
+
# introduced or changed are kept, so a later block's view of an outer
|
|
702
|
+
# variable cannot clobber an earlier block's assignment.
|
|
703
|
+
def capture_block_locals(saved_environment)
|
|
704
|
+
@environment.each do |name, type|
|
|
705
|
+
next if saved_environment[name].equal?(type)
|
|
706
|
+
|
|
707
|
+
recorded = @block_locals[name]
|
|
708
|
+
@block_locals[name] = recorded ? union(recorded, type) : type
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def bind_block_parameter(parameter)
|
|
713
|
+
if parameter.is_a?(Prism::MultiTargetNode)
|
|
714
|
+
members = (parameter.lefts + parameter.rights).map { |member| bind_block_parameter(member) }
|
|
715
|
+
if parameter.rest&.expression
|
|
716
|
+
rest_type = fresh(parameter.rest.expression.name)
|
|
717
|
+
@environment[parameter.rest.expression.name] = Generic.new("Array", [rest_type])
|
|
718
|
+
members << @environment[parameter.rest.expression.name]
|
|
719
|
+
end
|
|
720
|
+
return Generic.new("Tuple", members)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
type = fresh(parameter.name)
|
|
724
|
+
@environment[parameter.name] = type
|
|
725
|
+
type
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def infer_lambda(node)
|
|
729
|
+
saved_environment = @environment.dup
|
|
730
|
+
parameters = bind_parameters(node.parameters&.parameters)
|
|
731
|
+
result = infer(node.body)
|
|
732
|
+
capture_block_locals(saved_environment)
|
|
733
|
+
@environment = saved_environment
|
|
734
|
+
Function.new(parameters.map(&:type), result)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def infer_children(node)
|
|
738
|
+
result = Named.new("nil")
|
|
739
|
+
node.compact_child_nodes.each { |child| result = infer(child) }
|
|
740
|
+
result
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def union_all(types)
|
|
744
|
+
types.compact.reduce { |left, right| union(left, right) } || fresh("element")
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def union(left, right)
|
|
748
|
+
return right unless left
|
|
749
|
+
return left unless right
|
|
750
|
+
return left if left == right
|
|
751
|
+
|
|
752
|
+
members = []
|
|
753
|
+
members.concat(left.members) if left.is_a?(Union)
|
|
754
|
+
members << left unless left.is_a?(Union)
|
|
755
|
+
members.concat(right.members) if right.is_a?(Union)
|
|
756
|
+
members << right unless right.is_a?(Union)
|
|
757
|
+
Union.new(members.uniq)
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
class Collector < Prism::Visitor
|
|
762
|
+
attr_reader :methods, :constants, :includes, :declared_ports, :declared_config_options,
|
|
763
|
+
:declared_member_types, :structured_owners, :referenced_types
|
|
764
|
+
|
|
765
|
+
def initialize(path, profile: nil)
|
|
766
|
+
@path = path
|
|
767
|
+
@profile = profile
|
|
768
|
+
@namespace = []
|
|
769
|
+
@singleton = false
|
|
770
|
+
@methods = []
|
|
771
|
+
@superclasses = {}
|
|
772
|
+
@constants = {}
|
|
773
|
+
@includes = Hash.new { |hash, key| hash[key] = [] }
|
|
774
|
+
@declared_ports = Hash.new { |hash, key| hash[key] = { inputs: {}, outputs: {} } }
|
|
775
|
+
@declared_config_options = Hash.new { |hash, key| hash[key] = {} }
|
|
776
|
+
@declared_member_types = Hash.new { |hash, key| hash[key] = {} }
|
|
777
|
+
@structured_owners = Set.new
|
|
778
|
+
@referenced_types = Set.new
|
|
779
|
+
@structured_field_context = false
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def singleton_context? = @singleton
|
|
783
|
+
def namespace_empty? = @namespace.empty?
|
|
784
|
+
def structured_field_context? = @structured_field_context
|
|
785
|
+
|
|
786
|
+
def superclass_for(owner)
|
|
787
|
+
@superclasses[owner]
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def current_owner
|
|
791
|
+
@namespace.empty? ? File.basename(@path, ".rb") : @namespace.join("::")
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def visit_module_node(node)
|
|
795
|
+
within_namespace(node.constant_path.slice) { super }
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def visit_class_node(node)
|
|
799
|
+
name = node.constant_path.slice
|
|
800
|
+
@superclasses[qualified_owner(name)] = node.superclass&.slice
|
|
801
|
+
within_namespace(name) { super }
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def visit_singleton_class_node(node)
|
|
805
|
+
previous = @singleton
|
|
806
|
+
@singleton = node.expression.is_a?(Prism::SelfNode)
|
|
807
|
+
super
|
|
808
|
+
ensure
|
|
809
|
+
@singleton = previous
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def visit_def_node(node)
|
|
813
|
+
singleton = @singleton || !node.receiver.nil?
|
|
814
|
+
separator = singleton ? "." : "#"
|
|
815
|
+
owner = current_owner
|
|
816
|
+
if node.name == :initialize && !singleton
|
|
817
|
+
methods.reject! do |method|
|
|
818
|
+
method.owner == owner &&
|
|
819
|
+
method.method_name == :initialize &&
|
|
820
|
+
method.capabilities.empty?
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
analyzer = Analyzer.new
|
|
824
|
+
methods << analyzer.analyze(
|
|
825
|
+
node,
|
|
826
|
+
"#{owner}#{separator}#{node.name}",
|
|
827
|
+
owner: owner,
|
|
828
|
+
kind: singleton ? :singleton : :instance,
|
|
829
|
+
superclass: @superclasses[owner]
|
|
830
|
+
)
|
|
831
|
+
# A nested def has its own scope and is collected by the normal traversal.
|
|
832
|
+
super
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def visit_call_node(node)
|
|
836
|
+
synthesize_attribute_methods(node) if node.receiver.nil?
|
|
837
|
+
record_include(node) if node.receiver.nil? && node.name == :include
|
|
838
|
+
@profile&.visit_call_node(self, node)
|
|
839
|
+
super
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# `Hit = App::Entity.define do ... end`, `Foo = Class.new(Base) do ... end`,
|
|
843
|
+
# and similar block-based definitions are class bodies: methods defined
|
|
844
|
+
# inside belong to the assigned constant, not the enclosing module.
|
|
845
|
+
def visit_constant_write_node(node)
|
|
846
|
+
record_constant(node.name.to_s, node.value)
|
|
847
|
+
within_constant_definition(node.name.to_s, node.value) { super }
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def visit_constant_path_write_node(node)
|
|
851
|
+
record_constant(node.target.slice, node.value)
|
|
852
|
+
within_constant_definition(node.target.slice, node.value) { super }
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def symbol_arguments(node)
|
|
856
|
+
Array(node.arguments&.arguments).filter_map do |argument|
|
|
857
|
+
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
|
|
858
|
+
end
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def keyword_constant(arguments_node, key)
|
|
862
|
+
return nil unless arguments_node
|
|
863
|
+
|
|
864
|
+
Array(arguments_node.arguments).each do |argument|
|
|
865
|
+
next unless argument.is_a?(Prism::KeywordHashNode)
|
|
866
|
+
|
|
867
|
+
argument.elements.each do |element|
|
|
868
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
869
|
+
next unless element.key.slice.delete_suffix(":").to_sym == key
|
|
870
|
+
|
|
871
|
+
return constant_argument_slice(element.value) || element.value.slice
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
nil
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def core_member_type_hint(member)
|
|
878
|
+
type_name = CORE_MEMBER_NAME_TYPES[member]
|
|
879
|
+
Named.new(type_name) if type_name
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
private
|
|
883
|
+
|
|
884
|
+
def within_constant_definition(name, value, &)
|
|
885
|
+
if value.is_a?(Prism::CallNode) && record_definition_members(name, value) && !value.block
|
|
886
|
+
return yield
|
|
887
|
+
end
|
|
888
|
+
return yield unless value.is_a?(Prism::CallNode) && value.block
|
|
889
|
+
|
|
890
|
+
if value.name == :new && value.receiver&.slice == "Class"
|
|
891
|
+
@superclasses[qualified_owner(name)] = value.arguments&.arguments&.first&.slice
|
|
892
|
+
end
|
|
893
|
+
within_namespace(name, &)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# `Entity.define(:url, :title) { ... }`, `Data.define`, and `Struct.new`
|
|
897
|
+
# generate member readers and a keyword constructor. Without synthesizing
|
|
898
|
+
# them, every attribute read in the repo is an unresolvable send.
|
|
899
|
+
def record_definition_members(name, value)
|
|
900
|
+
entity_define = value.name == :define &&
|
|
901
|
+
@profile&.entity_define_receiver?(value.receiver&.slice)
|
|
902
|
+
members =
|
|
903
|
+
if entity_define || value.name == :define && %w[Entity Data].include?(value.receiver&.slice)
|
|
904
|
+
symbol_arguments(value)
|
|
905
|
+
elsif value.name == :new && value.receiver&.slice == "Struct"
|
|
906
|
+
symbol_arguments(value)
|
|
907
|
+
elsif value.name == :define && value.receiver&.slice == "Data"
|
|
908
|
+
symbol_arguments(value)
|
|
909
|
+
end
|
|
910
|
+
return false if members.nil? || members.empty?
|
|
911
|
+
|
|
912
|
+
within_namespace(name) do
|
|
913
|
+
@structured_field_context = entity_define
|
|
914
|
+
@structured_owners << current_owner
|
|
915
|
+
members.each { |member| methods << synthesized_accessor(member, value, writer: false) }
|
|
916
|
+
methods << synthesized_member_initializer(members, value)
|
|
917
|
+
members.each do |member|
|
|
918
|
+
hint = @profile&.member_type_hints&.fetch(member, nil) || core_member_type_hint(member)
|
|
919
|
+
declared_member_types[current_owner][member] = hint if hint
|
|
920
|
+
end
|
|
921
|
+
ensure
|
|
922
|
+
@structured_field_context = false
|
|
923
|
+
end
|
|
924
|
+
true
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# A Data-style keyword constructor: each keyword parameter shares its
|
|
928
|
+
# type variable with the backing member ivar, so call-site argument
|
|
929
|
+
# types flow into the synthesized readers.
|
|
930
|
+
def synthesized_member_initializer(members, node)
|
|
931
|
+
scope = Object.new
|
|
932
|
+
parameters = []
|
|
933
|
+
ivars = {}
|
|
934
|
+
members.each_with_index do |member, index|
|
|
935
|
+
value = TypeVariable.new(scope, index, member)
|
|
936
|
+
parameters << Parameter.new(name: member, type: value, kind: :keyword)
|
|
937
|
+
ivars[:"@#{member}"] = value
|
|
938
|
+
end
|
|
939
|
+
owner = current_owner
|
|
940
|
+
MethodResult.new(
|
|
941
|
+
name: "#{owner}#initialize",
|
|
942
|
+
owner: owner,
|
|
943
|
+
kind: :instance,
|
|
944
|
+
method_name: :initialize,
|
|
945
|
+
superclass: @superclasses[owner],
|
|
946
|
+
line: node.location.start_line,
|
|
947
|
+
end_line: node.location.start_line,
|
|
948
|
+
parameters: parameters,
|
|
949
|
+
result: TypeVariable.new(scope, members.length, "instance"),
|
|
950
|
+
capabilities: [],
|
|
951
|
+
self_type: TypeVariable.new(scope, members.length + 1, "self"),
|
|
952
|
+
ivars: ivars,
|
|
953
|
+
locals: {},
|
|
954
|
+
port_assignments: [],
|
|
955
|
+
case_narrowings: {}
|
|
956
|
+
)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Constants assigned literals (regexes, frozen hashes, word lists) are
|
|
960
|
+
# ubiquitous; recording their types lets the solver treat `RE.match?(x)`
|
|
961
|
+
# or `PATTERNS.fetch(key)` like sends on the literal itself.
|
|
962
|
+
def record_constant(name, value)
|
|
963
|
+
type = literal_constant_type(value)
|
|
964
|
+
@constants[qualified_owner(name)] = type if type
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def literal_constant_type(node)
|
|
968
|
+
case node
|
|
969
|
+
when Prism::StringNode, Prism::InterpolatedStringNode then Named.new("String")
|
|
970
|
+
when Prism::SymbolNode then Named.new("Symbol")
|
|
971
|
+
when Prism::IntegerNode then Named.new("Integer")
|
|
972
|
+
when Prism::FloatNode then Named.new("Float")
|
|
973
|
+
when Prism::TrueNode, Prism::FalseNode then Named.new("bool")
|
|
974
|
+
when Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode then Named.new("Regexp")
|
|
975
|
+
when Prism::ArrayNode then literal_array_type(node)
|
|
976
|
+
when Prism::HashNode then literal_hash_type(node)
|
|
977
|
+
when Prism::CallNode then literal_call_type(node)
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def literal_array_type(node)
|
|
982
|
+
elements = node.elements.map { |element| literal_constant_type(element) }
|
|
983
|
+
return nil if elements.empty? || elements.any?(&:nil?)
|
|
984
|
+
|
|
985
|
+
unique = elements.uniq
|
|
986
|
+
Generic.new("Array", [unique.length == 1 ? unique.first : Union.new(unique)])
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def literal_hash_type(node)
|
|
990
|
+
keys = []
|
|
991
|
+
values = []
|
|
992
|
+
node.elements.each do |element|
|
|
993
|
+
return nil unless element.is_a?(Prism::AssocNode)
|
|
994
|
+
|
|
995
|
+
keys << literal_constant_type(element.key)
|
|
996
|
+
values << literal_constant_type(element.value)
|
|
997
|
+
end
|
|
998
|
+
return nil if keys.empty? || keys.any?(&:nil?) || values.any?(&:nil?)
|
|
999
|
+
|
|
1000
|
+
key = keys.uniq.length == 1 ? keys.first : Union.new(keys.uniq)
|
|
1001
|
+
value = values.uniq.length == 1 ? values.first : Union.new(values.uniq)
|
|
1002
|
+
Generic.new("Hash", [key, value])
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def literal_call_type(node)
|
|
1006
|
+
if %i[freeze dup].include?(node.name) && node.receiver
|
|
1007
|
+
literal_constant_type(node.receiver)
|
|
1008
|
+
elsif %i[new create_many].include?(node.name) && %w[Fast::Regexp Regexp].include?(node.receiver&.slice)
|
|
1009
|
+
Named.new("Regexp")
|
|
1010
|
+
elsif node.name == :to_set && node.receiver
|
|
1011
|
+
inner = literal_constant_type(node.receiver)
|
|
1012
|
+
Generic.new("Set", inner.arguments) if inner.is_a?(Generic) && inner.name == "Array"
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# attr_reader/attr_writer/attr_accessor define real methods, so synthesize
|
|
1017
|
+
# entries for them. Their types are tied to the backing instance variable,
|
|
1018
|
+
# which lets the solver connect `@foo = ...` in one method to `foo` reads
|
|
1019
|
+
# everywhere else.
|
|
1020
|
+
def synthesize_attribute_methods(node)
|
|
1021
|
+
return unless %i[attr_reader attr_writer attr_accessor].include?(node.name)
|
|
1022
|
+
|
|
1023
|
+
names = Array(node.arguments&.arguments).filter_map do |argument|
|
|
1024
|
+
argument.unescaped.to_sym if argument.is_a?(Prism::SymbolNode)
|
|
1025
|
+
end
|
|
1026
|
+
names.each do |name|
|
|
1027
|
+
methods << synthesized_accessor(name, node, writer: false) unless node.name == :attr_writer
|
|
1028
|
+
methods << synthesized_accessor(name, node, writer: true) unless node.name == :attr_reader
|
|
1029
|
+
end
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
def synthesized_accessor(name, node, writer:)
|
|
1033
|
+
scope = Object.new
|
|
1034
|
+
value = TypeVariable.new(scope, 0, name)
|
|
1035
|
+
owner = current_owner
|
|
1036
|
+
method_name = writer ? :"#{name}=" : name
|
|
1037
|
+
MethodResult.new(
|
|
1038
|
+
name: "#{owner}#{@singleton ? "." : "#"}#{method_name}",
|
|
1039
|
+
owner: owner,
|
|
1040
|
+
kind: @singleton ? :singleton : :instance,
|
|
1041
|
+
method_name: method_name,
|
|
1042
|
+
superclass: @superclasses[owner],
|
|
1043
|
+
line: node.location.start_line,
|
|
1044
|
+
end_line: node.location.start_line,
|
|
1045
|
+
parameters: writer ? [Parameter.new(name: name, type: value, kind: :required)] : [],
|
|
1046
|
+
result: value,
|
|
1047
|
+
capabilities: [],
|
|
1048
|
+
self_type: TypeVariable.new(scope, 1, "self"),
|
|
1049
|
+
ivars: { "@#{name}": value },
|
|
1050
|
+
locals: {},
|
|
1051
|
+
port_assignments: [],
|
|
1052
|
+
case_narrowings: {}
|
|
1053
|
+
)
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
def within_namespace(name)
|
|
1057
|
+
@namespace << name
|
|
1058
|
+
yield
|
|
1059
|
+
ensure
|
|
1060
|
+
@namespace.pop
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def qualified_owner(name)
|
|
1064
|
+
(@namespace + [name]).join("::")
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def record_include(node)
|
|
1068
|
+
return if @singleton || @namespace.empty?
|
|
1069
|
+
|
|
1070
|
+
owner = current_owner
|
|
1071
|
+
Array(node.arguments&.arguments).each do |argument|
|
|
1072
|
+
name = constant_argument_slice(argument)
|
|
1073
|
+
@includes[owner] << name if name
|
|
1074
|
+
end
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def constant_argument_slice(node)
|
|
1078
|
+
case node
|
|
1079
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
1080
|
+
when Prism::ConstantPathNode then node.slice
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
class FixedPointInferencer
|
|
1086
|
+
MAX_ITERATIONS = 30
|
|
1087
|
+
PREDICATES = %i[
|
|
1088
|
+
== != < <= > >= all? any? blank? connected? empty? eql? include?
|
|
1089
|
+
is_a? key? member? nil? present? respond_to? start_with? end_with? zero?
|
|
1090
|
+
].to_set.freeze
|
|
1091
|
+
INTEGER_RESULTS = %i[count length size bytesize].to_set.freeze
|
|
1092
|
+
STRING_RESULTS = %i[
|
|
1093
|
+
strip lstrip rstrip chomp chop squeeze tr downcase upcase capitalize
|
|
1094
|
+
swapcase gsub sub join to_json encode truncate squish
|
|
1095
|
+
strip! chomp! gsub! sub! downcase! upcase! squish!
|
|
1096
|
+
].to_set.freeze
|
|
1097
|
+
STRING_LIST_RESULTS = %i[split lines chars].to_set.freeze
|
|
1098
|
+
NORETURN = Named.new("noreturn")
|
|
1099
|
+
|
|
1100
|
+
attr_reader :iterations
|
|
1101
|
+
|
|
1102
|
+
def initialize(methods, constants: {}, includes: {}, metadata: InferenceMetadata.empty)
|
|
1103
|
+
# `initialize` is only reachable through `new`, which returns the
|
|
1104
|
+
# instance, so its meaningful type is the owner class.
|
|
1105
|
+
@methods = methods.map do |method|
|
|
1106
|
+
next method unless method.kind == :instance && method.method_name == :initialize
|
|
1107
|
+
|
|
1108
|
+
method.with(result: Named.new(method.owner))
|
|
1109
|
+
end
|
|
1110
|
+
@index = @methods.to_h { |method| [[method.owner, method.kind, method.method_name], method] }
|
|
1111
|
+
@superclasses = methods.filter_map do |method|
|
|
1112
|
+
[method.owner, method.superclass] if method.superclass
|
|
1113
|
+
end.to_h
|
|
1114
|
+
@constants = constants
|
|
1115
|
+
@includes = resolve_includes(includes)
|
|
1116
|
+
@metadata = metadata
|
|
1117
|
+
@duck_index = Hash.new { |hash, key| hash[key] = [] }
|
|
1118
|
+
@methods.each do |method|
|
|
1119
|
+
next unless method.kind == :instance && method.method_name != :initialize
|
|
1120
|
+
|
|
1121
|
+
@duck_index[method.method_name] << method
|
|
1122
|
+
end
|
|
1123
|
+
@substitutions = {}
|
|
1124
|
+
@call_mappings = {}
|
|
1125
|
+
@stable_variables = {}
|
|
1126
|
+
@fresh_scope = Object.new
|
|
1127
|
+
@fresh_id = 0
|
|
1128
|
+
@iterations = 0
|
|
1129
|
+
@converged = false
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def run
|
|
1133
|
+
apply_declared_annotations!
|
|
1134
|
+
anchor_constructor_definitions
|
|
1135
|
+
solve_to_fixed_point
|
|
1136
|
+
|
|
1137
|
+
# Newly grounded parameters make more receivers concrete, so another
|
|
1138
|
+
# solving round can resolve targets that failed before. The second
|
|
1139
|
+
# round lets values flow through one level of indirection (caller ->
|
|
1140
|
+
# factory -> constructor).
|
|
1141
|
+
2.times do
|
|
1142
|
+
flow_call_arguments!
|
|
1143
|
+
flow_structural_arguments!
|
|
1144
|
+
solve_to_fixed_point
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
apply_late_send_defaults!
|
|
1148
|
+
apply_definition_conventions!
|
|
1149
|
+
narrow_residual_returns!
|
|
1150
|
+
unify_instance_variables
|
|
1151
|
+
solve_to_fixed_point
|
|
1152
|
+
apply_intra_method_param_flow!
|
|
1153
|
+
apply_chained_send_narrowing!
|
|
1154
|
+
apply_block_argument_callee_flow!
|
|
1155
|
+
flow_call_arguments!
|
|
1156
|
+
solve_to_fixed_point
|
|
1157
|
+
reflow_callee_returns!
|
|
1158
|
+
solve_to_fixed_point
|
|
1159
|
+
apply_mutator_return_nil!
|
|
1160
|
+
apply_forced_return_conventions!
|
|
1161
|
+
@methods.map { |method| resolved_method(method) }
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
def converged? = @converged
|
|
1165
|
+
|
|
1166
|
+
def apply_declared_annotations!
|
|
1167
|
+
apply_declared_member_types!
|
|
1168
|
+
apply_factory_param_bindings!
|
|
1169
|
+
apply_case_branch_narrowing!
|
|
1170
|
+
apply_core_member_type_conventions!
|
|
1171
|
+
apply_initialize_param_bindings!
|
|
1172
|
+
apply_port_handler_wiring! if @metadata.port_wiring?
|
|
1173
|
+
apply_output_emit_wiring! if @metadata.output_emit?
|
|
1174
|
+
apply_config_hash_wiring! if @metadata.config_hash?
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
def apply_core_member_type_conventions!
|
|
1178
|
+
CORE_MEMBER_NAME_TYPES.each do |member, type_name|
|
|
1179
|
+
type = Named.new(type_name)
|
|
1180
|
+
@duck_index[member].each do |accessor|
|
|
1181
|
+
unify(accessor.result, type)
|
|
1182
|
+
accessor.ivars.each_value { |ivar| unify(ivar, type) }
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
def apply_initialize_param_bindings!
|
|
1188
|
+
@methods.each do |method|
|
|
1189
|
+
next unless method.kind == :instance && method.method_name == :initialize
|
|
1190
|
+
|
|
1191
|
+
method.parameters.each do |parameter|
|
|
1192
|
+
ivar = method.ivars[:"@#{parameter.name}"]
|
|
1193
|
+
unify(parameter.type, ivar) if ivar
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
def apply_declared_member_types!
|
|
1199
|
+
@metadata.member_types.each do |owner, fields|
|
|
1200
|
+
fields.each do |member, type|
|
|
1201
|
+
accessor = @index[[owner, :instance, member]]
|
|
1202
|
+
next unless accessor
|
|
1203
|
+
|
|
1204
|
+
unify(accessor.result, type)
|
|
1205
|
+
accessor.ivars.each_value { |ivar| unify(ivar, type) }
|
|
1206
|
+
end
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def apply_factory_param_bindings!
|
|
1211
|
+
@metadata.member_types.each do |owner, fields|
|
|
1212
|
+
%i[create new].each do |method_name|
|
|
1213
|
+
factory = @index[[owner, :singleton, method_name]]
|
|
1214
|
+
next unless factory
|
|
1215
|
+
|
|
1216
|
+
factory.parameters.each do |parameter|
|
|
1217
|
+
next unless %i[keyword optional_keyword].include?(parameter.kind)
|
|
1218
|
+
|
|
1219
|
+
type = fields[parameter.name]
|
|
1220
|
+
unify(parameter.type, type) if type
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def apply_case_branch_narrowing!
|
|
1227
|
+
@methods.each do |method|
|
|
1228
|
+
method.case_narrowings.each do |name, types|
|
|
1229
|
+
parameter = method.parameters.find { |candidate| candidate.name == name }
|
|
1230
|
+
next unless parameter
|
|
1231
|
+
|
|
1232
|
+
merged = types.reduce { |left, right| merge_union(left, right) }
|
|
1233
|
+
unify(parameter.type, merged) if merged
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
def apply_port_handler_wiring!
|
|
1239
|
+
wiring = @metadata.port_wiring
|
|
1240
|
+
@metadata.ports.each do |owner, ports|
|
|
1241
|
+
inputs = ports[:inputs]
|
|
1242
|
+
next if inputs.empty?
|
|
1243
|
+
|
|
1244
|
+
handler = @index[[owner, :instance, wiring.handler_method]]
|
|
1245
|
+
next unless handler
|
|
1246
|
+
|
|
1247
|
+
entity_param = handler_entity_param(handler, wiring.entity_param)
|
|
1248
|
+
dispatch_param = handler.parameters.find do |parameter|
|
|
1249
|
+
parameter.name == wiring.dispatch_param &&
|
|
1250
|
+
%i[keyword optional_keyword].include?(parameter.kind)
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
if entity_param
|
|
1254
|
+
entity_type = port_type_for_param(entity_param.name, inputs)
|
|
1255
|
+
unify(entity_param.type, entity_type) if entity_type
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
unify(dispatch_param.type, wiring.dispatch_param_type) if dispatch_param
|
|
1259
|
+
end
|
|
1260
|
+
apply_port_assignments!
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
def handler_entity_param(handler, selector)
|
|
1264
|
+
case selector
|
|
1265
|
+
when :first_required
|
|
1266
|
+
handler.parameters.find { |parameter| parameter.kind == :required }
|
|
1267
|
+
when Symbol
|
|
1268
|
+
handler.parameters.find { |parameter| parameter.name == selector }
|
|
1269
|
+
end
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
def apply_output_emit_wiring!
|
|
1273
|
+
wiring = @metadata.output_emit
|
|
1274
|
+
@metadata.ports.each do |owner, ports|
|
|
1275
|
+
outputs = ports[:outputs]
|
|
1276
|
+
next if outputs.empty?
|
|
1277
|
+
|
|
1278
|
+
@methods.select { |method| method.owner == owner }.each do |method|
|
|
1279
|
+
method.capabilities.each do |capability|
|
|
1280
|
+
next unless capability.message == wiring.message
|
|
1281
|
+
next unless capability.receiver == method.self_type
|
|
1282
|
+
|
|
1283
|
+
port_key = literal_symbol_key(capability.keywords[wiring.port_keyword])
|
|
1284
|
+
next unless port_key
|
|
1285
|
+
|
|
1286
|
+
output_type = outputs[port_key]
|
|
1287
|
+
next unless output_type
|
|
1288
|
+
|
|
1289
|
+
payload = capability.arguments[wiring.argument_index]
|
|
1290
|
+
unify(payload, output_type) if payload
|
|
1291
|
+
end
|
|
1292
|
+
end
|
|
1293
|
+
end
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def port_type_for_param(param_name, inputs)
|
|
1297
|
+
return inputs.values.first if inputs.length == 1
|
|
1298
|
+
|
|
1299
|
+
matched = inputs.find { |port_name, _| param_matches_port?(param_name, port_name) }
|
|
1300
|
+
return matched.last if matched
|
|
1301
|
+
|
|
1302
|
+
members = inputs.values.uniq
|
|
1303
|
+
members.length == 1 ? members.first : Union.new(members)
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def param_matches_port?(param_name, port_name)
|
|
1307
|
+
param = param_name.to_s
|
|
1308
|
+
port = port_name.to_s
|
|
1309
|
+
return true if param == port
|
|
1310
|
+
return true if port == "#{param}s" || port == "#{param}es"
|
|
1311
|
+
return true if port.end_with?("_#{param}") || port.start_with?("#{param}_")
|
|
1312
|
+
|
|
1313
|
+
false
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def apply_port_assignments!
|
|
1317
|
+
@methods.each do |method|
|
|
1318
|
+
next if method.port_assignments.empty?
|
|
1319
|
+
|
|
1320
|
+
ports = @metadata.ports.dig(method.owner, :inputs) || {}
|
|
1321
|
+
method.port_assignments.each do |assignment|
|
|
1322
|
+
port_type = ports[assignment.fetch(:port)]
|
|
1323
|
+
next unless port_type
|
|
1324
|
+
|
|
1325
|
+
param = method.parameters.find { |parameter| parameter.name == assignment.fetch(:param) }
|
|
1326
|
+
unify(param.type, port_type) if param
|
|
1327
|
+
|
|
1328
|
+
ivar = method.ivars[assignment.fetch(:ivar)]
|
|
1329
|
+
unify(ivar, port_type) if assignment.fetch(:ivar) && ivar
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
def apply_config_hash_wiring!
|
|
1335
|
+
wiring = @metadata.config_hash
|
|
1336
|
+
@metadata.config_options.each do |owner, options|
|
|
1337
|
+
next if options.empty?
|
|
1338
|
+
|
|
1339
|
+
init = @index[[owner, :instance, :initialize]]
|
|
1340
|
+
next unless init
|
|
1341
|
+
|
|
1342
|
+
config_param = init.parameters.find { |parameter| parameter.name == wiring.init_param }
|
|
1343
|
+
next unless config_param
|
|
1344
|
+
|
|
1345
|
+
value_type = options.values.uniq
|
|
1346
|
+
value_type = value_type.length == 1 ? value_type.first : Union.new(value_type)
|
|
1347
|
+
unify(config_param.type, Generic.new("Hash", [Named.new("Symbol"), value_type]))
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
@methods.each do |method|
|
|
1351
|
+
options = @metadata.config_options[method.owner]
|
|
1352
|
+
next unless options
|
|
1353
|
+
|
|
1354
|
+
method.capabilities.each do |capability|
|
|
1355
|
+
next unless %i[fetch [] dig].include?(capability.message)
|
|
1356
|
+
next unless config_hash_receiver?(method, capability.receiver, wiring)
|
|
1357
|
+
|
|
1358
|
+
key = literal_symbol_key(capability.arguments.first)
|
|
1359
|
+
next unless key
|
|
1360
|
+
|
|
1361
|
+
option_type = options[key]
|
|
1362
|
+
unify(capability.result, option_type) if option_type
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
def config_hash_receiver?(method, receiver, wiring)
|
|
1368
|
+
terminal = dereference(resolve(receiver))
|
|
1369
|
+
return true if terminal == Named.new("Hash")
|
|
1370
|
+
|
|
1371
|
+
method.ivars.key?(wiring.ivar) && dereference(method.ivars[wiring.ivar]) == terminal
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
def literal_symbol_key(key)
|
|
1375
|
+
case key
|
|
1376
|
+
when Named then key.name.to_sym
|
|
1377
|
+
end
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def literal_hash_key?(hash_type, key)
|
|
1381
|
+
literal_key = literal_symbol_key(key) || literal_integer_key(key)
|
|
1382
|
+
return false unless literal_key
|
|
1383
|
+
|
|
1384
|
+
case hash_type
|
|
1385
|
+
when Generic
|
|
1386
|
+
return false unless hash?(hash_type)
|
|
1387
|
+
|
|
1388
|
+
key_type = dereference(hash_type.arguments[0])
|
|
1389
|
+
case key_type
|
|
1390
|
+
when Named then key_type.name == "Symbol" || key_type.name == "Integer"
|
|
1391
|
+
else false
|
|
1392
|
+
end
|
|
1393
|
+
when Union
|
|
1394
|
+
hash_type.members.any? { |member| literal_hash_key?(member, key) }
|
|
1395
|
+
else
|
|
1396
|
+
false
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
def literal_integer_key(key)
|
|
1401
|
+
case key
|
|
1402
|
+
when Named
|
|
1403
|
+
return key.name.to_i if key.name.match?(/\A-?\d+\z/)
|
|
1404
|
+
|
|
1405
|
+
nil
|
|
1406
|
+
end
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
def literal_key_name(key)
|
|
1410
|
+
case key
|
|
1411
|
+
when Named
|
|
1412
|
+
return key.name unless RESERVED_TYPE_NAMES.include?(key.name)
|
|
1413
|
+
|
|
1414
|
+
nil
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
def core_member_type_for_key(key_arg)
|
|
1419
|
+
name = literal_key_name(key_arg)&.to_sym
|
|
1420
|
+
return unless name
|
|
1421
|
+
|
|
1422
|
+
if (type_name = CORE_MEMBER_NAME_TYPES[name])
|
|
1423
|
+
return Named.new(type_name)
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
@metadata.member_type_hints[name]
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
def hash_new_block_value_type(block)
|
|
1430
|
+
resolved = resolve(block.result)
|
|
1431
|
+
return resolved if hash?(resolved) || array?(resolved)
|
|
1432
|
+
return resolved if ground?(resolved) && !resolved.is_a?(Named)
|
|
1433
|
+
|
|
1434
|
+
nil
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
def unify_hash_key_argument!(hash_type, key_arg)
|
|
1438
|
+
return unless hash?(hash_type)
|
|
1439
|
+
|
|
1440
|
+
terminal = dereference(key_arg)
|
|
1441
|
+
case terminal
|
|
1442
|
+
when Named
|
|
1443
|
+
if terminal.name == "Integer" || terminal.name == "Symbol"
|
|
1444
|
+
unify(hash_type.arguments[0], terminal)
|
|
1445
|
+
elsif terminal.name.match?(/\A-?\d+\z/)
|
|
1446
|
+
unify(hash_type.arguments[0], Named.new("Integer"))
|
|
1447
|
+
elsif literal_symbol_key(terminal)
|
|
1448
|
+
unify(hash_type.arguments[0], Named.new("Symbol"))
|
|
1449
|
+
end
|
|
1450
|
+
end
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
def hash_key_type_compatible?(hash_type, key_arg)
|
|
1454
|
+
case hash_type
|
|
1455
|
+
when Generic
|
|
1456
|
+
return false unless hash?(hash_type)
|
|
1457
|
+
|
|
1458
|
+
dereference(hash_type.arguments[0]) == dereference(key_arg)
|
|
1459
|
+
when Union
|
|
1460
|
+
hash_type.members.any? { |member| hash_key_type_compatible?(member, key_arg) }
|
|
1461
|
+
else
|
|
1462
|
+
false
|
|
1463
|
+
end
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
def hash_dig_type(hash_type, keys)
|
|
1467
|
+
current = hash_type
|
|
1468
|
+
keys.each do |key|
|
|
1469
|
+
return nil unless hash?(current)
|
|
1470
|
+
|
|
1471
|
+
if literal_hash_key?(current, key) || hash_key_type_compatible?(current, key)
|
|
1472
|
+
current = current.arguments[1]
|
|
1473
|
+
else
|
|
1474
|
+
current = current.arguments[1]
|
|
1475
|
+
end
|
|
1476
|
+
end
|
|
1477
|
+
current
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
def structured_type?(type, _method)
|
|
1481
|
+
terminal = dereference(type)
|
|
1482
|
+
return false unless terminal.is_a?(Named)
|
|
1483
|
+
|
|
1484
|
+
structured_owner?(terminal.name)
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
def structured_owner?(owner)
|
|
1488
|
+
return true if @metadata.structured_type?(owner)
|
|
1489
|
+
|
|
1490
|
+
init = @index[[owner, :instance, :initialize]]
|
|
1491
|
+
return false unless init
|
|
1492
|
+
|
|
1493
|
+
init.parameters.any? do |parameter|
|
|
1494
|
+
%i[keyword optional_keyword].include?(parameter.kind) &&
|
|
1495
|
+
(parameter.name == :id || @metadata.member_type_hints.key?(parameter.name))
|
|
1496
|
+
end
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
def structured_owner_name(type)
|
|
1500
|
+
terminal = dereference(type)
|
|
1501
|
+
terminal.is_a?(Named) && structured_owner?(terminal.name) ? terminal.name : nil
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
def entity_hash_type(owner)
|
|
1505
|
+
fields = @metadata.member_types[owner]
|
|
1506
|
+
if fields.nil? || fields.empty?
|
|
1507
|
+
return Generic.new("Hash", [Named.new("String"), Named.new("Object")])
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
value_type = fields.values.reduce { |left, right| merge_union(left, right) }
|
|
1511
|
+
Generic.new("Hash", [Named.new("String"), value_type])
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
private
|
|
1515
|
+
|
|
1516
|
+
def native_solver_enabled?
|
|
1517
|
+
return @native_solver_enabled if defined?(@native_solver_enabled)
|
|
1518
|
+
|
|
1519
|
+
@native_solver_enabled = begin
|
|
1520
|
+
require_relative "native_bridge"
|
|
1521
|
+
NativeBridge.enabled?
|
|
1522
|
+
rescue LoadError, StandardError
|
|
1523
|
+
false
|
|
1524
|
+
end
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
def solve_to_fixed_point
|
|
1528
|
+
if native_solver_enabled?
|
|
1529
|
+
NativeBridge.prime!(self)
|
|
1530
|
+
end
|
|
1531
|
+
|
|
1532
|
+
MAX_ITERATIONS.times do |iteration|
|
|
1533
|
+
@changed = false
|
|
1534
|
+
@duck_cache = {}
|
|
1535
|
+
@methods.each do |method|
|
|
1536
|
+
method.capabilities.each do |capability|
|
|
1537
|
+
apply_builtin(method, capability)
|
|
1538
|
+
# Anchor before resolving the target so a delegating `new`
|
|
1539
|
+
# override cannot flood the call site with unresolved variables.
|
|
1540
|
+
apply_constructor(method, capability)
|
|
1541
|
+
target = resolve_target(method, capability)
|
|
1542
|
+
if target
|
|
1543
|
+
apply_method_call(method, capability, target)
|
|
1544
|
+
elsif apply_union_member_dispatch(method, capability)
|
|
1545
|
+
next
|
|
1546
|
+
else
|
|
1547
|
+
apply_duck_dispatch(method, capability)
|
|
1548
|
+
apply_fallback(method, capability)
|
|
1549
|
+
end
|
|
1550
|
+
end
|
|
1551
|
+
end
|
|
1552
|
+
@iterations = iteration + 1
|
|
1553
|
+
unless @changed
|
|
1554
|
+
@converged = true
|
|
1555
|
+
break
|
|
1556
|
+
end
|
|
1557
|
+
end
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
# Singleton `new`/`create` definitions return an instance of their owner,
|
|
1561
|
+
# whatever their bodies delegate to.
|
|
1562
|
+
def anchor_constructor_definitions
|
|
1563
|
+
@methods.each do |method|
|
|
1564
|
+
next unless method.kind == :singleton && %i[new create].include?(method.method_name)
|
|
1565
|
+
|
|
1566
|
+
anchor = Named.new(method.owner)
|
|
1567
|
+
branches = method.result.is_a?(Union) ? method.result.members : [method.result]
|
|
1568
|
+
branches.each { |branch| unify(branch, anchor) }
|
|
1569
|
+
end
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
def unify_instance_variables
|
|
1573
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
1574
|
+
@methods.each do |method|
|
|
1575
|
+
method.ivars.each { |name, type| groups[[method.owner, name]] << type }
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
groups.each_value do |types|
|
|
1579
|
+
observed = types.filter_map do |type|
|
|
1580
|
+
resolved = resolve(type)
|
|
1581
|
+
resolved unless resolved.is_a?(TypeVariable)
|
|
1582
|
+
end
|
|
1583
|
+
merged = observed.reduce { |left, right| merge_union(left, right) }
|
|
1584
|
+
next unless merged
|
|
1585
|
+
|
|
1586
|
+
types.each do |type|
|
|
1587
|
+
terminal = dereference(type)
|
|
1588
|
+
unify(terminal, merged) if terminal.is_a?(TypeVariable)
|
|
1589
|
+
end
|
|
1590
|
+
end
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
def apply_builtin(method, capability)
|
|
1594
|
+
receiver = unwrap_nilable(resolve(capability.receiver))
|
|
1595
|
+
message = capability.message
|
|
1596
|
+
result = capability.result
|
|
1597
|
+
|
|
1598
|
+
if (constant = class_name_from(receiver))
|
|
1599
|
+
# Constant reads look like class references; when the constant is
|
|
1600
|
+
# actually a typed literal (regex, hash table, word list), sends on
|
|
1601
|
+
# it are sends on the literal.
|
|
1602
|
+
literal = constant_literal(constant, method.owner)
|
|
1603
|
+
if literal
|
|
1604
|
+
receiver = literal
|
|
1605
|
+
else
|
|
1606
|
+
apply_class_builtin(constant, capability)
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
if PREDICATES.include?(message)
|
|
1611
|
+
unify(result, Named.new("bool"))
|
|
1612
|
+
elsif INTEGER_RESULTS.include?(message)
|
|
1613
|
+
unify(result, Named.new("Integer"))
|
|
1614
|
+
elsif STRING_RESULTS.include?(message)
|
|
1615
|
+
unify(result, Named.new("String"))
|
|
1616
|
+
elsif STRING_LIST_RESULTS.include?(message)
|
|
1617
|
+
unify(result, Generic.new("Array", [Named.new("String")]))
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
case message
|
|
1621
|
+
when :to_s, :inspect, :String
|
|
1622
|
+
unify(result, Named.new("String"))
|
|
1623
|
+
when :to_i, :ord, :Integer
|
|
1624
|
+
unify(result, Named.new("Integer"))
|
|
1625
|
+
when :to_f, :Float
|
|
1626
|
+
unify(result, Named.new("Float"))
|
|
1627
|
+
when :to_sym
|
|
1628
|
+
unify(result, Named.new("Symbol"))
|
|
1629
|
+
when :!
|
|
1630
|
+
unify(result, Named.new("bool"))
|
|
1631
|
+
when :raise, :throw
|
|
1632
|
+
unify(result, NORETURN)
|
|
1633
|
+
when :puts, :print
|
|
1634
|
+
unify(result, Named.new("nil"))
|
|
1635
|
+
when :emit, :close_input_queues!, :release_optional_inputs!, :close_input_port!
|
|
1636
|
+
unify(result, Named.new("nil")) if capability.keywords.key?(:to) || %i[close_input_queues! release_optional_inputs! close_input_port!].include?(message)
|
|
1637
|
+
when :debug, :info, :warn, :error, :fatal
|
|
1638
|
+
unify(result, Named.new("nil")) if receiver == Named.new("Logger")
|
|
1639
|
+
when :logger
|
|
1640
|
+
unify(result, Named.new("Logger"))
|
|
1641
|
+
when :tap, :itself, :dup, :clone, :freeze, :deep_dup, :with
|
|
1642
|
+
unify(result, receiver)
|
|
1643
|
+
when :synchronize
|
|
1644
|
+
block = capability.arguments.last
|
|
1645
|
+
unify(result, block.result) if receiver == Named.new("Mutex") && block.is_a?(Function)
|
|
1646
|
+
when :clock_gettime
|
|
1647
|
+
unify(result, Named.new("Float"))
|
|
1648
|
+
when :then, :yield_self
|
|
1649
|
+
block = capability.arguments.last
|
|
1650
|
+
unify(result, block.result) if block.is_a?(Function)
|
|
1651
|
+
when :presence
|
|
1652
|
+
unify(result, Union.new([receiver, Named.new("nil")].uniq)) unless receiver == Named.new("nil")
|
|
1653
|
+
when :new, :create
|
|
1654
|
+
class_name = class_name_from(receiver)
|
|
1655
|
+
if class_name == "Hash"
|
|
1656
|
+
block = capability.arguments.find { |argument| argument.is_a?(Function) }
|
|
1657
|
+
if block
|
|
1658
|
+
key_type = block.parameters[1] || stable_fresh([capability.object_id, :hash_key], "key")
|
|
1659
|
+
value_type = hash_new_block_value_type(block) || block.result
|
|
1660
|
+
unify(result, Generic.new("Hash", [key_type, value_type]))
|
|
1661
|
+
else
|
|
1662
|
+
unify(result, Named.new("Hash"))
|
|
1663
|
+
end
|
|
1664
|
+
elsif class_name
|
|
1665
|
+
unify(result, Named.new(class_name))
|
|
1666
|
+
end
|
|
1667
|
+
when :Array
|
|
1668
|
+
argument = capability.arguments.first || stable_fresh([capability.object_id, :array_element], "element")
|
|
1669
|
+
element = array_element(resolve(argument)) || resolve(argument)
|
|
1670
|
+
unify(result, Generic.new("Array", [element]))
|
|
1671
|
+
when :flatten
|
|
1672
|
+
element = array_element(receiver)
|
|
1673
|
+
if element
|
|
1674
|
+
flattened = array_element(element) || element
|
|
1675
|
+
unify(result, Generic.new("Array", [flattened]))
|
|
1676
|
+
end
|
|
1677
|
+
when :take, :uniq, :sort, :sort_by, :reverse,
|
|
1678
|
+
:drop, :take_while, :drop_while, :shuffle, :rotate, :to_a
|
|
1679
|
+
unify(result, receiver) if receiver.is_a?(Generic) && receiver.name == "Array"
|
|
1680
|
+
when :reject, :select, :filter, :find_all, :grep, :index_by
|
|
1681
|
+
apply_enumerable_block(receiver, capability, result, mode: :same_collection)
|
|
1682
|
+
when :compact, :compact!
|
|
1683
|
+
element = array_element(receiver)
|
|
1684
|
+
if element
|
|
1685
|
+
unify(result, Generic.new("Array", [strip_nil(element)]))
|
|
1686
|
+
elsif hash?(receiver)
|
|
1687
|
+
unify(result, Generic.new("Hash", [receiver.arguments[0], strip_nil(receiver.arguments[1])]))
|
|
1688
|
+
end
|
|
1689
|
+
when :to_set
|
|
1690
|
+
element = enumerable_element(receiver)
|
|
1691
|
+
unify(result, Generic.new("Set", [element])) if element
|
|
1692
|
+
when :<<, :push, :append, :unshift, :prepend
|
|
1693
|
+
if array?(receiver)
|
|
1694
|
+
capability.arguments.each do |argument|
|
|
1695
|
+
unify_array_elements(receiver, argument) unless argument.is_a?(Function)
|
|
1696
|
+
end
|
|
1697
|
+
unify(result, receiver)
|
|
1698
|
+
elsif receiver == Named.new("String")
|
|
1699
|
+
unify(result, receiver)
|
|
1700
|
+
end
|
|
1701
|
+
when :concat
|
|
1702
|
+
if array?(receiver)
|
|
1703
|
+
capability.arguments.each do |argument|
|
|
1704
|
+
unify(receiver, argument) unless argument.is_a?(Function)
|
|
1705
|
+
end
|
|
1706
|
+
unify(result, receiver)
|
|
1707
|
+
elsif receiver == Named.new("String")
|
|
1708
|
+
unify(result, receiver)
|
|
1709
|
+
end
|
|
1710
|
+
when :+, :-, :*, :/, :%, :**
|
|
1711
|
+
if receiver.is_a?(Named) && %w[Integer Float String].include?(receiver.name)
|
|
1712
|
+
unify(result, receiver)
|
|
1713
|
+
elsif array?(receiver) && %i[+ -].include?(message)
|
|
1714
|
+
unify(result, receiver)
|
|
1715
|
+
end
|
|
1716
|
+
when :first, :last, :min, :max, :find, :detect, :sample
|
|
1717
|
+
element = enumerable_element(receiver)
|
|
1718
|
+
unify(result, Union.new([element, Named.new("nil")])) if element
|
|
1719
|
+
when :sum
|
|
1720
|
+
element = enumerable_element(receiver)
|
|
1721
|
+
unify(result, element) if element.is_a?(Named) && %w[Integer Float].include?(element.name)
|
|
1722
|
+
when :map, :collect
|
|
1723
|
+
element = enumerable_element(receiver)
|
|
1724
|
+
block = capability.arguments.last
|
|
1725
|
+
if block.is_a?(Function)
|
|
1726
|
+
unify(block.parameters.first, element) if element && block.parameters.first
|
|
1727
|
+
unify(result, Generic.new("Array", [block.result]))
|
|
1728
|
+
elsif element
|
|
1729
|
+
unify(result, Generic.new("Enumerator", [element]))
|
|
1730
|
+
end
|
|
1731
|
+
when :flat_map
|
|
1732
|
+
block = capability.arguments.last
|
|
1733
|
+
if block.is_a?(Function)
|
|
1734
|
+
element = enumerable_element(receiver)
|
|
1735
|
+
unify(block.parameters.first, element) if element && block.parameters.first
|
|
1736
|
+
inner = resolve(block.result)
|
|
1737
|
+
unify(result, Generic.new("Array", [array_element(inner) || inner]))
|
|
1738
|
+
end
|
|
1739
|
+
when :filter_map
|
|
1740
|
+
block = capability.arguments.last
|
|
1741
|
+
unify(result, Generic.new("Array", [strip_nil(resolve(block.result))])) if block.is_a?(Function)
|
|
1742
|
+
when :each_with_object
|
|
1743
|
+
memo = capability.arguments.find { |argument| !argument.is_a?(Function) }
|
|
1744
|
+
block = capability.arguments.last
|
|
1745
|
+
if block.is_a?(Function)
|
|
1746
|
+
element = enumerable_element(receiver)
|
|
1747
|
+
unify(block.parameters[0], element) if element && block.parameters[0]
|
|
1748
|
+
unify(block.parameters[1], memo) if memo && block.parameters[1]
|
|
1749
|
+
end
|
|
1750
|
+
unify(result, memo) if memo
|
|
1751
|
+
when :reduce, :inject
|
|
1752
|
+
block = capability.arguments.last
|
|
1753
|
+
unify(result, block.result) if block.is_a?(Function)
|
|
1754
|
+
when :keys
|
|
1755
|
+
unify(result, Generic.new("Array", [receiver.arguments[0]])) if hash?(receiver)
|
|
1756
|
+
when :values
|
|
1757
|
+
unify(result, Generic.new("Array", [receiver.arguments[1]])) if hash?(receiver)
|
|
1758
|
+
when :to_h, :to_hash
|
|
1759
|
+
if (owner = structured_owner_name(receiver))
|
|
1760
|
+
unify(result, entity_hash_type(owner))
|
|
1761
|
+
elsif receiver.is_a?(Union)
|
|
1762
|
+
hash_types = receiver.members.filter_map { |member| structured_owner_name(member) }.map { |name| entity_hash_type(name) }
|
|
1763
|
+
unify(result, hash_types.reduce { |left, right| merge_union(left, right) }) if hash_types.any?
|
|
1764
|
+
elsif structured_owner?(method.owner) || structured_type?(receiver, method)
|
|
1765
|
+
unify(result, entity_hash_type(method.owner))
|
|
1766
|
+
elsif hash?(receiver)
|
|
1767
|
+
unify(result, receiver)
|
|
1768
|
+
end
|
|
1769
|
+
when :deep_stringify_keys, :stringify_keys
|
|
1770
|
+
if hash?(receiver)
|
|
1771
|
+
unify(result, Generic.new("Hash", [Named.new("String"), receiver.arguments[1]]))
|
|
1772
|
+
end
|
|
1773
|
+
when :merge, :merge!, :except, :slice, :with_indifferent_access, :deep_symbolize_keys, :symbolize_keys
|
|
1774
|
+
unify(result, receiver) if hash?(receiver)
|
|
1775
|
+
when :transform_values
|
|
1776
|
+
block = capability.arguments.last
|
|
1777
|
+
if hash?(receiver) && block.is_a?(Function)
|
|
1778
|
+
unify(block.parameters.first, receiver.arguments[1]) if block.parameters.first
|
|
1779
|
+
unify(result, Generic.new("Hash", [receiver.arguments[0], block.result]))
|
|
1780
|
+
end
|
|
1781
|
+
when :transform_keys
|
|
1782
|
+
block = capability.arguments.last
|
|
1783
|
+
if hash?(receiver) && block.is_a?(Function)
|
|
1784
|
+
unify(block.parameters.first, receiver.arguments[0]) if block.parameters.first
|
|
1785
|
+
unify(result, Generic.new("Hash", [block.result, receiver.arguments[1]]))
|
|
1786
|
+
end
|
|
1787
|
+
when :each_value
|
|
1788
|
+
if hash?(receiver)
|
|
1789
|
+
block = capability.arguments.last
|
|
1790
|
+
unify(block.parameters.first, receiver.arguments[1]) if block.is_a?(Function) && block.parameters.first
|
|
1791
|
+
unify(result, receiver)
|
|
1792
|
+
end
|
|
1793
|
+
when :each_key
|
|
1794
|
+
if hash?(receiver)
|
|
1795
|
+
block = capability.arguments.last
|
|
1796
|
+
unify(block.parameters.first, receiver.arguments[0]) if block.is_a?(Function) && block.parameters.first
|
|
1797
|
+
unify(result, receiver)
|
|
1798
|
+
end
|
|
1799
|
+
when :fetch
|
|
1800
|
+
if hash?(receiver)
|
|
1801
|
+
if (option_type = config_option_type(method, capability.arguments.first))
|
|
1802
|
+
unify(result, option_type)
|
|
1803
|
+
else
|
|
1804
|
+
unify(result, receiver.arguments[1])
|
|
1805
|
+
end
|
|
1806
|
+
elsif array?(receiver)
|
|
1807
|
+
unify(result, array_element(receiver))
|
|
1808
|
+
end
|
|
1809
|
+
when :delete
|
|
1810
|
+
if hash?(receiver)
|
|
1811
|
+
key_arg = capability.arguments.first
|
|
1812
|
+
unify(result, receiver.arguments[1])
|
|
1813
|
+
unify_hash_key_argument!(receiver, key_arg)
|
|
1814
|
+
end
|
|
1815
|
+
when :[]
|
|
1816
|
+
if hash?(receiver)
|
|
1817
|
+
key_arg = capability.arguments.first
|
|
1818
|
+
if (member_type = core_member_type_for_key(key_arg))
|
|
1819
|
+
unify(result, member_type)
|
|
1820
|
+
elsif (option_type = config_option_type(method, key_arg))
|
|
1821
|
+
unify(result, option_type)
|
|
1822
|
+
elsif literal_hash_key?(receiver, key_arg) || hash_key_type_compatible?(receiver, key_arg)
|
|
1823
|
+
unify(result, receiver.arguments[1])
|
|
1824
|
+
else
|
|
1825
|
+
unify(result, Union.new([receiver.arguments[1], Named.new("nil")]))
|
|
1826
|
+
end
|
|
1827
|
+
unify_hash_key_argument!(receiver, key_arg)
|
|
1828
|
+
elsif array?(receiver)
|
|
1829
|
+
unify(result, Union.new([array_element(receiver), Named.new("nil")]))
|
|
1830
|
+
elsif dereference(capability.receiver).is_a?(TypeVariable)
|
|
1831
|
+
key_arg = capability.arguments.first
|
|
1832
|
+
member_type = core_member_type_for_key(key_arg)
|
|
1833
|
+
unify(result, member_type) if member_type
|
|
1834
|
+
elsif receiver == Named.new("String") || receiver == Named.new("MatchData")
|
|
1835
|
+
unify(result, Union.new([Named.new("String"), Named.new("nil")]))
|
|
1836
|
+
end
|
|
1837
|
+
when :dig
|
|
1838
|
+
if hash?(receiver)
|
|
1839
|
+
dug = hash_dig_type(receiver, capability.arguments)
|
|
1840
|
+
if dug
|
|
1841
|
+
unify(result, Union.new([dug, Named.new("nil")]))
|
|
1842
|
+
elsif (option_type = config_option_type(method, capability.arguments.first))
|
|
1843
|
+
unify(result, option_type)
|
|
1844
|
+
else
|
|
1845
|
+
unify(result, Union.new([receiver.arguments[1], Named.new("nil")]))
|
|
1846
|
+
end
|
|
1847
|
+
elsif array?(receiver)
|
|
1848
|
+
unify(result, Union.new([array_element(receiver), Named.new("nil")]))
|
|
1849
|
+
end
|
|
1850
|
+
when :class
|
|
1851
|
+
case receiver
|
|
1852
|
+
when Generic
|
|
1853
|
+
unify(result, Named.new("Class[#{receiver.name}]"))
|
|
1854
|
+
when Named
|
|
1855
|
+
if receiver.name.start_with?("Class[") || %w[nil bool noreturn].include?(receiver.name)
|
|
1856
|
+
unify(result, Named.new("Class"))
|
|
1857
|
+
else
|
|
1858
|
+
unify(result, Named.new("Class[#{receiver.name}]"))
|
|
1859
|
+
end
|
|
1860
|
+
end
|
|
1861
|
+
when :name
|
|
1862
|
+
if receiver.is_a?(Named) && (receiver.name == "Class" || receiver.name.start_with?("Class["))
|
|
1863
|
+
unify(result, Named.new("String"))
|
|
1864
|
+
end
|
|
1865
|
+
when :message, :full_message
|
|
1866
|
+
unify(result, Named.new("String")) if exception_type?(receiver)
|
|
1867
|
+
when :backtrace
|
|
1868
|
+
if exception_type?(receiver)
|
|
1869
|
+
unify(result, Union.new([Generic.new("Array", [Named.new("String")]), Named.new("nil")]))
|
|
1870
|
+
end
|
|
1871
|
+
when :match
|
|
1872
|
+
if [Named.new("Regexp"), Named.new("String")].include?(receiver)
|
|
1873
|
+
unify(result, Union.new([Named.new("MatchData"), Named.new("nil")]))
|
|
1874
|
+
end
|
|
1875
|
+
when :"=~"
|
|
1876
|
+
if [Named.new("Regexp"), Named.new("String")].include?(receiver)
|
|
1877
|
+
unify(result, Union.new([Named.new("Integer"), Named.new("nil")]))
|
|
1878
|
+
end
|
|
1879
|
+
when :source
|
|
1880
|
+
unify(result, Named.new("String")) if receiver == Named.new("Regexp")
|
|
1881
|
+
when :[]=
|
|
1882
|
+
if hash?(receiver) && capability.arguments.length >= 2
|
|
1883
|
+
unify(receiver.arguments[0], capability.arguments[0])
|
|
1884
|
+
unify(receiver.arguments[1], capability.arguments[1])
|
|
1885
|
+
elsif array?(receiver) && capability.arguments.length >= 2
|
|
1886
|
+
unify(array_element(receiver), capability.arguments.last)
|
|
1887
|
+
end
|
|
1888
|
+
unify(result, capability.arguments.last) unless capability.arguments.empty?
|
|
1889
|
+
when :with_index
|
|
1890
|
+
apply_indexed_enumeration(receiver, capability, result)
|
|
1891
|
+
when :each, :each_with_index
|
|
1892
|
+
apply_each(receiver, capability, result, indexed: message == :each_with_index)
|
|
1893
|
+
end
|
|
1894
|
+
|
|
1895
|
+
# Methods that return their receiver regardless of its type: tying the
|
|
1896
|
+
# two variables lets either side ground the other.
|
|
1897
|
+
if SELF_RETURNING.include?(message) && dereference(result).is_a?(TypeVariable)
|
|
1898
|
+
unify(result, capability.receiver)
|
|
1899
|
+
end
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
SELF_RETURNING = %i[
|
|
1903
|
+
each each_with_index each_entry tap dup clone freeze itself
|
|
1904
|
+
<< clear push append unshift prepend concat
|
|
1905
|
+
sort! uniq! compact! reverse! flatten! map!
|
|
1906
|
+
].to_set.freeze
|
|
1907
|
+
|
|
1908
|
+
def exception_type?(type)
|
|
1909
|
+
type.is_a?(Named) &&
|
|
1910
|
+
(type.name == "StandardError" || type.name.end_with?("Error", "Exception"))
|
|
1911
|
+
end
|
|
1912
|
+
|
|
1913
|
+
# Last-resort defaults for sends whose result survived every solving
|
|
1914
|
+
# pass: `.class` is always a Class, key-conversion methods always yield
|
|
1915
|
+
# hashes, `round` is numeric.
|
|
1916
|
+
def apply_late_send_defaults!
|
|
1917
|
+
@methods.each do |method|
|
|
1918
|
+
method.capabilities.each do |capability|
|
|
1919
|
+
next unless dereference(capability.result).is_a?(TypeVariable)
|
|
1920
|
+
|
|
1921
|
+
case capability.message
|
|
1922
|
+
when :class
|
|
1923
|
+
unify(capability.result, Named.new("Class"))
|
|
1924
|
+
when :round, :ceil, :floor
|
|
1925
|
+
unify(capability.result, capability.arguments.empty? ? Named.new("Integer") : Named.new("Float"))
|
|
1926
|
+
when :abs
|
|
1927
|
+
unify(capability.result, Named.new("Numeric"))
|
|
1928
|
+
when :deep_symbolize_keys, :symbolize_keys
|
|
1929
|
+
value = stable_fresh([capability.object_id, :hash_value], "value")
|
|
1930
|
+
unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value]))
|
|
1931
|
+
when :deep_stringify_keys, :stringify_keys, :with_indifferent_access, :to_unsafe_h
|
|
1932
|
+
value = stable_fresh([capability.object_id, :hash_value], "value")
|
|
1933
|
+
unify(capability.result, Generic.new("Hash", [Named.new("String"), value]))
|
|
1934
|
+
end
|
|
1935
|
+
end
|
|
1936
|
+
end
|
|
1937
|
+
end
|
|
1938
|
+
|
|
1939
|
+
# Library classes outside the repo with well-known return types.
|
|
1940
|
+
def apply_class_builtin(constant, capability)
|
|
1941
|
+
result = capability.result
|
|
1942
|
+
message = capability.message
|
|
1943
|
+
case constant
|
|
1944
|
+
when "Time", "DateTime", "Date"
|
|
1945
|
+
unify(result, Named.new(constant)) if %i[now at parse current zone today].include?(message)
|
|
1946
|
+
when "Process"
|
|
1947
|
+
unify(result, Named.new("Float")) if message == :clock_gettime
|
|
1948
|
+
when "ENV"
|
|
1949
|
+
case message
|
|
1950
|
+
when :[] then unify(result, Union.new([Named.new("String"), Named.new("nil")]))
|
|
1951
|
+
when :fetch then unify(result, Named.new("String"))
|
|
1952
|
+
end
|
|
1953
|
+
when "JSON", "YAML", "Oj"
|
|
1954
|
+
case message
|
|
1955
|
+
when :parse, :load, :safe_load
|
|
1956
|
+
value = stable_fresh([constant, :parsed_value], "value")
|
|
1957
|
+
unify(result, Generic.new("Hash", [Named.new("String"), value]))
|
|
1958
|
+
when :generate, :dump, :pretty_generate then unify(result, Named.new("String"))
|
|
1959
|
+
end
|
|
1960
|
+
when "Rails"
|
|
1961
|
+
case message
|
|
1962
|
+
when :logger then unify(result, Named.new("Logger"))
|
|
1963
|
+
when :env then unify(result, Named.new("String"))
|
|
1964
|
+
when :root then unify(result, Named.new("Pathname"))
|
|
1965
|
+
when :application then unify(result, Named.new("Rails::Application"))
|
|
1966
|
+
end
|
|
1967
|
+
when "File"
|
|
1968
|
+
case message
|
|
1969
|
+
when :read, :basename, :dirname, :extname, :join, :expand_path, :absolute_path, :realpath
|
|
1970
|
+
unify(result, Named.new("String"))
|
|
1971
|
+
when :readlines then unify(result, Generic.new("Array", [Named.new("String")]))
|
|
1972
|
+
end
|
|
1973
|
+
when "Dir"
|
|
1974
|
+
unify(result, Generic.new("Array", [Named.new("String")])) if %i[glob children entries].include?(message)
|
|
1975
|
+
when "SecureRandom"
|
|
1976
|
+
unify(result, Named.new("String")) if %i[uuid hex urlsafe_base64 alphanumeric base64].include?(message)
|
|
1977
|
+
when "Base64"
|
|
1978
|
+
unify(result, Named.new("String")) if message.to_s.include?("code64")
|
|
1979
|
+
when "Digest::SHA256", "Digest::SHA1", "Digest::MD5"
|
|
1980
|
+
unify(result, Named.new("String")) if %i[hexdigest base64digest digest].include?(message)
|
|
1981
|
+
when "Regexp"
|
|
1982
|
+
case message
|
|
1983
|
+
when :escape, :quote then unify(result, Named.new("String"))
|
|
1984
|
+
when :union then unify(result, Named.new("Regexp"))
|
|
1985
|
+
end
|
|
1986
|
+
when "URI"
|
|
1987
|
+
case message
|
|
1988
|
+
when :parse, :join then unify(result, Named.new("URI"))
|
|
1989
|
+
when :encode_www_form, :decode_www_form_component, :encode_www_form_component
|
|
1990
|
+
unify(result, Named.new("String"))
|
|
1991
|
+
end
|
|
1992
|
+
when "Fiber"
|
|
1993
|
+
unify(result, Named.new("Fiber")) if %i[current new].include?(message)
|
|
1994
|
+
when "Array"
|
|
1995
|
+
if message == :Array
|
|
1996
|
+
argument = capability.arguments.first
|
|
1997
|
+
if argument
|
|
1998
|
+
element = array_element(resolve(argument)) || resolve(argument)
|
|
1999
|
+
unify(result, Generic.new("Array", [element]))
|
|
2000
|
+
else
|
|
2001
|
+
unify(result, Generic.new("Array", [fresh("element")]))
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
when "Dox::Metrics"
|
|
2005
|
+
unify(result, Named.new("nil")) if %i[increment gauge histogram timing measure].include?(message)
|
|
2006
|
+
end
|
|
2007
|
+
end
|
|
2008
|
+
|
|
2009
|
+
def constant_literal(name, lexical_owner)
|
|
2010
|
+
constant = name.delete_prefix("::")
|
|
2011
|
+
return @constants[constant] if name.start_with?("::")
|
|
2012
|
+
|
|
2013
|
+
parts = lexical_owner.split("::")
|
|
2014
|
+
parts.length.downto(0) do |length|
|
|
2015
|
+
candidate = (parts.first(length) + [constant]).join("::")
|
|
2016
|
+
type = @constants[candidate]
|
|
2017
|
+
return type if type
|
|
2018
|
+
end
|
|
2019
|
+
nil
|
|
2020
|
+
end
|
|
2021
|
+
|
|
2022
|
+
DUCK_UNION_LIMIT = 8
|
|
2023
|
+
DUCK_CORE_RECEIVERS = %w[String Integer Float Symbol bool Time Regexp MatchData noreturn].freeze
|
|
2024
|
+
|
|
2025
|
+
# When the receiver's class is unknown but the message is defined in the
|
|
2026
|
+
# repository, dispatch by name: exactly when one method defines it, or by
|
|
2027
|
+
# unioning return types when the few definitions agree enough. This is
|
|
2028
|
+
# what resolves entity-attribute reads on untyped pipeline inputs.
|
|
2029
|
+
def apply_union_member_dispatch(method, capability)
|
|
2030
|
+
return false unless dereference(capability.result).is_a?(TypeVariable)
|
|
2031
|
+
|
|
2032
|
+
receiver = resolve(capability.receiver)
|
|
2033
|
+
return false unless receiver.is_a?(Union)
|
|
2034
|
+
|
|
2035
|
+
targets = receiver.members.filter_map do |member|
|
|
2036
|
+
terminal = dereference(member)
|
|
2037
|
+
next unless terminal.is_a?(Named)
|
|
2038
|
+
next unless @metadata.structured_type?(terminal.name)
|
|
2039
|
+
|
|
2040
|
+
resolve_named_owner(terminal.name, method.owner, :instance, capability.message)
|
|
2041
|
+
end
|
|
2042
|
+
return false if targets.empty?
|
|
2043
|
+
|
|
2044
|
+
if targets.length == 1
|
|
2045
|
+
apply_method_call(method, capability, targets.first)
|
|
2046
|
+
else
|
|
2047
|
+
apply_union_method_call(method, capability, targets)
|
|
2048
|
+
end
|
|
2049
|
+
true
|
|
2050
|
+
end
|
|
2051
|
+
|
|
2052
|
+
def apply_union_method_call(caller, capability, callees)
|
|
2053
|
+
returns = callees.map do |callee|
|
|
2054
|
+
key = [caller.object_id, capability.object_id, callee.object_id]
|
|
2055
|
+
mapping = @call_mappings[key] ||= {}
|
|
2056
|
+
instantiated_parameters = callee.parameters.map do |parameter|
|
|
2057
|
+
Parameter.new(
|
|
2058
|
+
name: parameter.name,
|
|
2059
|
+
type: instantiate(resolve(parameter.type), mapping),
|
|
2060
|
+
kind: parameter.kind
|
|
2061
|
+
)
|
|
2062
|
+
end
|
|
2063
|
+
bind_arguments(instantiated_parameters, capability)
|
|
2064
|
+
instantiate(resolve(callee.result), mapping)
|
|
2065
|
+
end
|
|
2066
|
+
unified = returns.reduce { |left, right| Union.new([left, right].uniq) }
|
|
2067
|
+
unify(capability.result, unified)
|
|
2068
|
+
end
|
|
2069
|
+
|
|
2070
|
+
def apply_duck_dispatch(method, capability)
|
|
2071
|
+
return unless dereference(capability.result).is_a?(TypeVariable)
|
|
2072
|
+
|
|
2073
|
+
receiver = resolve(capability.receiver)
|
|
2074
|
+
return unless duck_receiver?(receiver)
|
|
2075
|
+
|
|
2076
|
+
candidates = @duck_index[capability.message]
|
|
2077
|
+
return if candidates.empty?
|
|
2078
|
+
|
|
2079
|
+
if candidates.length == 1
|
|
2080
|
+
apply_method_call(method, capability, candidates.first)
|
|
2081
|
+
else
|
|
2082
|
+
union = duck_union(capability.message)
|
|
2083
|
+
unify(capability.result, union) if union
|
|
2084
|
+
end
|
|
2085
|
+
end
|
|
2086
|
+
|
|
2087
|
+
# The union of ground return types across same-named definitions, cached
|
|
2088
|
+
# per solving iteration. Unresolved definitions are excluded: the ground
|
|
2089
|
+
# subset is the best evidence available either way.
|
|
2090
|
+
def duck_union(message)
|
|
2091
|
+
@duck_cache.fetch(message) do
|
|
2092
|
+
ground = @duck_index[message]
|
|
2093
|
+
.map { |candidate| resolve(candidate.result) }
|
|
2094
|
+
.select { |type| ground?(type) }
|
|
2095
|
+
.uniq
|
|
2096
|
+
@duck_cache[message] =
|
|
2097
|
+
if ground.empty? || ground.length > DUCK_UNION_LIMIT
|
|
2098
|
+
nil
|
|
2099
|
+
else
|
|
2100
|
+
ground.length == 1 ? ground.first : Union.new(ground)
|
|
2101
|
+
end
|
|
2102
|
+
end
|
|
2103
|
+
end
|
|
2104
|
+
|
|
2105
|
+
# A call's effective callee: hierarchy resolution first, then the duck
|
|
2106
|
+
# index when it names exactly one repository method.
|
|
2107
|
+
def call_target(caller, capability)
|
|
2108
|
+
target = resolve_target(caller, capability)
|
|
2109
|
+
return target if target
|
|
2110
|
+
return unless duck_receiver?(resolve(capability.receiver))
|
|
2111
|
+
|
|
2112
|
+
candidates = @duck_index[capability.message]
|
|
2113
|
+
candidates.length == 1 ? candidates.first : nil
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
def duck_receiver?(receiver)
|
|
2117
|
+
case receiver
|
|
2118
|
+
when TypeVariable, Union
|
|
2119
|
+
true
|
|
2120
|
+
when Named
|
|
2121
|
+
return true if @metadata.structured_type?(receiver.name)
|
|
2122
|
+
!DUCK_CORE_RECEIVERS.include?(receiver.name) && !receiver.name.start_with?("Class[")
|
|
2123
|
+
else
|
|
2124
|
+
false
|
|
2125
|
+
end
|
|
2126
|
+
end
|
|
2127
|
+
|
|
2128
|
+
# Fallbacks for sends that resolve to no repository method. Ruby's `?`
|
|
2129
|
+
# suffix convention is reliable enough for a typedoc, and a bare
|
|
2130
|
+
# `new`/`create` inside a singleton method constructs the owner class.
|
|
2131
|
+
def apply_fallback(method, capability)
|
|
2132
|
+
unify(capability.result, Named.new("bool")) if capability.message.to_s.end_with?("?")
|
|
2133
|
+
return unless capability.receiver == method.self_type
|
|
2134
|
+
|
|
2135
|
+
# Rails controller/framework conventions for self-sends that resolve
|
|
2136
|
+
# to nothing in the repository.
|
|
2137
|
+
case capability.message
|
|
2138
|
+
when :params
|
|
2139
|
+
value = stable_fresh([capability.object_id, :params_value], "value")
|
|
2140
|
+
unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value]))
|
|
2141
|
+
when :request
|
|
2142
|
+
unify(capability.result, Named.new("ActionDispatch::Request"))
|
|
2143
|
+
when :response
|
|
2144
|
+
unify(capability.result, Named.new("ActionDispatch::Response"))
|
|
2145
|
+
when :logger
|
|
2146
|
+
unify(capability.result, Named.new("Logger"))
|
|
2147
|
+
when :headers
|
|
2148
|
+
unify(capability.result, Generic.new("Hash", [Named.new("String"), Named.new("String")]))
|
|
2149
|
+
when :options
|
|
2150
|
+
apply_config_hash_reader_fallback(method, capability)
|
|
2151
|
+
else
|
|
2152
|
+
fallback = @metadata.framework_self_fallbacks[capability.message]
|
|
2153
|
+
unify(capability.result, fallback.call(method, capability)) if fallback
|
|
2154
|
+
end
|
|
2155
|
+
end
|
|
2156
|
+
|
|
2157
|
+
def apply_config_hash_reader_fallback(method, capability)
|
|
2158
|
+
wiring = @metadata.config_hash
|
|
2159
|
+
options = @metadata.config_options[method.owner]
|
|
2160
|
+
reader = wiring&.reader_method || capability.message
|
|
2161
|
+
return unless capability.message == reader
|
|
2162
|
+
|
|
2163
|
+
if options&.any?
|
|
2164
|
+
values = options.values.uniq
|
|
2165
|
+
value_type = values.length == 1 ? values.first : Union.new(values)
|
|
2166
|
+
unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), value_type]))
|
|
2167
|
+
else
|
|
2168
|
+
unify(capability.result, Generic.new("Hash", [Named.new("Symbol"), Named.new("Object")]))
|
|
2169
|
+
end
|
|
2170
|
+
end
|
|
2171
|
+
|
|
2172
|
+
def config_option_type(method, key)
|
|
2173
|
+
literal_symbol_key(key)&.then { |symbol| @metadata.config_options.dig(method.owner, symbol) }
|
|
2174
|
+
end
|
|
2175
|
+
|
|
2176
|
+
def nullable_type(inner)
|
|
2177
|
+
Generic.new("Nullable", [inner])
|
|
2178
|
+
end
|
|
2179
|
+
|
|
2180
|
+
# A bare `new`/`create` inside a singleton method constructs the owner
|
|
2181
|
+
# class. Runs even when a `new` override is indexed, so overrides that
|
|
2182
|
+
# delegate to un-analyzable aliases still anchor to the nominal type.
|
|
2183
|
+
def apply_constructor(method, capability)
|
|
2184
|
+
return unless capability.receiver == method.self_type
|
|
2185
|
+
return unless method.kind == :singleton
|
|
2186
|
+
return unless %i[new create].include?(capability.message)
|
|
2187
|
+
|
|
2188
|
+
unify(capability.result, Named.new(method.owner))
|
|
2189
|
+
end
|
|
2190
|
+
|
|
2191
|
+
def unwrap_nilable(type)
|
|
2192
|
+
return type unless type.is_a?(Union)
|
|
2193
|
+
|
|
2194
|
+
members = type.members.reject { |member| [Named.new("nil"), NORETURN].include?(member) }
|
|
2195
|
+
members.length == 1 ? members.first : type
|
|
2196
|
+
end
|
|
2197
|
+
|
|
2198
|
+
def strip_nil(type)
|
|
2199
|
+
return type unless type.is_a?(Union)
|
|
2200
|
+
|
|
2201
|
+
members = type.members.reject { |member| member == Named.new("nil") }
|
|
2202
|
+
case members.length
|
|
2203
|
+
when 0 then type
|
|
2204
|
+
when 1 then members.first
|
|
2205
|
+
else Union.new(members)
|
|
2206
|
+
end
|
|
2207
|
+
end
|
|
2208
|
+
|
|
2209
|
+
def array?(type)
|
|
2210
|
+
case type
|
|
2211
|
+
when Generic
|
|
2212
|
+
type.name == "Array"
|
|
2213
|
+
when Union
|
|
2214
|
+
type.members.any? { |member| array?(member) }
|
|
2215
|
+
else
|
|
2216
|
+
false
|
|
2217
|
+
end
|
|
2218
|
+
end
|
|
2219
|
+
|
|
2220
|
+
def hash?(type) = type.is_a?(Generic) && type.name == "Hash" && type.arguments.length == 2
|
|
2221
|
+
|
|
2222
|
+
def hashish?(type)
|
|
2223
|
+
case type
|
|
2224
|
+
when Generic
|
|
2225
|
+
hash?(type)
|
|
2226
|
+
when Union
|
|
2227
|
+
type.members.any? { |member| hashish?(member) }
|
|
2228
|
+
else
|
|
2229
|
+
false
|
|
2230
|
+
end
|
|
2231
|
+
end
|
|
2232
|
+
|
|
2233
|
+
def hash_value_type(type)
|
|
2234
|
+
case type
|
|
2235
|
+
when Generic
|
|
2236
|
+
hash?(type) ? type.arguments[1] : nil
|
|
2237
|
+
when Union
|
|
2238
|
+
values = type.members.filter_map { |member| hash_value_type(member) }
|
|
2239
|
+
values.reduce { |left, right| merge_union(left, right) }
|
|
2240
|
+
end
|
|
2241
|
+
end
|
|
2242
|
+
|
|
2243
|
+
def unify_array_elements(type, value)
|
|
2244
|
+
case unwrap_nilable(resolve(type))
|
|
2245
|
+
when Generic
|
|
2246
|
+
element = array_element(type)
|
|
2247
|
+
unify(element, value) if element
|
|
2248
|
+
when Union
|
|
2249
|
+
type.members.each { |member| unify_array_elements(member, value) }
|
|
2250
|
+
end
|
|
2251
|
+
end
|
|
2252
|
+
|
|
2253
|
+
def apply_indexed_enumeration(receiver, capability, result)
|
|
2254
|
+
element = enumerable_element(receiver)
|
|
2255
|
+
block = capability.arguments.last
|
|
2256
|
+
return unless block.is_a?(Function)
|
|
2257
|
+
|
|
2258
|
+
unify(block.parameters[0], element) if element && block.parameters[0]
|
|
2259
|
+
unify(block.parameters[1], Named.new("Integer")) if block.parameters[1]
|
|
2260
|
+
unify(result, Generic.new("Array", [block.result]))
|
|
2261
|
+
end
|
|
2262
|
+
|
|
2263
|
+
def apply_enumerable_block(receiver, capability, result, mode:)
|
|
2264
|
+
element = enumerable_element(receiver)
|
|
2265
|
+
block = capability.arguments.last
|
|
2266
|
+
if block.is_a?(Function)
|
|
2267
|
+
unify(block.parameters.first, element) if element && block.parameters.first
|
|
2268
|
+
end
|
|
2269
|
+
|
|
2270
|
+
case mode
|
|
2271
|
+
when :same_collection
|
|
2272
|
+
unify(result, receiver) if array?(receiver)
|
|
2273
|
+
when :optional_element
|
|
2274
|
+
unify(result, Union.new([element, Named.new("nil")])) if element
|
|
2275
|
+
when :bool
|
|
2276
|
+
unify(result, Named.new("bool"))
|
|
2277
|
+
when :integer
|
|
2278
|
+
unify(result, Named.new("Integer"))
|
|
2279
|
+
end
|
|
2280
|
+
end
|
|
2281
|
+
|
|
2282
|
+
def apply_each(receiver, capability, result, indexed:)
|
|
2283
|
+
element = enumerable_element(receiver)
|
|
2284
|
+
block = capability.arguments.last
|
|
2285
|
+
if block.is_a?(Function)
|
|
2286
|
+
unify(block.parameters[0], element) if element && block.parameters[0]
|
|
2287
|
+
unify(block.parameters[1], Named.new("Integer")) if indexed && block.parameters[1]
|
|
2288
|
+
end
|
|
2289
|
+
unify(result, receiver)
|
|
2290
|
+
end
|
|
2291
|
+
|
|
2292
|
+
def resolve_target(method, capability)
|
|
2293
|
+
if capability.receiver == method.self_type
|
|
2294
|
+
resolve_in_hierarchy(method.owner, method.kind, capability.message)
|
|
2295
|
+
else
|
|
2296
|
+
receiver = resolve(capability.receiver)
|
|
2297
|
+
if (class_name = class_name_from(receiver))
|
|
2298
|
+
resolve_named_owner(class_name, method.owner, :singleton, capability.message)
|
|
2299
|
+
elsif receiver.is_a?(Named)
|
|
2300
|
+
resolve_named_owner(receiver.name, method.owner, :instance, capability.message)
|
|
2301
|
+
end
|
|
2302
|
+
end
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
def resolve_in_hierarchy(owner, kind, method_name)
|
|
2306
|
+
current = owner
|
|
2307
|
+
visited = Set.new
|
|
2308
|
+
while current && !visited.include?(current)
|
|
2309
|
+
visited << current
|
|
2310
|
+
target = @index[[current, kind, method_name]]
|
|
2311
|
+
return target if target
|
|
2312
|
+
|
|
2313
|
+
current = resolve_superclass(current, @superclasses[current])
|
|
2314
|
+
end
|
|
2315
|
+
|
|
2316
|
+
@includes[owner]&.each do |mod|
|
|
2317
|
+
target = @index[[mod, kind, method_name]]
|
|
2318
|
+
return target if target
|
|
2319
|
+
end
|
|
2320
|
+
nil
|
|
2321
|
+
end
|
|
2322
|
+
|
|
2323
|
+
def resolve_named_owner(name, lexical_owner, kind, method_name)
|
|
2324
|
+
constant = name.delete_prefix("::")
|
|
2325
|
+
candidates = [constant]
|
|
2326
|
+
unless name.start_with?("::")
|
|
2327
|
+
parts = lexical_owner.split("::")
|
|
2328
|
+
parts.length.downto(1) { |length| candidates << (parts.first(length) + [constant]).join("::") }
|
|
2329
|
+
end
|
|
2330
|
+
candidates.uniq.each do |owner|
|
|
2331
|
+
target = resolve_in_hierarchy(owner, kind, method_name)
|
|
2332
|
+
return target if target
|
|
2333
|
+
end
|
|
2334
|
+
nil
|
|
2335
|
+
end
|
|
2336
|
+
|
|
2337
|
+
def resolve_includes(includes)
|
|
2338
|
+
result = Hash.new { |hash, key| hash[key] = [] }
|
|
2339
|
+
includes.each do |owner, names|
|
|
2340
|
+
names.each do |name|
|
|
2341
|
+
mod = resolve_module_constant(name, owner)
|
|
2342
|
+
result[owner] << mod if mod
|
|
2343
|
+
end
|
|
2344
|
+
end
|
|
2345
|
+
result.transform_values { |mods| mods.uniq }
|
|
2346
|
+
end
|
|
2347
|
+
|
|
2348
|
+
def resolve_module_constant(name, lexical_owner)
|
|
2349
|
+
return name.delete_prefix("::") if name.start_with?("::")
|
|
2350
|
+
|
|
2351
|
+
segments = name.split("::")
|
|
2352
|
+
parts = lexical_owner.split("::")
|
|
2353
|
+
parts.length.downto(0) do |length|
|
|
2354
|
+
candidate = (parts.first(length) + segments).join("::")
|
|
2355
|
+
return candidate if module_defined?(candidate)
|
|
2356
|
+
end
|
|
2357
|
+
(parts + segments).join("::")
|
|
2358
|
+
end
|
|
2359
|
+
|
|
2360
|
+
def module_defined?(name)
|
|
2361
|
+
@index.keys.any? { |(owner, _, _)| owner == name }
|
|
2362
|
+
end
|
|
2363
|
+
|
|
2364
|
+
def resolve_superclass(owner, superclass)
|
|
2365
|
+
return nil unless superclass
|
|
2366
|
+
|
|
2367
|
+
superclass = superclass.delete_prefix("::")
|
|
2368
|
+
return superclass if @methods.any? { |method| method.owner == superclass }
|
|
2369
|
+
|
|
2370
|
+
namespace = owner.split("::")[0...-1]
|
|
2371
|
+
namespace.length.downto(0) do |length|
|
|
2372
|
+
candidate = (namespace.first(length) + [superclass]).join("::")
|
|
2373
|
+
return candidate if @methods.any? { |method| method.owner == candidate }
|
|
2374
|
+
end
|
|
2375
|
+
superclass
|
|
2376
|
+
end
|
|
2377
|
+
|
|
2378
|
+
def apply_method_call(caller, capability, callee)
|
|
2379
|
+
# A recursive call unifies with the method's own variables directly:
|
|
2380
|
+
# instantiating them mints an endless chain of fresh variables that
|
|
2381
|
+
# prevents the fixed point from converging.
|
|
2382
|
+
if callee.equal?(caller)
|
|
2383
|
+
bind_arguments(callee.parameters, capability)
|
|
2384
|
+
unify(capability.result, callee.result)
|
|
2385
|
+
return
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2388
|
+
key = [caller.object_id, capability.object_id, callee.object_id]
|
|
2389
|
+
mapping = @call_mappings[key] ||= {}
|
|
2390
|
+
instantiated_parameters = callee.parameters.map do |parameter|
|
|
2391
|
+
Parameter.new(
|
|
2392
|
+
name: parameter.name,
|
|
2393
|
+
type: instantiate(resolve(parameter.type), mapping),
|
|
2394
|
+
kind: parameter.kind
|
|
2395
|
+
)
|
|
2396
|
+
end
|
|
2397
|
+
bind_arguments(instantiated_parameters, capability)
|
|
2398
|
+
unify(capability.result, instantiate(resolve(callee.result), mapping))
|
|
2399
|
+
end
|
|
2400
|
+
|
|
2401
|
+
def bind_arguments(parameters, capability)
|
|
2402
|
+
positional = capability.arguments.dup
|
|
2403
|
+
parameters.each do |parameter|
|
|
2404
|
+
case parameter.kind
|
|
2405
|
+
when :required, :optional
|
|
2406
|
+
unify(parameter.type, positional.shift) unless positional.empty?
|
|
2407
|
+
when :rest
|
|
2408
|
+
elements = positional.reject { |argument| argument.is_a?(Function) }
|
|
2409
|
+
element_type = elements.reduce { |left, right| Union.new([left, right].uniq) } ||
|
|
2410
|
+
stable_fresh([capability.object_id, parameter.name, :rest_element], "element")
|
|
2411
|
+
unify(parameter.type, Generic.new("Array", [element_type]))
|
|
2412
|
+
when :keyword, :optional_keyword
|
|
2413
|
+
argument = capability.keywords[parameter.name]
|
|
2414
|
+
unify(parameter.type, argument) if argument
|
|
2415
|
+
when :block
|
|
2416
|
+
block = positional.reverse.find { |argument| argument.is_a?(Function) }
|
|
2417
|
+
next unless block
|
|
2418
|
+
|
|
2419
|
+
declared = resolve(parameter.type)
|
|
2420
|
+
if declared.is_a?(Function)
|
|
2421
|
+
# Block arity rarely matches the callee's yield shape exactly;
|
|
2422
|
+
# unify pairwise and always flow the return type.
|
|
2423
|
+
declared.parameters.zip(block.parameters).each { |left, right| unify(left, right) if left && right }
|
|
2424
|
+
unify(declared.result, block.result)
|
|
2425
|
+
else
|
|
2426
|
+
unify(parameter.type, block)
|
|
2427
|
+
end
|
|
2428
|
+
end
|
|
2429
|
+
end
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
def instantiate(type, mapping)
|
|
2433
|
+
case type
|
|
2434
|
+
when TypeVariable
|
|
2435
|
+
mapping[type] ||= fresh(type.hint)
|
|
2436
|
+
when Generic
|
|
2437
|
+
Generic.new(type.name, type.arguments.map { |argument| instantiate(argument, mapping) })
|
|
2438
|
+
when Union
|
|
2439
|
+
Union.new(type.members.map { |member| instantiate(member, mapping) }.uniq)
|
|
2440
|
+
when Function
|
|
2441
|
+
Function.new(
|
|
2442
|
+
type.parameters.map { |parameter| instantiate(parameter, mapping) },
|
|
2443
|
+
instantiate(type.result, mapping)
|
|
2444
|
+
)
|
|
2445
|
+
else
|
|
2446
|
+
type
|
|
2447
|
+
end
|
|
2448
|
+
end
|
|
2449
|
+
|
|
2450
|
+
def unify(left, right)
|
|
2451
|
+
return unless left && right
|
|
2452
|
+
|
|
2453
|
+
left = dereference(left)
|
|
2454
|
+
right = dereference(right)
|
|
2455
|
+
return if left == right
|
|
2456
|
+
|
|
2457
|
+
if left.is_a?(TypeVariable)
|
|
2458
|
+
bind(left, right)
|
|
2459
|
+
elsif right.is_a?(TypeVariable)
|
|
2460
|
+
bind(right, left)
|
|
2461
|
+
elsif left.is_a?(Generic) && right.is_a?(Generic) &&
|
|
2462
|
+
left.name == right.name && left.arguments.length == right.arguments.length
|
|
2463
|
+
left.arguments.zip(right.arguments).each { |pair| unify(*pair) }
|
|
2464
|
+
elsif left.is_a?(Function) && right.is_a?(Function) &&
|
|
2465
|
+
left.parameters.length == right.parameters.length
|
|
2466
|
+
left.parameters.zip(right.parameters).each { |pair| unify(*pair) }
|
|
2467
|
+
unify(left.result, right.result)
|
|
2468
|
+
end
|
|
2469
|
+
end
|
|
2470
|
+
|
|
2471
|
+
def bind(variable, type)
|
|
2472
|
+
return if occurs?(variable, type)
|
|
2473
|
+
|
|
2474
|
+
@substitutions[variable] = type
|
|
2475
|
+
# Variable-to-variable links are alpha-moves carrying no new concrete
|
|
2476
|
+
# information. Counting them as progress lets recursive call cycles
|
|
2477
|
+
# mint fresh-variable chains that never converge.
|
|
2478
|
+
@changed = true unless type.is_a?(TypeVariable)
|
|
2479
|
+
end
|
|
2480
|
+
|
|
2481
|
+
def occurs?(variable, type)
|
|
2482
|
+
type = dereference(type)
|
|
2483
|
+
case type
|
|
2484
|
+
when TypeVariable
|
|
2485
|
+
type == variable
|
|
2486
|
+
when Generic
|
|
2487
|
+
type.arguments.any? { |argument| occurs?(variable, argument) }
|
|
2488
|
+
when Union
|
|
2489
|
+
type.members.any? { |member| occurs?(variable, member) }
|
|
2490
|
+
when Function
|
|
2491
|
+
type.parameters.any? { |parameter| occurs?(variable, parameter) } || occurs?(variable, type.result)
|
|
2492
|
+
else
|
|
2493
|
+
false
|
|
2494
|
+
end
|
|
2495
|
+
end
|
|
2496
|
+
|
|
2497
|
+
def dereference(type)
|
|
2498
|
+
seen = Set.new
|
|
2499
|
+
while type.is_a?(TypeVariable) && @substitutions.key?(type) && !seen.include?(type)
|
|
2500
|
+
seen << type
|
|
2501
|
+
type = @substitutions.fetch(type)
|
|
2502
|
+
end
|
|
2503
|
+
type
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
def resolve(type)
|
|
2507
|
+
type = dereference(type)
|
|
2508
|
+
case type
|
|
2509
|
+
when Generic
|
|
2510
|
+
Generic.new(type.name, type.arguments.map { |argument| resolve(argument) })
|
|
2511
|
+
when Union
|
|
2512
|
+
members = type.members.flat_map do |member|
|
|
2513
|
+
resolved = resolve(member)
|
|
2514
|
+
resolved.is_a?(Union) ? resolved.members : resolved
|
|
2515
|
+
end.uniq
|
|
2516
|
+
# Raising branches contribute no value, so noreturn disappears from
|
|
2517
|
+
# unions unless the method can only raise.
|
|
2518
|
+
live = members.reject { |member| member == NORETURN }
|
|
2519
|
+
members = live unless live.empty?
|
|
2520
|
+
members.length == 1 ? members.first : Union.new(members)
|
|
2521
|
+
when Function
|
|
2522
|
+
Function.new(type.parameters.map { |parameter| resolve(parameter) }, resolve(type.result))
|
|
2523
|
+
else
|
|
2524
|
+
type
|
|
2525
|
+
end
|
|
2526
|
+
end
|
|
2527
|
+
|
|
2528
|
+
MAX_NARROWING_SENDS = 8
|
|
2529
|
+
NARROWING_ROUNDS = 3
|
|
2530
|
+
|
|
2531
|
+
MAX_CONSTRUCTOR_UNION = 4
|
|
2532
|
+
|
|
2533
|
+
# Calls bind instantiated copies of the callee's parameters, so the
|
|
2534
|
+
# originals (and everything downstream: ivars, accessors, receivers)
|
|
2535
|
+
# stay unresolved. Accumulating ground argument types from all call
|
|
2536
|
+
# sites and binding them into the original parameters narrows those
|
|
2537
|
+
# chains at the source.
|
|
2538
|
+
def flow_call_arguments!
|
|
2539
|
+
accumulated = Hash.new { |hash, key| hash[key] = [] }
|
|
2540
|
+
@methods.each do |caller|
|
|
2541
|
+
caller.capabilities.each do |capability|
|
|
2542
|
+
target = call_target(caller, capability)
|
|
2543
|
+
accumulate_call_arguments(target, capability, accumulated) if target
|
|
2544
|
+
next unless capability.message == :new
|
|
2545
|
+
|
|
2546
|
+
class_name =
|
|
2547
|
+
if capability.receiver == caller.self_type && caller.kind == :singleton
|
|
2548
|
+
caller.owner
|
|
2549
|
+
else
|
|
2550
|
+
class_name_from(resolve(capability.receiver))
|
|
2551
|
+
end
|
|
2552
|
+
next unless class_name
|
|
2553
|
+
|
|
2554
|
+
init = resolve_named_owner(class_name, caller.owner, :instance, :initialize)
|
|
2555
|
+
accumulate_call_arguments(init, capability, accumulated) if init
|
|
2556
|
+
end
|
|
2557
|
+
end
|
|
2558
|
+
|
|
2559
|
+
accumulated.each do |variable, types|
|
|
2560
|
+
next unless dereference(variable).is_a?(TypeVariable)
|
|
2561
|
+
|
|
2562
|
+
members = types.uniq
|
|
2563
|
+
next if members.empty? || members.length > MAX_CONSTRUCTOR_UNION
|
|
2564
|
+
|
|
2565
|
+
unify(variable, members.length == 1 ? members.first : Union.new(members))
|
|
2566
|
+
end
|
|
2567
|
+
end
|
|
2568
|
+
|
|
2569
|
+
def accumulate_call_arguments(callee, capability, accumulated)
|
|
2570
|
+
positional = capability.arguments.reject { |argument| argument.is_a?(Function) }
|
|
2571
|
+
callee.parameters.each do |parameter|
|
|
2572
|
+
argument =
|
|
2573
|
+
case parameter.kind
|
|
2574
|
+
when :required, :optional then positional.shift
|
|
2575
|
+
when :keyword, :optional_keyword then capability.keywords[parameter.name]
|
|
2576
|
+
end
|
|
2577
|
+
next unless argument
|
|
2578
|
+
|
|
2579
|
+
terminal = dereference(parameter.type)
|
|
2580
|
+
next unless terminal.is_a?(TypeVariable)
|
|
2581
|
+
next if structural_parameter?(callee, parameter)
|
|
2582
|
+
|
|
2583
|
+
resolved = resolve(argument)
|
|
2584
|
+
accumulated[terminal] << resolved if flowable_argument?(resolved)
|
|
2585
|
+
end
|
|
2586
|
+
end
|
|
2587
|
+
|
|
2588
|
+
def flowable_argument?(type)
|
|
2589
|
+
case dereference(type)
|
|
2590
|
+
when TypeVariable then false
|
|
2591
|
+
when Union then type.members.any? { |member| flowable_argument?(member) }
|
|
2592
|
+
when Function then false
|
|
2593
|
+
else true
|
|
2594
|
+
end
|
|
2595
|
+
end
|
|
2596
|
+
|
|
2597
|
+
def structural_parameter?(callee, parameter)
|
|
2598
|
+
terminal = dereference(parameter.type)
|
|
2599
|
+
return false unless terminal.is_a?(TypeVariable)
|
|
2600
|
+
|
|
2601
|
+
callee.capabilities.any? do |capability|
|
|
2602
|
+
capability.message == :[] && dereference(capability.receiver) == terminal
|
|
2603
|
+
end
|
|
2604
|
+
end
|
|
2605
|
+
|
|
2606
|
+
# When callers pass hashes or arrays into a parameter, the callee body's
|
|
2607
|
+
# literal index sends on that parameter tell us which slots to narrow.
|
|
2608
|
+
def flow_structural_arguments!
|
|
2609
|
+
@methods.each do |caller|
|
|
2610
|
+
caller.capabilities.each do |capability|
|
|
2611
|
+
callee = call_target(caller, capability)
|
|
2612
|
+
next unless callee
|
|
2613
|
+
|
|
2614
|
+
positional = capability.arguments.reject { |argument| argument.is_a?(Function) }
|
|
2615
|
+
callee.parameters.each do |parameter|
|
|
2616
|
+
next if parameter.kind == :block
|
|
2617
|
+
|
|
2618
|
+
argument = case parameter.kind
|
|
2619
|
+
when :required, :optional then positional.shift
|
|
2620
|
+
when :keyword, :optional_keyword then capability.keywords[parameter.name]
|
|
2621
|
+
end
|
|
2622
|
+
next unless argument
|
|
2623
|
+
|
|
2624
|
+
flow_argument_slots(callee, parameter.type, resolve(argument))
|
|
2625
|
+
end
|
|
2626
|
+
end
|
|
2627
|
+
end
|
|
2628
|
+
end
|
|
2629
|
+
|
|
2630
|
+
def flow_argument_slots(callee, parameter_type, argument_type)
|
|
2631
|
+
parameter_type = dereference(parameter_type)
|
|
2632
|
+
return unless parameter_type.is_a?(TypeVariable)
|
|
2633
|
+
|
|
2634
|
+
case unwrap_nilable(argument_type)
|
|
2635
|
+
when Generic
|
|
2636
|
+
if hash?(argument_type)
|
|
2637
|
+
value_type = argument_type.arguments[1]
|
|
2638
|
+
index_slots_for(callee, parameter_type).each { |slot| unify(slot, value_type) }
|
|
2639
|
+
elsif array?(argument_type)
|
|
2640
|
+
element_type = array_element(argument_type)
|
|
2641
|
+
index_slots_for(callee, parameter_type).each do |slot|
|
|
2642
|
+
unify(slot, Union.new([element_type, Named.new("nil")]))
|
|
2643
|
+
end
|
|
2644
|
+
end
|
|
2645
|
+
when Union
|
|
2646
|
+
argument_type.members.each { |member| flow_argument_slots(callee, parameter_type, member) }
|
|
2647
|
+
end
|
|
2648
|
+
end
|
|
2649
|
+
|
|
2650
|
+
def index_slots_for(callee, parameter_type)
|
|
2651
|
+
callee.capabilities.filter_map do |capability|
|
|
2652
|
+
next unless capability.message == :[]
|
|
2653
|
+
next unless dereference(capability.receiver) == parameter_type
|
|
2654
|
+
|
|
2655
|
+
capability.result
|
|
2656
|
+
end
|
|
2657
|
+
end
|
|
2658
|
+
|
|
2659
|
+
def ground?(type)
|
|
2660
|
+
case type
|
|
2661
|
+
when TypeVariable then false
|
|
2662
|
+
when Generic then type.arguments.all? { |argument| ground?(argument) }
|
|
2663
|
+
when Union then type.members.all? { |member| ground?(member) }
|
|
2664
|
+
when Function then type.parameters.all? { |parameter| ground?(parameter) } && ground?(type.result)
|
|
2665
|
+
else true
|
|
2666
|
+
end
|
|
2667
|
+
end
|
|
2668
|
+
|
|
2669
|
+
# The same naming conventions trusted for unresolved sends apply to
|
|
2670
|
+
# method definitions whose body defeated inference: `?` methods return
|
|
2671
|
+
# bool, `to_*`/`*_hash` converters return their conventional type.
|
|
2672
|
+
def apply_definition_conventions!
|
|
2673
|
+
@methods.each do |method|
|
|
2674
|
+
default = convention_default(method.method_name.to_s)
|
|
2675
|
+
next unless default
|
|
2676
|
+
|
|
2677
|
+
parameter_terminals = method.parameters.map { |parameter| dereference(parameter.type) }
|
|
2678
|
+
residual = residual_return_variables(method) - parameter_terminals
|
|
2679
|
+
residual.each { |variable| unify(variable, default) }
|
|
2680
|
+
end
|
|
2681
|
+
end
|
|
2682
|
+
|
|
2683
|
+
def apply_intra_method_param_flow!
|
|
2684
|
+
@methods.each do |method|
|
|
2685
|
+
method.parameters.each do |parameter|
|
|
2686
|
+
method.capabilities.each do |capability|
|
|
2687
|
+
next unless %i[<< push append unshift prepend].include?(capability.message)
|
|
2688
|
+
|
|
2689
|
+
receiver = resolve(capability.receiver)
|
|
2690
|
+
next unless array?(receiver)
|
|
2691
|
+
|
|
2692
|
+
capability.arguments.each do |argument|
|
|
2693
|
+
next if argument.is_a?(Function)
|
|
2694
|
+
next unless argument.equal?(parameter.type)
|
|
2695
|
+
|
|
2696
|
+
unify_array_elements(receiver, resolve(parameter.type))
|
|
2697
|
+
end
|
|
2698
|
+
end
|
|
2699
|
+
end
|
|
2700
|
+
end
|
|
2701
|
+
end
|
|
2702
|
+
|
|
2703
|
+
# Nested sends like `hash[key1][key2] << item` create separate capabilities
|
|
2704
|
+
# per subexpression; when the inner result is still a variable, propagate
|
|
2705
|
+
# the looked-up slot type to the outer receiver before solving `<<`.
|
|
2706
|
+
def apply_chained_send_narrowing!
|
|
2707
|
+
@methods.each do |method|
|
|
2708
|
+
method.capabilities.each do |capability|
|
|
2709
|
+
narrow_chained_receiver!(method, capability)
|
|
2710
|
+
end
|
|
2711
|
+
end
|
|
2712
|
+
end
|
|
2713
|
+
|
|
2714
|
+
def narrow_chained_receiver!(method, capability)
|
|
2715
|
+
receiver = capability.receiver
|
|
2716
|
+
return unless receiver.is_a?(TypeVariable)
|
|
2717
|
+
|
|
2718
|
+
source_type = resolve_chained_source_type(method, capability)
|
|
2719
|
+
return unless source_type
|
|
2720
|
+
|
|
2721
|
+
case capability.message
|
|
2722
|
+
when :[]
|
|
2723
|
+
if hashish?(source_type)
|
|
2724
|
+
key_arg = capability.arguments.first
|
|
2725
|
+
if literal_hash_key?(source_type, key_arg) || hash_key_type_compatible?(source_type, key_arg)
|
|
2726
|
+
value_type = hash_value_type(source_type)
|
|
2727
|
+
unify(receiver, value_type) if value_type
|
|
2728
|
+
end
|
|
2729
|
+
elsif array?(source_type)
|
|
2730
|
+
element = array_element(source_type)
|
|
2731
|
+
unify(receiver, Union.new([element, Named.new("nil")])) if element
|
|
2732
|
+
end
|
|
2733
|
+
when :<<, :push, :append, :unshift, :prepend
|
|
2734
|
+
if array?(source_type)
|
|
2735
|
+
unify(receiver, source_type)
|
|
2736
|
+
capability.arguments.each do |argument|
|
|
2737
|
+
unify_array_elements(source_type, argument) unless argument.is_a?(Function)
|
|
2738
|
+
end
|
|
2739
|
+
end
|
|
2740
|
+
end
|
|
2741
|
+
end
|
|
2742
|
+
|
|
2743
|
+
def resolve_chained_source_type(method, capability)
|
|
2744
|
+
receiver = resolve(capability.receiver)
|
|
2745
|
+
return receiver unless receiver.is_a?(TypeVariable)
|
|
2746
|
+
|
|
2747
|
+
source = method.capabilities.find { |other| other.result.equal?(receiver) }
|
|
2748
|
+
return nil unless source
|
|
2749
|
+
|
|
2750
|
+
parent_type = resolve_chained_source_type(method, source)
|
|
2751
|
+
return nil unless parent_type
|
|
2752
|
+
|
|
2753
|
+
case source.message
|
|
2754
|
+
when :[]
|
|
2755
|
+
if hashish?(parent_type)
|
|
2756
|
+
key_arg = source.arguments.first
|
|
2757
|
+
if literal_hash_key?(parent_type, key_arg) || hash_key_type_compatible?(parent_type, key_arg)
|
|
2758
|
+
hash_value_type(parent_type)
|
|
2759
|
+
end
|
|
2760
|
+
elsif array?(parent_type)
|
|
2761
|
+
array_element(parent_type)
|
|
2762
|
+
end
|
|
2763
|
+
else
|
|
2764
|
+
nil
|
|
2765
|
+
end
|
|
2766
|
+
end
|
|
2767
|
+
|
|
2768
|
+
# Block parameters from `.each { |x| foo(x) }` are fresh variables that
|
|
2769
|
+
# cross-method flow skips; unify callee parameters directly.
|
|
2770
|
+
def apply_block_argument_callee_flow!
|
|
2771
|
+
@methods.each do |method|
|
|
2772
|
+
method.capabilities.each do |capability|
|
|
2773
|
+
next unless %i[each each_with_index each_value each_key].include?(capability.message)
|
|
2774
|
+
|
|
2775
|
+
block = capability.arguments.last
|
|
2776
|
+
next unless block.is_a?(Function)
|
|
2777
|
+
|
|
2778
|
+
block_params = block.parameters
|
|
2779
|
+
next if block_params.empty?
|
|
2780
|
+
|
|
2781
|
+
method.capabilities.each do |inner|
|
|
2782
|
+
target = call_target(method, inner)
|
|
2783
|
+
next unless target
|
|
2784
|
+
|
|
2785
|
+
positional = inner.arguments.reject { |argument| argument.is_a?(Function) }
|
|
2786
|
+
positional.each_with_index do |argument, index|
|
|
2787
|
+
block_param = block_params[index]
|
|
2788
|
+
next unless block_param && argument.equal?(block_param)
|
|
2789
|
+
|
|
2790
|
+
param = target.parameters[index]
|
|
2791
|
+
unify(param.type, block_param) if param
|
|
2792
|
+
end
|
|
2793
|
+
end
|
|
2794
|
+
end
|
|
2795
|
+
end
|
|
2796
|
+
end
|
|
2797
|
+
|
|
2798
|
+
def apply_mutator_return_nil!
|
|
2799
|
+
@methods.each do |method|
|
|
2800
|
+
next unless method.method_name.to_s.end_with?("!")
|
|
2801
|
+
next if mutator_returns_receiver?(method)
|
|
2802
|
+
|
|
2803
|
+
force_return!(method, Named.new("nil"))
|
|
2804
|
+
end
|
|
2805
|
+
end
|
|
2806
|
+
|
|
2807
|
+
def mutator_returns_receiver?(method)
|
|
2808
|
+
method.capabilities.any? do |capability|
|
|
2809
|
+
SELF_RETURNING.include?(capability.message) && capability.receiver == method.self_type
|
|
2810
|
+
end
|
|
2811
|
+
end
|
|
2812
|
+
|
|
2813
|
+
# Call-site return types can be bound while callees still carry stale
|
|
2814
|
+
# intermediate variables (e.g. `<<` before array element narrowing).
|
|
2815
|
+
# Re-unify once callees have converged.
|
|
2816
|
+
def reflow_callee_returns!
|
|
2817
|
+
@methods.each do |caller|
|
|
2818
|
+
caller.capabilities.each do |capability|
|
|
2819
|
+
callee = call_target(caller, capability)
|
|
2820
|
+
next unless callee
|
|
2821
|
+
next if callee.equal?(caller)
|
|
2822
|
+
|
|
2823
|
+
result_var = capability.result
|
|
2824
|
+
next unless result_var.is_a?(TypeVariable)
|
|
2825
|
+
|
|
2826
|
+
bind(result_var, resolve(callee.result))
|
|
2827
|
+
end
|
|
2828
|
+
end
|
|
2829
|
+
end
|
|
2830
|
+
|
|
2831
|
+
def apply_forced_return_conventions!
|
|
2832
|
+
@methods.each do |method|
|
|
2833
|
+
default = convention_default(method.method_name.to_s)
|
|
2834
|
+
next unless default == Named.new("nil")
|
|
2835
|
+
next unless @metadata.side_effect_methods.include?(method.method_name) ||
|
|
2836
|
+
method.method_name.to_s.match?(/\Areset(?:_[a-z_]+)?!\z/)
|
|
2837
|
+
|
|
2838
|
+
force_return!(method, default)
|
|
2839
|
+
end
|
|
2840
|
+
end
|
|
2841
|
+
|
|
2842
|
+
def force_return!(method, type)
|
|
2843
|
+
return unless method.result.is_a?(TypeVariable)
|
|
2844
|
+
|
|
2845
|
+
@substitutions[method.result] = type
|
|
2846
|
+
@changed = true
|
|
2847
|
+
end
|
|
2848
|
+
|
|
2849
|
+
def convention_default(name)
|
|
2850
|
+
return Named.new("bool") if name.end_with?("?")
|
|
2851
|
+
return Named.new("nil") if @metadata.side_effect_methods.include?(name.to_sym)
|
|
2852
|
+
return Named.new("nil") if name.match?(/\Areset(?:_[a-z_]+)?!\z/)
|
|
2853
|
+
|
|
2854
|
+
case name
|
|
2855
|
+
when "to_s", "to_str", "inspect" then Named.new("String")
|
|
2856
|
+
when "to_i" then Named.new("Integer")
|
|
2857
|
+
when "to_f" then Named.new("Float")
|
|
2858
|
+
when "to_sym" then Named.new("Symbol")
|
|
2859
|
+
when "count", "size", "length" then Named.new("Integer")
|
|
2860
|
+
else
|
|
2861
|
+
if name == "to_h" || name == "to_hash" || name.end_with?("_h", "_hash") || name.end_with?("_count")
|
|
2862
|
+
name.end_with?("_count") ? Named.new("Integer") : Generic.new("Hash", [fresh(:key), fresh(:value)])
|
|
2863
|
+
end
|
|
2864
|
+
end
|
|
2865
|
+
end
|
|
2866
|
+
|
|
2867
|
+
# Usage-based narrowing: when a method's return stays unresolved, the
|
|
2868
|
+
# messages callers send to its result are structural evidence. Attaching
|
|
2869
|
+
# them as capabilities lets the renderer show a named structural type
|
|
2870
|
+
# instead of Object. Display-only: no unification happens here.
|
|
2871
|
+
def narrow_residual_returns!
|
|
2872
|
+
NARROWING_ROUNDS.times do
|
|
2873
|
+
break unless narrowing_round
|
|
2874
|
+
end
|
|
2875
|
+
end
|
|
2876
|
+
|
|
2877
|
+
# One narrowing round; returns true when new evidence was attached so a
|
|
2878
|
+
# follow-up round can propagate it through forwarding methods.
|
|
2879
|
+
def narrowing_round
|
|
2880
|
+
observations = Hash.new { |hash, key| hash[key] = [] }
|
|
2881
|
+
@methods.each do |caller|
|
|
2882
|
+
caller.capabilities.each do |capability|
|
|
2883
|
+
target = call_target(caller, capability)
|
|
2884
|
+
observations[target] << [caller, capability.result] if target
|
|
2885
|
+
end
|
|
2886
|
+
end
|
|
2887
|
+
|
|
2888
|
+
changed = false
|
|
2889
|
+
observations.each do |callee, calls|
|
|
2890
|
+
parameter_variables = callee.parameters.map { |parameter| dereference(parameter.type) }
|
|
2891
|
+
residual = residual_return_variables(callee) - parameter_variables
|
|
2892
|
+
next if residual.empty?
|
|
2893
|
+
|
|
2894
|
+
sends = collect_result_observations(calls)
|
|
2895
|
+
next if sends.empty?
|
|
2896
|
+
|
|
2897
|
+
residual.each do |variable|
|
|
2898
|
+
existing = callee.capabilities.select { |capability| dereference(capability.receiver) == variable }
|
|
2899
|
+
seen = existing.map { |capability| [capability.message, capability.arguments.length] }.to_set
|
|
2900
|
+
budget = MAX_NARROWING_SENDS - seen.size
|
|
2901
|
+
break if budget <= 0
|
|
2902
|
+
|
|
2903
|
+
sends.each do |send|
|
|
2904
|
+
key = [send.fetch(:message), send.fetch(:arguments).length]
|
|
2905
|
+
next if seen.include?(key)
|
|
2906
|
+
|
|
2907
|
+
callee.capabilities << Capability.new(
|
|
2908
|
+
receiver: variable,
|
|
2909
|
+
message: send.fetch(:message),
|
|
2910
|
+
arguments: send.fetch(:arguments),
|
|
2911
|
+
keywords: send.fetch(:keywords),
|
|
2912
|
+
result: send.fetch(:result),
|
|
2913
|
+
line: callee.line
|
|
2914
|
+
)
|
|
2915
|
+
seen << key
|
|
2916
|
+
changed = true
|
|
2917
|
+
break if seen.size >= MAX_NARROWING_SENDS
|
|
2918
|
+
end
|
|
2919
|
+
end
|
|
2920
|
+
end
|
|
2921
|
+
changed
|
|
2922
|
+
end
|
|
2923
|
+
|
|
2924
|
+
def residual_return_variables(method)
|
|
2925
|
+
resolved = resolve(method.result)
|
|
2926
|
+
case resolved
|
|
2927
|
+
when TypeVariable then [resolved]
|
|
2928
|
+
when Union then resolved.members.grep(TypeVariable)
|
|
2929
|
+
else []
|
|
2930
|
+
end
|
|
2931
|
+
end
|
|
2932
|
+
|
|
2933
|
+
def collect_result_observations(calls)
|
|
2934
|
+
groups = {}
|
|
2935
|
+
calls.each do |caller, result|
|
|
2936
|
+
terminal = dereference(result)
|
|
2937
|
+
next unless terminal.is_a?(TypeVariable)
|
|
2938
|
+
|
|
2939
|
+
caller.capabilities.each do |capability|
|
|
2940
|
+
next unless dereference(capability.receiver) == terminal
|
|
2941
|
+
|
|
2942
|
+
key = [capability.message, capability.arguments.length, capability.keywords.keys.sort]
|
|
2943
|
+
entry = groups[key] ||= {
|
|
2944
|
+
message: capability.message,
|
|
2945
|
+
arguments: capability.arguments.map { |argument| resolve(argument) },
|
|
2946
|
+
keywords: capability.keywords.transform_values { |value| resolve(value) },
|
|
2947
|
+
result: nil
|
|
2948
|
+
}
|
|
2949
|
+
observed = resolve(capability.result)
|
|
2950
|
+
entry[:result] = entry[:result] ? merge_union(entry[:result], observed) : observed
|
|
2951
|
+
end
|
|
2952
|
+
end
|
|
2953
|
+
groups.values.sort_by { |send| send.fetch(:message).to_s }
|
|
2954
|
+
end
|
|
2955
|
+
|
|
2956
|
+
def merge_union(left, right)
|
|
2957
|
+
return left if left == right
|
|
2958
|
+
|
|
2959
|
+
members = (left.is_a?(Union) ? left.members : [left]) +
|
|
2960
|
+
(right.is_a?(Union) ? right.members : [right])
|
|
2961
|
+
Union.new(members.uniq)
|
|
2962
|
+
end
|
|
2963
|
+
|
|
2964
|
+
def resolved_method(method)
|
|
2965
|
+
MethodResult.new(
|
|
2966
|
+
name: method.name,
|
|
2967
|
+
owner: method.owner,
|
|
2968
|
+
kind: method.kind,
|
|
2969
|
+
method_name: method.method_name,
|
|
2970
|
+
superclass: method.superclass,
|
|
2971
|
+
line: method.line,
|
|
2972
|
+
end_line: method.end_line,
|
|
2973
|
+
parameters: method.parameters.map do |parameter|
|
|
2974
|
+
Parameter.new(name: parameter.name, type: resolve(parameter.type), kind: parameter.kind)
|
|
2975
|
+
end,
|
|
2976
|
+
result: resolve(method.result),
|
|
2977
|
+
capabilities: method.capabilities.map do |capability|
|
|
2978
|
+
Capability.new(
|
|
2979
|
+
receiver: resolve(capability.receiver),
|
|
2980
|
+
message: capability.message,
|
|
2981
|
+
arguments: capability.arguments.map { |argument| resolve(argument) },
|
|
2982
|
+
keywords: capability.keywords.transform_values { |value| resolve(value) },
|
|
2983
|
+
result: resolve(capability.result),
|
|
2984
|
+
line: capability.line
|
|
2985
|
+
)
|
|
2986
|
+
end,
|
|
2987
|
+
self_type: resolve(method.self_type),
|
|
2988
|
+
ivars: method.ivars.transform_values { |type| resolve(type) },
|
|
2989
|
+
locals: method.locals.transform_values { |type| resolve(type) },
|
|
2990
|
+
port_assignments: method.port_assignments,
|
|
2991
|
+
case_narrowings: method.case_narrowings
|
|
2992
|
+
)
|
|
2993
|
+
end
|
|
2994
|
+
|
|
2995
|
+
def fresh(hint = nil)
|
|
2996
|
+
variable = TypeVariable.new(@fresh_scope, @fresh_id, hint)
|
|
2997
|
+
@fresh_id += 1
|
|
2998
|
+
variable
|
|
2999
|
+
end
|
|
3000
|
+
|
|
3001
|
+
def stable_fresh(key, hint = nil)
|
|
3002
|
+
@stable_variables[key] ||= fresh(hint)
|
|
3003
|
+
end
|
|
3004
|
+
|
|
3005
|
+
def class_name_from(type)
|
|
3006
|
+
return unless type.is_a?(Named)
|
|
3007
|
+
|
|
3008
|
+
match = type.name.match(/\AClass\[(.+)\]\z/)
|
|
3009
|
+
match && match[1].delete_prefix("::")
|
|
3010
|
+
end
|
|
3011
|
+
|
|
3012
|
+
def array_element(type)
|
|
3013
|
+
case type
|
|
3014
|
+
when Generic
|
|
3015
|
+
type.arguments.first if type.name == "Array"
|
|
3016
|
+
when Union
|
|
3017
|
+
elements = type.members.filter_map { |member| array_element(member) }
|
|
3018
|
+
elements.reduce { |left, right| merge_union(left, right) }
|
|
3019
|
+
end
|
|
3020
|
+
end
|
|
3021
|
+
|
|
3022
|
+
def enumerable_element(type)
|
|
3023
|
+
case type
|
|
3024
|
+
when Generic
|
|
3025
|
+
return type.arguments.first if %w[Array Enumerator Set].include?(type.name)
|
|
3026
|
+
when Union
|
|
3027
|
+
elements = type.members.filter_map { |member| enumerable_element(member) }
|
|
3028
|
+
return elements.reduce { |left, right| merge_union(left, right) } unless elements.empty?
|
|
3029
|
+
end
|
|
3030
|
+
|
|
3031
|
+
nil
|
|
3032
|
+
end
|
|
3033
|
+
end
|
|
3034
|
+
|
|
3035
|
+
class UniversalHelperRegistry
|
|
3036
|
+
def initialize(methods)
|
|
3037
|
+
@occurrences = {}
|
|
3038
|
+
@definitions = {}
|
|
3039
|
+
methods.each { |method| register_method(method) }
|
|
3040
|
+
consolidate!
|
|
3041
|
+
end
|
|
3042
|
+
|
|
3043
|
+
def occurrence(method, receiver)
|
|
3044
|
+
@occurrences[[method.object_id, receiver]]
|
|
3045
|
+
end
|
|
3046
|
+
|
|
3047
|
+
def occurrences_for(method)
|
|
3048
|
+
@occurrences.filter_map { |(method_id, _), occurrence| occurrence if method_id == method.object_id }
|
|
3049
|
+
end
|
|
3050
|
+
|
|
3051
|
+
def definitions
|
|
3052
|
+
@definitions.values.sort_by { |definition| definition.fetch(:name) }
|
|
3053
|
+
end
|
|
3054
|
+
|
|
3055
|
+
def apply_names!(names)
|
|
3056
|
+
@definitions.each_value do |definition|
|
|
3057
|
+
semantic_name = normalize_semantic_name(names[definition.fetch(:id)])
|
|
3058
|
+
definition[:name] = semantic_name if semantic_name
|
|
3059
|
+
end
|
|
3060
|
+
end
|
|
3061
|
+
|
|
3062
|
+
# Clusters of distinct definitions that would receive the same normalized
|
|
3063
|
+
# name. The namer re-prompts these cluster by cluster until empty.
|
|
3064
|
+
def collisions(names)
|
|
3065
|
+
@definitions.each_value
|
|
3066
|
+
.group_by { |definition| normalize_semantic_name(names[definition.fetch(:id)]) }
|
|
3067
|
+
.select { |name, definitions| name && definitions.length > 1 }
|
|
3068
|
+
end
|
|
3069
|
+
|
|
3070
|
+
def normalize_semantic_name(name)
|
|
3071
|
+
return nil if name.to_s.empty?
|
|
3072
|
+
|
|
3073
|
+
tokens = name.to_s.scan(/[A-Za-z0-9]+/)
|
|
3074
|
+
normalized = if tokens.length == 1
|
|
3075
|
+
tokens.first.sub(/\A[a-z]/, &:upcase)
|
|
3076
|
+
else
|
|
3077
|
+
tokens.map(&:capitalize).join
|
|
3078
|
+
end
|
|
3079
|
+
return nil if normalized.empty?
|
|
3080
|
+
|
|
3081
|
+
normalized += "Able" unless normalized.match?(/(?:able|ible|ifiable|izable)\z/i)
|
|
3082
|
+
normalized
|
|
3083
|
+
end
|
|
3084
|
+
|
|
3085
|
+
private
|
|
3086
|
+
|
|
3087
|
+
def register_method(method)
|
|
3088
|
+
groups = method.capabilities
|
|
3089
|
+
.select { |capability| capability.receiver.is_a?(TypeVariable) }
|
|
3090
|
+
.group_by(&:receiver)
|
|
3091
|
+
|
|
3092
|
+
groups.each do |receiver, capabilities|
|
|
3093
|
+
shape = build_shape(receiver, capabilities)
|
|
3094
|
+
name = "Capability_#{Digest::SHA256.hexdigest(shape.fetch(:definition))[0, 10]}"
|
|
3095
|
+
definition = @definitions[name] ||= {
|
|
3096
|
+
id: name,
|
|
3097
|
+
name: name,
|
|
3098
|
+
parameters: shape.fetch(:parameter_names),
|
|
3099
|
+
definition: shape.fetch(:definition),
|
|
3100
|
+
template: shape.fetch(:template),
|
|
3101
|
+
slots: shape.fetch(:slots),
|
|
3102
|
+
uses: 0
|
|
3103
|
+
}
|
|
3104
|
+
definition[:uses] += 1
|
|
3105
|
+
@occurrences[[method.object_id, receiver]] = {
|
|
3106
|
+
arguments: shape.fetch(:parameter_types),
|
|
3107
|
+
definition: definition
|
|
3108
|
+
}
|
|
3109
|
+
end
|
|
3110
|
+
end
|
|
3111
|
+
|
|
3112
|
+
# Final compression: definitions sharing the same method structure (same
|
|
3113
|
+
# messages, arities, and keyword names) merge into one type. Slots that
|
|
3114
|
+
# agree stay fixed, concrete slots that differ become unions, and slots
|
|
3115
|
+
# involving generics become shared parameters bound per occurrence.
|
|
3116
|
+
def consolidate!
|
|
3117
|
+
@definitions.values.group_by { |definition| definition.fetch(:template) }.each_value do |members|
|
|
3118
|
+
next if members.length == 1
|
|
3119
|
+
|
|
3120
|
+
members = members.sort_by { |member| member.fetch(:id) }
|
|
3121
|
+
slot_count = members.first.fetch(:slots).length
|
|
3122
|
+
roles = (0...slot_count).map do |index|
|
|
3123
|
+
values = members.map { |member| member.fetch(:slots)[index] }
|
|
3124
|
+
if values.any? { |value| value.match?(/\bT\d+\b/) }
|
|
3125
|
+
:parameter
|
|
3126
|
+
elsif values.uniq.length == 1
|
|
3127
|
+
values.first
|
|
3128
|
+
else
|
|
3129
|
+
values.flat_map { |value| value.split(" | ") }.uniq.join(" | ")
|
|
3130
|
+
end
|
|
3131
|
+
end
|
|
3132
|
+
parameter_slots = roles.each_index.select { |index| roles[index] == :parameter }
|
|
3133
|
+
merged_text = members.first.fetch(:template).gsub(/%(\d+)%/) do
|
|
3134
|
+
index = Regexp.last_match(1).to_i
|
|
3135
|
+
roles[index] == :parameter ? "T#{parameter_slots.index(index) + 1}" : roles[index]
|
|
3136
|
+
end
|
|
3137
|
+
merged_id = "Capability_#{Digest::SHA256.hexdigest(merged_text)[0, 10]}"
|
|
3138
|
+
merged = {
|
|
3139
|
+
id: merged_id,
|
|
3140
|
+
name: merged_id,
|
|
3141
|
+
parameters: parameter_slots.each_index.map { |index| "T#{index + 1}" },
|
|
3142
|
+
definition: merged_text,
|
|
3143
|
+
template: merged_text,
|
|
3144
|
+
slots: [],
|
|
3145
|
+
uses: members.sum { |member| member.fetch(:uses) }
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
member_ids = members.to_set { |member| member.fetch(:id) }
|
|
3149
|
+
@occurrences.each_value do |occurrence|
|
|
3150
|
+
definition = occurrence.fetch(:definition)
|
|
3151
|
+
next unless member_ids.include?(definition.fetch(:id))
|
|
3152
|
+
|
|
3153
|
+
occurrence[:arguments] = parameter_slots.map do |index|
|
|
3154
|
+
TemplateArgument.new(
|
|
3155
|
+
template: definition.fetch(:slots)[index],
|
|
3156
|
+
arguments: occurrence.fetch(:arguments)
|
|
3157
|
+
)
|
|
3158
|
+
end
|
|
3159
|
+
occurrence[:definition] = merged
|
|
3160
|
+
end
|
|
3161
|
+
members.each { |member| @definitions.delete(member.fetch(:id)) }
|
|
3162
|
+
@definitions[merged_id] = merged
|
|
3163
|
+
end
|
|
3164
|
+
end
|
|
3165
|
+
|
|
3166
|
+
def build_shape(receiver, capabilities)
|
|
3167
|
+
parameter_types = []
|
|
3168
|
+
parameter_indexes = {}
|
|
3169
|
+
slots = []
|
|
3170
|
+
normalize = lambda do |type|
|
|
3171
|
+
case type
|
|
3172
|
+
when TypeVariable
|
|
3173
|
+
return "Self" if type == receiver
|
|
3174
|
+
|
|
3175
|
+
index = parameter_indexes[type] ||= begin
|
|
3176
|
+
parameter_types << type
|
|
3177
|
+
parameter_types.length
|
|
3178
|
+
end
|
|
3179
|
+
"T#{index}"
|
|
3180
|
+
when Named
|
|
3181
|
+
type.name
|
|
3182
|
+
when Generic
|
|
3183
|
+
"#{type.name}[#{type.arguments.map { |argument| normalize.call(argument) }.join(", ")}]"
|
|
3184
|
+
when Union
|
|
3185
|
+
format_union_slots(type.members.map { |member| normalize.call(member) }.uniq)
|
|
3186
|
+
when Function
|
|
3187
|
+
params = type.parameters.map { |parameter| normalize.call(parameter) }.join(", ")
|
|
3188
|
+
"(#{params}) -> #{normalize.call(type.result)}"
|
|
3189
|
+
else
|
|
3190
|
+
"Object"
|
|
3191
|
+
end
|
|
3192
|
+
end
|
|
3193
|
+
# Functions are structure (block arity), everything else is a type slot.
|
|
3194
|
+
slotify = lambda do |type|
|
|
3195
|
+
if type.is_a?(Function)
|
|
3196
|
+
params = type.parameters.map { |parameter| slotify.call(parameter) }.join(", ")
|
|
3197
|
+
"(#{params}) -> #{slotify.call(type.result)}"
|
|
3198
|
+
else
|
|
3199
|
+
slots << normalize.call(type)
|
|
3200
|
+
"%#{slots.length - 1}%"
|
|
3201
|
+
end
|
|
3202
|
+
end
|
|
3203
|
+
|
|
3204
|
+
sends = capabilities.sort_by { |capability| capability_sort_key(capability) }.map do |capability|
|
|
3205
|
+
arguments = capability.arguments.map { |argument| slotify.call(argument) }
|
|
3206
|
+
arguments.concat(
|
|
3207
|
+
capability.keywords.sort.map { |name, value| "#{name}: #{slotify.call(value)}" }
|
|
3208
|
+
)
|
|
3209
|
+
"#{capability.message}(#{arguments.join(", ")}) -> #{slotify.call(capability.result)}"
|
|
3210
|
+
end
|
|
3211
|
+
template = "{ #{sends.join("; ")} }"
|
|
3212
|
+
|
|
3213
|
+
{
|
|
3214
|
+
parameter_types: parameter_types,
|
|
3215
|
+
parameter_names: parameter_types.each_index.map { |index| "T#{index + 1}" },
|
|
3216
|
+
template: template,
|
|
3217
|
+
slots: slots,
|
|
3218
|
+
definition: template.gsub(/%(\d+)%/) { slots[Regexp.last_match(1).to_i] }
|
|
3219
|
+
}
|
|
3220
|
+
end
|
|
3221
|
+
|
|
3222
|
+
def capability_sort_key(capability)
|
|
3223
|
+
[
|
|
3224
|
+
capability.message.to_s,
|
|
3225
|
+
capability.arguments.length,
|
|
3226
|
+
capability.keywords.keys.map(&:to_s).sort.join(","),
|
|
3227
|
+
capability.line
|
|
3228
|
+
]
|
|
3229
|
+
end
|
|
3230
|
+
|
|
3231
|
+
def format_union_slots(members)
|
|
3232
|
+
members = members.uniq
|
|
3233
|
+
has_nil = members.include?("nil")
|
|
3234
|
+
members = members.reject { |member| member == "nil" || member == "Nullable[]" }
|
|
3235
|
+
|
|
3236
|
+
nullable_inners = []
|
|
3237
|
+
others = []
|
|
3238
|
+
members.each do |member|
|
|
3239
|
+
if (match = member.match(/\ANullable\[(.*)\]\z/))
|
|
3240
|
+
nullable_inners << match[1]
|
|
3241
|
+
else
|
|
3242
|
+
others << member
|
|
3243
|
+
end
|
|
3244
|
+
end
|
|
3245
|
+
|
|
3246
|
+
if has_nil || nullable_inners.any?
|
|
3247
|
+
inner_parts = (others + nullable_inners).uniq.sort
|
|
3248
|
+
inner = inner_parts.length == 1 ? inner_parts.first : inner_parts.join(" | ")
|
|
3249
|
+
return "Nullable[#{inner}]"
|
|
3250
|
+
end
|
|
3251
|
+
|
|
3252
|
+
others.sort!
|
|
3253
|
+
others.length == 1 ? others.first : others.join(" | ")
|
|
3254
|
+
end
|
|
3255
|
+
end
|
|
3256
|
+
|
|
3257
|
+
class Renderer
|
|
3258
|
+
NULLABLE = "Nullable"
|
|
3259
|
+
CORE_KEY_TYPES = %w[String Symbol Integer Float Rational Complex bool nil Object].freeze
|
|
3260
|
+
|
|
3261
|
+
attr_reader :helper_registry
|
|
3262
|
+
|
|
3263
|
+
def initialize(method, helper_registry: UniversalHelperRegistry.new([method]), analyzed_method: method)
|
|
3264
|
+
@method = method
|
|
3265
|
+
@analyzed_method = analyzed_method
|
|
3266
|
+
@helper_registry = helper_registry
|
|
3267
|
+
@names = {}
|
|
3268
|
+
@helpers = analyzed_method.capabilities
|
|
3269
|
+
.map(&:receiver)
|
|
3270
|
+
.uniq
|
|
3271
|
+
.filter_map do |receiver|
|
|
3272
|
+
occurrence = helper_registry.occurrence(analyzed_method, receiver)
|
|
3273
|
+
[receiver, occurrence] if occurrence
|
|
3274
|
+
end
|
|
3275
|
+
.to_h
|
|
3276
|
+
end
|
|
3277
|
+
|
|
3278
|
+
def text
|
|
3279
|
+
params = @method.parameters.map { |parameter| render_parameter(parameter) }.join(", ")
|
|
3280
|
+
signature = "#{@method.name} : (#{params}) -> #{render(@method.result, grounded_only: true)}"
|
|
3281
|
+
helpers = helper_definitions.map do |helper|
|
|
3282
|
+
parameters = helper.fetch(:parameters)
|
|
3283
|
+
generic = parameters.empty? ? "" : "<#{parameters.join(", ")}>"
|
|
3284
|
+
definition = polish_type_string(helper.fetch(:definition))
|
|
3285
|
+
" type #{helper.fetch(:name)}#{generic} = #{definition}"
|
|
3286
|
+
end
|
|
3287
|
+
constraints = ungrouped_capabilities.map do |capability|
|
|
3288
|
+
" L#{capability.line}: #{render_capability(capability)}"
|
|
3289
|
+
end
|
|
3290
|
+
sections = [signature]
|
|
3291
|
+
sections << "helpers:\n#{helpers.join("\n")}" unless helpers.empty?
|
|
3292
|
+
sections << "constraints:\n#{constraints.join("\n")}" unless constraints.empty?
|
|
3293
|
+
sections.join("\n")
|
|
3294
|
+
end
|
|
3295
|
+
|
|
3296
|
+
def as_json
|
|
3297
|
+
{
|
|
3298
|
+
name: @method.name,
|
|
3299
|
+
line: @method.line,
|
|
3300
|
+
end_line: @method.end_line,
|
|
3301
|
+
signature: text.lines.first.chomp,
|
|
3302
|
+
helpers: helper_definitions,
|
|
3303
|
+
constraints: ungrouped_capabilities.map { |capability| render_capability(capability) },
|
|
3304
|
+
send_constraint_count: @method.capabilities.length,
|
|
3305
|
+
locals: rendered_bindings
|
|
3306
|
+
}
|
|
3307
|
+
end
|
|
3308
|
+
|
|
3309
|
+
private
|
|
3310
|
+
|
|
3311
|
+
# Local variables, parameters, and ivars with their rendered types, for
|
|
3312
|
+
# editor hover support.
|
|
3313
|
+
def rendered_bindings
|
|
3314
|
+
bindings = {}
|
|
3315
|
+
@method.locals.each { |name, type| bindings[name.to_s] = render(type, grounded_only: true, skip_helpers: true) }
|
|
3316
|
+
@method.ivars.each { |name, type| bindings[name.to_s] = render(type, grounded_only: true, skip_helpers: true) }
|
|
3317
|
+
bindings
|
|
3318
|
+
end
|
|
3319
|
+
|
|
3320
|
+
def render_parameter(parameter)
|
|
3321
|
+
return "... #{render(parameter.type)}" if parameter.kind == :forwarding
|
|
3322
|
+
|
|
3323
|
+
prefix = case parameter.kind
|
|
3324
|
+
when :rest then "*"
|
|
3325
|
+
when :keyword_rest then "**"
|
|
3326
|
+
when :block then "&"
|
|
3327
|
+
else ""
|
|
3328
|
+
end
|
|
3329
|
+
suffix = %i[keyword optional_keyword].include?(parameter.kind) ? ":" : ""
|
|
3330
|
+
optional = %i[optional optional_keyword].include?(parameter.kind) ? "?" : ""
|
|
3331
|
+
type_text = render_parameter_type(parameter)
|
|
3332
|
+
"#{prefix}#{parameter.name}#{suffix}#{optional} #{type_text}"
|
|
3333
|
+
end
|
|
3334
|
+
|
|
3335
|
+
def render_parameter_type(parameter)
|
|
3336
|
+
concrete = render(parameter.type, skip_helpers: true)
|
|
3337
|
+
return concrete if concrete_parameter_type?(concrete)
|
|
3338
|
+
|
|
3339
|
+
analyzed = @analyzed_method.parameters.find { |candidate| candidate.name == parameter.name }
|
|
3340
|
+
analyzed_variable = analyzed&.type
|
|
3341
|
+
analyzed_variable = nil unless analyzed_variable.is_a?(TypeVariable)
|
|
3342
|
+
|
|
3343
|
+
if analyzed_variable
|
|
3344
|
+
record = render_record_type(analyzed_variable)
|
|
3345
|
+
return record if record
|
|
3346
|
+
|
|
3347
|
+
return render_helper_reference(@helpers.fetch(analyzed_variable)) if @helpers.key?(analyzed_variable)
|
|
3348
|
+
end
|
|
3349
|
+
|
|
3350
|
+
render(parameter.type, structural: true)
|
|
3351
|
+
end
|
|
3352
|
+
|
|
3353
|
+
def concrete_parameter_type?(text)
|
|
3354
|
+
return false if text == "Object"
|
|
3355
|
+
return false if text.start_with?("{ ")
|
|
3356
|
+
return false if text.match?(/\ACapability_[a-f0-9]+/)
|
|
3357
|
+
|
|
3358
|
+
true
|
|
3359
|
+
end
|
|
3360
|
+
|
|
3361
|
+
def render_record_type(variable)
|
|
3362
|
+
capabilities = solved_capabilities_for(variable).select { |capability| capability.message == :[] }
|
|
3363
|
+
fields = Hash.new { |hash, key| hash[key] = [] }
|
|
3364
|
+
capabilities.each do |capability|
|
|
3365
|
+
field = field_name_from_index_key(capability.arguments.first)
|
|
3366
|
+
next unless field
|
|
3367
|
+
|
|
3368
|
+
fields[field] << capability.result
|
|
3369
|
+
end
|
|
3370
|
+
return nil if fields.empty?
|
|
3371
|
+
|
|
3372
|
+
body = fields.sort.map do |name, types|
|
|
3373
|
+
rendered = join_union(types.map { |type| render(type, grounded_only: true, skip_helpers: true) }.uniq)
|
|
3374
|
+
"#{name}: #{rendered}"
|
|
3375
|
+
end.join(", ")
|
|
3376
|
+
"{ #{body} }"
|
|
3377
|
+
end
|
|
3378
|
+
|
|
3379
|
+
def solved_capabilities_for(analyzed_variable)
|
|
3380
|
+
analyzed = @analyzed_method.capabilities.select { |capability| capability.receiver == analyzed_variable }
|
|
3381
|
+
analyzed.map do |analyzed_capability|
|
|
3382
|
+
@method.capabilities.find do |solved_capability|
|
|
3383
|
+
solved_capability.line == analyzed_capability.line &&
|
|
3384
|
+
solved_capability.message == analyzed_capability.message &&
|
|
3385
|
+
index_key_eq?(solved_capability.arguments, analyzed_capability.arguments)
|
|
3386
|
+
end || analyzed_capability
|
|
3387
|
+
end
|
|
3388
|
+
end
|
|
3389
|
+
|
|
3390
|
+
def index_key_eq?(left_args, right_args)
|
|
3391
|
+
left_args.zip(right_args).all? do |left, right|
|
|
3392
|
+
resolve_index_key(left) == resolve_index_key(right)
|
|
3393
|
+
end
|
|
3394
|
+
end
|
|
3395
|
+
|
|
3396
|
+
def resolve_index_key(key)
|
|
3397
|
+
case key
|
|
3398
|
+
when Named then key.name
|
|
3399
|
+
else render(key, skip_helpers: true)
|
|
3400
|
+
end
|
|
3401
|
+
end
|
|
3402
|
+
|
|
3403
|
+
def field_name_from_index_key(key)
|
|
3404
|
+
case key
|
|
3405
|
+
when Named
|
|
3406
|
+
return key.name unless CORE_KEY_TYPES.include?(key.name)
|
|
3407
|
+
end
|
|
3408
|
+
nil
|
|
3409
|
+
end
|
|
3410
|
+
|
|
3411
|
+
def record_parameter?(variable)
|
|
3412
|
+
@analyzed_method.capabilities.any? do |capability|
|
|
3413
|
+
capability.receiver == variable &&
|
|
3414
|
+
capability.message == :[] &&
|
|
3415
|
+
field_name_from_index_key(capability.arguments.first)
|
|
3416
|
+
end
|
|
3417
|
+
end
|
|
3418
|
+
|
|
3419
|
+
def record_field_slot?(capability)
|
|
3420
|
+
return false unless capability.message == :[]
|
|
3421
|
+
|
|
3422
|
+
receiver = capability.receiver
|
|
3423
|
+
return false unless receiver.is_a?(TypeVariable) && record_parameter?(receiver)
|
|
3424
|
+
|
|
3425
|
+
field_name_from_index_key(capability.arguments.first)
|
|
3426
|
+
end
|
|
3427
|
+
|
|
3428
|
+
def render(type, structural: false, grounded_only: false, skip_helpers: false, depth: 0)
|
|
3429
|
+
return "Object" if depth > 48
|
|
3430
|
+
|
|
3431
|
+
type = simplify(type)
|
|
3432
|
+
rendered = case type
|
|
3433
|
+
when TypeVariable
|
|
3434
|
+
if @helpers.key?(type) && !skip_helpers
|
|
3435
|
+
render_helper_reference(@helpers.fetch(type))
|
|
3436
|
+
else
|
|
3437
|
+
render_structural_variable(type, depth: depth + 1)
|
|
3438
|
+
end
|
|
3439
|
+
when Named
|
|
3440
|
+
type.name
|
|
3441
|
+
when Generic
|
|
3442
|
+
if type.name == NULLABLE && type.arguments.length == 1
|
|
3443
|
+
"Nullable[#{render(type.arguments.first, grounded_only:, skip_helpers:, depth: depth + 1)}]"
|
|
3444
|
+
else
|
|
3445
|
+
"#{type.name}[#{type.arguments.map { |argument| render(argument, grounded_only:, skip_helpers:, depth: depth + 1) }.join(", ")}]"
|
|
3446
|
+
end
|
|
3447
|
+
when Union
|
|
3448
|
+
join_union(type.members.map { |member| render(member, grounded_only:, skip_helpers:, depth: depth + 1) })
|
|
3449
|
+
when Function
|
|
3450
|
+
parameters = type.parameters.map { |parameter| render(parameter, grounded_only:, skip_helpers:, depth: depth + 1) }.join(", ")
|
|
3451
|
+
"(#{parameters}) -> #{render(type.result, grounded_only:, skip_helpers:, depth: depth + 1)}"
|
|
3452
|
+
else
|
|
3453
|
+
"Object"
|
|
3454
|
+
end
|
|
3455
|
+
polish_type_string(rendered)
|
|
3456
|
+
end
|
|
3457
|
+
|
|
3458
|
+
def simplify(type, seen = nil, depth = 0)
|
|
3459
|
+
return type if depth > 48
|
|
3460
|
+
seen ||= Set.new
|
|
3461
|
+
return type if seen.include?(type.object_id)
|
|
3462
|
+
|
|
3463
|
+
seen.add(type.object_id)
|
|
3464
|
+
case type
|
|
3465
|
+
when Union
|
|
3466
|
+
members = type.members.flat_map { |member| member.is_a?(Union) ? member.members : [member] }
|
|
3467
|
+
members = members.map { |member| simplify(member, seen, depth + 1) }
|
|
3468
|
+
members = dedupe_types(members)
|
|
3469
|
+
return members.first if members.length == 1
|
|
3470
|
+
|
|
3471
|
+
nil_members, rest = members.partition { |member| nil_type?(member) }
|
|
3472
|
+
if nil_members.any?
|
|
3473
|
+
inner = rest.length == 1 ? rest.first : Union.new(rest)
|
|
3474
|
+
inner = simplify(inner, seen, depth + 1)
|
|
3475
|
+
return Generic.new(NULLABLE, [inner]) unless nil_type?(inner)
|
|
3476
|
+
|
|
3477
|
+
return inner
|
|
3478
|
+
end
|
|
3479
|
+
|
|
3480
|
+
Union.new(members)
|
|
3481
|
+
when Generic
|
|
3482
|
+
arguments = type.arguments.map { |argument| simplify(argument, seen, depth + 1) }
|
|
3483
|
+
if type.name == NULLABLE && arguments.length == 1
|
|
3484
|
+
inner = unwrap_nullable(arguments.first)
|
|
3485
|
+
return Generic.new(NULLABLE, [inner.is_a?(Generic) && inner.name == NULLABLE ? inner.arguments.first : inner])
|
|
3486
|
+
end
|
|
3487
|
+
|
|
3488
|
+
Generic.new(type.name, arguments)
|
|
3489
|
+
when Function
|
|
3490
|
+
Function.new(
|
|
3491
|
+
type.parameters.map { |parameter| simplify(parameter, seen, depth + 1) },
|
|
3492
|
+
simplify(type.result, seen, depth + 1)
|
|
3493
|
+
)
|
|
3494
|
+
else
|
|
3495
|
+
type
|
|
3496
|
+
end
|
|
3497
|
+
end
|
|
3498
|
+
|
|
3499
|
+
def nil_type?(type)
|
|
3500
|
+
type.is_a?(Named) && type.name == "nil"
|
|
3501
|
+
end
|
|
3502
|
+
|
|
3503
|
+
def unwrap_nullable(type)
|
|
3504
|
+
while type.is_a?(Generic) && type.name == NULLABLE && type.arguments.length == 1
|
|
3505
|
+
type = type.arguments.first
|
|
3506
|
+
end
|
|
3507
|
+
type
|
|
3508
|
+
end
|
|
3509
|
+
|
|
3510
|
+
def nullable_type(inner)
|
|
3511
|
+
inner = unwrap_nullable(inner)
|
|
3512
|
+
return inner if nil_type?(inner)
|
|
3513
|
+
|
|
3514
|
+
Generic.new(NULLABLE, [inner])
|
|
3515
|
+
end
|
|
3516
|
+
|
|
3517
|
+
def flatten_nullable_type(type)
|
|
3518
|
+
return type unless type.is_a?(Generic) && type.name == NULLABLE && type.arguments.length == 1
|
|
3519
|
+
|
|
3520
|
+
Generic.new(NULLABLE, [unwrap_nullable(type.arguments.first)])
|
|
3521
|
+
end
|
|
3522
|
+
|
|
3523
|
+
def dedupe_types(types)
|
|
3524
|
+
types.uniq
|
|
3525
|
+
end
|
|
3526
|
+
|
|
3527
|
+
# Nullable[T] when nil is present; otherwise a sorted, deduped union. Never
|
|
3528
|
+
# drop members — an incomplete inference is shown as Object, not elided.
|
|
3529
|
+
def join_union(rendered)
|
|
3530
|
+
parts = []
|
|
3531
|
+
has_nil = false
|
|
3532
|
+
rendered.uniq.each do |member|
|
|
3533
|
+
case member
|
|
3534
|
+
when "nil"
|
|
3535
|
+
has_nil = true
|
|
3536
|
+
when "Nullable[]"
|
|
3537
|
+
next
|
|
3538
|
+
else
|
|
3539
|
+
if (match = member.match(/\ANullable\[(.*)\]\z/m))
|
|
3540
|
+
has_nil = true
|
|
3541
|
+
parts.concat(split_top_level_union(match[1]))
|
|
3542
|
+
else
|
|
3543
|
+
parts.concat(split_top_level_union(member))
|
|
3544
|
+
end
|
|
3545
|
+
end
|
|
3546
|
+
end
|
|
3547
|
+
|
|
3548
|
+
parts = parts.reject { |part| part == "nil" || part.empty? }.uniq.sort
|
|
3549
|
+
if has_nil
|
|
3550
|
+
inner = parts.length == 1 ? parts.first : parts.join(" | ")
|
|
3551
|
+
inner = inner.match(/\ANullable\[(.*)\]\z/m)&.then { |match| match[1] } || inner
|
|
3552
|
+
return "Nullable[#{inner}]"
|
|
3553
|
+
end
|
|
3554
|
+
|
|
3555
|
+
parts.length == 1 ? parts.first : parts.join(" | ")
|
|
3556
|
+
end
|
|
3557
|
+
|
|
3558
|
+
# Rewrite nil-union slots inside helper definition text and similar
|
|
3559
|
+
# pre-rendered type strings from the registry.
|
|
3560
|
+
def polish_type_string(text)
|
|
3561
|
+
return text unless text.is_a?(String)
|
|
3562
|
+
|
|
3563
|
+
text = flatten_nullable_text(text)
|
|
3564
|
+
8.times do
|
|
3565
|
+
previous = text
|
|
3566
|
+
text = text.gsub(/([A-Za-z0-9_:|\[\], ]+ \|\ nil|nil \|\ [A-Za-z0-9_:|\[\], ]+)/) do |match|
|
|
3567
|
+
join_union(split_top_level_union(match))
|
|
3568
|
+
end
|
|
3569
|
+
text = text.gsub(/Nullable\[([^\]]+)\]/) do |_match|
|
|
3570
|
+
inner = join_union(split_top_level_union(Regexp.last_match(1)))
|
|
3571
|
+
inner.start_with?("Nullable[") ? inner : "Nullable[#{inner}]"
|
|
3572
|
+
end
|
|
3573
|
+
text = flatten_nullable_text(text)
|
|
3574
|
+
break if text == previous
|
|
3575
|
+
end
|
|
3576
|
+
text
|
|
3577
|
+
end
|
|
3578
|
+
|
|
3579
|
+
def flatten_nullable_text(text)
|
|
3580
|
+
8.times do
|
|
3581
|
+
previous = text
|
|
3582
|
+
text = text.gsub("Nullable[Nullable[", "Nullable[")
|
|
3583
|
+
break if text == previous
|
|
3584
|
+
end
|
|
3585
|
+
text
|
|
3586
|
+
end
|
|
3587
|
+
|
|
3588
|
+
def split_top_level_union(text)
|
|
3589
|
+
parts = []
|
|
3590
|
+
depth = 0
|
|
3591
|
+
start = 0
|
|
3592
|
+
i = 0
|
|
3593
|
+
while i < text.length
|
|
3594
|
+
char = text[i]
|
|
3595
|
+
depth += 1 if char == "["
|
|
3596
|
+
depth -= 1 if char == "]"
|
|
3597
|
+
if depth.zero? && text[i, 3] == " | "
|
|
3598
|
+
parts << text[start...i].strip
|
|
3599
|
+
i += 3
|
|
3600
|
+
start = i
|
|
3601
|
+
next
|
|
3602
|
+
end
|
|
3603
|
+
i += 1
|
|
3604
|
+
end
|
|
3605
|
+
parts << text[start..].strip
|
|
3606
|
+
parts.reject(&:empty?)
|
|
3607
|
+
end
|
|
3608
|
+
|
|
3609
|
+
def render_structural_variable(variable, depth: 0)
|
|
3610
|
+
return "Object" if depth > 16
|
|
3611
|
+
|
|
3612
|
+
capabilities = @method.capabilities.select { |capability| capability.receiver == variable }
|
|
3613
|
+
return "Object" if capabilities.empty?
|
|
3614
|
+
|
|
3615
|
+
body = capabilities.first(24).map { |capability| render_send(capability, depth: depth + 1) }.uniq.join("; ")
|
|
3616
|
+
"{ #{body} }"
|
|
3617
|
+
end
|
|
3618
|
+
|
|
3619
|
+
def render_capability(capability)
|
|
3620
|
+
"#{render(capability.receiver)} responds to #{render_send(capability)}"
|
|
3621
|
+
end
|
|
3622
|
+
|
|
3623
|
+
def render_send(capability, depth: 0)
|
|
3624
|
+
return "..." if depth > 16
|
|
3625
|
+
|
|
3626
|
+
arguments = capability.arguments.map { |argument| render(argument, depth: depth + 1) }
|
|
3627
|
+
arguments.concat(capability.keywords.map { |name, value| "#{name}: #{render(value, depth: depth + 1)}" })
|
|
3628
|
+
"#{capability.message}(#{arguments.join(", ")}) -> #{render(capability.result, depth: depth + 1)}"
|
|
3629
|
+
end
|
|
3630
|
+
|
|
3631
|
+
def helper_definitions
|
|
3632
|
+
@helpers.values.uniq.map do |occurrence|
|
|
3633
|
+
definition = occurrence.fetch(:definition)
|
|
3634
|
+
{
|
|
3635
|
+
name: definition.fetch(:name),
|
|
3636
|
+
reference: render_helper_reference(occurrence),
|
|
3637
|
+
parameters: definition.fetch(:parameters),
|
|
3638
|
+
definition: polish_type_string(definition.fetch(:definition))
|
|
3639
|
+
}
|
|
3640
|
+
end.uniq { |helper| [helper.fetch(:name), helper.fetch(:definition)] }
|
|
3641
|
+
end
|
|
3642
|
+
|
|
3643
|
+
def ungrouped_capabilities
|
|
3644
|
+
grouped_lines = @analyzed_method.capabilities
|
|
3645
|
+
.select { |capability| capability.receiver.is_a?(TypeVariable) && @helpers.key?(capability.receiver) }
|
|
3646
|
+
.map(&:line)
|
|
3647
|
+
.to_set
|
|
3648
|
+
@method.capabilities.reject { |capability| grouped_lines.include?(capability.line) }
|
|
3649
|
+
end
|
|
3650
|
+
|
|
3651
|
+
def render_helper_reference(occurrence)
|
|
3652
|
+
arguments = occurrence.fetch(:arguments).map { |argument| render_helper_argument(argument) }
|
|
3653
|
+
name = occurrence.fetch(:definition).fetch(:name)
|
|
3654
|
+
reference = arguments.empty? ? name : "#{name}[#{arguments.join(", ")}]"
|
|
3655
|
+
polish_type_string(reference)
|
|
3656
|
+
end
|
|
3657
|
+
|
|
3658
|
+
def render_helper_argument(type)
|
|
3659
|
+
type = simplify(type) if type.is_a?(Union) || type.is_a?(Generic)
|
|
3660
|
+
case type
|
|
3661
|
+
when TemplateArgument
|
|
3662
|
+
substituted = type.template.gsub(/\bT(\d+)\b/) do
|
|
3663
|
+
render_helper_argument(type.arguments[Regexp.last_match(1).to_i - 1])
|
|
3664
|
+
end
|
|
3665
|
+
polish_type_string(substituted)
|
|
3666
|
+
when TypeVariable
|
|
3667
|
+
solved = solved_type_for_variable(type)
|
|
3668
|
+
case solved
|
|
3669
|
+
when Named then solved.name
|
|
3670
|
+
when Generic
|
|
3671
|
+
if solved.name == NULLABLE && solved.arguments.length == 1
|
|
3672
|
+
return "Nullable[#{render_helper_argument(solved.arguments.first)}]"
|
|
3673
|
+
end
|
|
3674
|
+
|
|
3675
|
+
"#{solved.name}[#{solved.arguments.map { |argument| render_helper_argument(argument) }.join(", ")}]"
|
|
3676
|
+
when Union
|
|
3677
|
+
join_union(solved.members.map { |member| render_helper_argument(member) })
|
|
3678
|
+
when Function
|
|
3679
|
+
parameters = solved.parameters.map { |parameter| render_helper_argument(parameter) }.join(", ")
|
|
3680
|
+
"(#{parameters}) -> #{render_helper_argument(solved.result)}"
|
|
3681
|
+
else
|
|
3682
|
+
"Object"
|
|
3683
|
+
end
|
|
3684
|
+
when Named
|
|
3685
|
+
type.name
|
|
3686
|
+
when Generic
|
|
3687
|
+
"#{type.name}[#{type.arguments.map { |argument| render_helper_argument(argument) }.join(", ")}]"
|
|
3688
|
+
when Union
|
|
3689
|
+
join_union(type.members.map { |member| render_helper_argument(member) })
|
|
3690
|
+
when Function
|
|
3691
|
+
parameters = type.parameters.map { |parameter| render_helper_argument(parameter) }.join(", ")
|
|
3692
|
+
"(#{parameters}) -> #{render_helper_argument(type.result)}"
|
|
3693
|
+
else
|
|
3694
|
+
"Object"
|
|
3695
|
+
end
|
|
3696
|
+
end
|
|
3697
|
+
|
|
3698
|
+
def solved_type_for_variable(analyzed_variable)
|
|
3699
|
+
analyzed_capability = @analyzed_method.capabilities.find { |capability| capability.result == analyzed_variable }
|
|
3700
|
+
return nil unless analyzed_capability
|
|
3701
|
+
|
|
3702
|
+
@method.capabilities.find do |capability|
|
|
3703
|
+
capability.line == analyzed_capability.line && capability.message == analyzed_capability.message
|
|
3704
|
+
end&.result
|
|
3705
|
+
end
|
|
3706
|
+
end
|
|
3707
|
+
|
|
3708
|
+
class HtmlDocument
|
|
3709
|
+
def initialize(reports, analyzed_files: reports.map(&:first).uniq, parse_errors: {})
|
|
3710
|
+
@reports = reports
|
|
3711
|
+
@grouped = reports.group_by(&:first)
|
|
3712
|
+
@analyzed_files = analyzed_files
|
|
3713
|
+
@parse_errors = parse_errors
|
|
3714
|
+
@helper_registry = reports.first&.last&.helper_registry
|
|
3715
|
+
end
|
|
3716
|
+
|
|
3717
|
+
def render
|
|
3718
|
+
method_count = @reports.length
|
|
3719
|
+
constraint_count = @reports.sum { |_, renderer| renderer.as_json.fetch(:send_constraint_count) }
|
|
3720
|
+
generated_at = Time.now.utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
3721
|
+
|
|
3722
|
+
<<~HTML
|
|
3723
|
+
<!doctype html>
|
|
3724
|
+
<html lang="en">
|
|
3725
|
+
<head>
|
|
3726
|
+
<meta charset="utf-8">
|
|
3727
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3728
|
+
<title>Autotype inferred types</title>
|
|
3729
|
+
<style>
|
|
3730
|
+
:root {
|
|
3731
|
+
color-scheme: light dark;
|
|
3732
|
+
--bg: #111318;
|
|
3733
|
+
--panel: #181b22;
|
|
3734
|
+
--panel-2: #20242d;
|
|
3735
|
+
--text: #e8eaf0;
|
|
3736
|
+
--muted: #9ba3b4;
|
|
3737
|
+
--line: #343a46;
|
|
3738
|
+
--accent: #78a9ff;
|
|
3739
|
+
--capability: #b6e3a8;
|
|
3740
|
+
--code: #f1d18a;
|
|
3741
|
+
}
|
|
3742
|
+
* { box-sizing: border-box; }
|
|
3743
|
+
html { scroll-behavior: smooth; }
|
|
3744
|
+
body {
|
|
3745
|
+
margin: 0;
|
|
3746
|
+
background: var(--bg);
|
|
3747
|
+
color: var(--text);
|
|
3748
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
3749
|
+
}
|
|
3750
|
+
a { color: var(--accent); text-decoration: none; }
|
|
3751
|
+
a:hover { text-decoration: underline; }
|
|
3752
|
+
code {
|
|
3753
|
+
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
3754
|
+
overflow-wrap: anywhere;
|
|
3755
|
+
}
|
|
3756
|
+
.layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; }
|
|
3757
|
+
aside {
|
|
3758
|
+
position: sticky;
|
|
3759
|
+
top: 0;
|
|
3760
|
+
height: 100vh;
|
|
3761
|
+
overflow: auto;
|
|
3762
|
+
border-right: 1px solid var(--line);
|
|
3763
|
+
background: var(--panel);
|
|
3764
|
+
padding: 20px 16px;
|
|
3765
|
+
}
|
|
3766
|
+
main { width: min(1100px, 100%); padding: 32px 42px 80px; }
|
|
3767
|
+
h1 { margin: 0 0 6px; font-size: 24px; }
|
|
3768
|
+
h2 { margin: 42px 0 12px; font-size: 18px; scroll-margin-top: 20px; }
|
|
3769
|
+
h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
3770
|
+
.muted { color: var(--muted); }
|
|
3771
|
+
.summary { display: flex; gap: 24px; margin: 18px 0 28px; }
|
|
3772
|
+
.summary strong { display: block; font-size: 20px; }
|
|
3773
|
+
.summary span { color: var(--muted); font-size: 12px; }
|
|
3774
|
+
#search {
|
|
3775
|
+
width: 100%;
|
|
3776
|
+
margin: 18px 0;
|
|
3777
|
+
padding: 9px 10px;
|
|
3778
|
+
border: 1px solid var(--line);
|
|
3779
|
+
border-radius: 6px;
|
|
3780
|
+
background: var(--panel-2);
|
|
3781
|
+
color: var(--text);
|
|
3782
|
+
}
|
|
3783
|
+
nav ul { margin: 0; padding: 0; list-style: none; }
|
|
3784
|
+
nav li { margin: 5px 0; }
|
|
3785
|
+
nav a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3786
|
+
.method {
|
|
3787
|
+
margin: 10px 0;
|
|
3788
|
+
border: 1px solid var(--line);
|
|
3789
|
+
border-radius: 7px;
|
|
3790
|
+
background: var(--panel);
|
|
3791
|
+
overflow: hidden;
|
|
3792
|
+
}
|
|
3793
|
+
.method header { padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
|
3794
|
+
.line { color: var(--muted); font-size: 12px; }
|
|
3795
|
+
.signature {
|
|
3796
|
+
display: block;
|
|
3797
|
+
padding: 13px 15px;
|
|
3798
|
+
color: var(--code);
|
|
3799
|
+
background: var(--panel-2);
|
|
3800
|
+
white-space: pre-wrap;
|
|
3801
|
+
overflow-wrap: anywhere;
|
|
3802
|
+
}
|
|
3803
|
+
.helpers { padding: 11px 15px 4px; border-top: 1px solid var(--line); }
|
|
3804
|
+
.helpers h4 { margin: 0 0 7px; color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
|
3805
|
+
.helper { display: block; padding: 5px 0 8px; }
|
|
3806
|
+
.helper-name { display: block; color: var(--accent); font-weight: 700; overflow-wrap: anywhere; }
|
|
3807
|
+
.helper-definition { display: block; padding-top: 2px; color: var(--capability); overflow-wrap: anywhere; }
|
|
3808
|
+
.universal-types { margin: 32px 0 44px; }
|
|
3809
|
+
.universal-type { padding: 10px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
|
|
3810
|
+
.universal-type code { display: block; }
|
|
3811
|
+
.uses { color: var(--muted); font-size: 12px; }
|
|
3812
|
+
.constraints { margin: 0; padding: 10px 15px 13px 34px; }
|
|
3813
|
+
.constraints li { padding: 2px 0; color: var(--capability); }
|
|
3814
|
+
.empty { padding: 10px 15px; color: var(--muted); }
|
|
3815
|
+
.warnings { margin: 24px 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 7px; }
|
|
3816
|
+
.warnings h2 { margin: 0 0 8px; font-size: 15px; }
|
|
3817
|
+
.warnings ul { margin: 0; padding-left: 20px; }
|
|
3818
|
+
.hidden { display: none; }
|
|
3819
|
+
@media (prefers-color-scheme: light) {
|
|
3820
|
+
:root {
|
|
3821
|
+
--bg: #f7f8fa;
|
|
3822
|
+
--panel: #fff;
|
|
3823
|
+
--panel-2: #f1f3f6;
|
|
3824
|
+
--text: #20242d;
|
|
3825
|
+
--muted: #677084;
|
|
3826
|
+
--line: #d9dde5;
|
|
3827
|
+
--accent: #245fb5;
|
|
3828
|
+
--capability: #276738;
|
|
3829
|
+
--code: #754f00;
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
@media (max-width: 760px) {
|
|
3833
|
+
.layout { display: block; }
|
|
3834
|
+
aside { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
3835
|
+
main { padding: 24px 18px 60px; }
|
|
3836
|
+
}
|
|
3837
|
+
</style>
|
|
3838
|
+
</head>
|
|
3839
|
+
<body>
|
|
3840
|
+
<div class="layout">
|
|
3841
|
+
<aside>
|
|
3842
|
+
<h1>Inferred types</h1>
|
|
3843
|
+
<div class="muted">Autotype · #{escape(generated_at)}</div>
|
|
3844
|
+
<input id="search" type="search" placeholder="Filter methods or types…" aria-label="Filter methods or types">
|
|
3845
|
+
<nav aria-label="Source files">
|
|
3846
|
+
<ul>#{navigation}</ul>
|
|
3847
|
+
</nav>
|
|
3848
|
+
</aside>
|
|
3849
|
+
<main>
|
|
3850
|
+
<h1>Syntax-less Ruby typedoc</h1>
|
|
3851
|
+
<p class="muted">Intraprocedural structural inference from Ruby message sends. Signatures are generalized at each method boundary.</p>
|
|
3852
|
+
<div class="summary">
|
|
3853
|
+
<div><strong>#{@analyzed_files.length}</strong><span>files analyzed</span></div>
|
|
3854
|
+
<div><strong>#{@grouped.length}</strong><span>files with methods</span></div>
|
|
3855
|
+
<div><strong>#{method_count}</strong><span>methods</span></div>
|
|
3856
|
+
<div><strong>#{constraint_count}</strong><span>send constraints</span></div>
|
|
3857
|
+
<div><strong>#{universal_definitions.length}</strong><span>universal types</span></div>
|
|
3858
|
+
</div>
|
|
3859
|
+
#{parse_failure_section}
|
|
3860
|
+
#{universal_type_section}
|
|
3861
|
+
#{sections}
|
|
3862
|
+
</main>
|
|
3863
|
+
</div>
|
|
3864
|
+
<script>
|
|
3865
|
+
const search = document.querySelector("#search");
|
|
3866
|
+
search.addEventListener("input", () => {
|
|
3867
|
+
const query = search.value.trim().toLowerCase();
|
|
3868
|
+
document.querySelectorAll(".method").forEach((element) => {
|
|
3869
|
+
element.classList.toggle("hidden", query && !element.dataset.search.includes(query));
|
|
3870
|
+
});
|
|
3871
|
+
document.querySelectorAll(".file-section").forEach((section) => {
|
|
3872
|
+
const visible = section.querySelector(".method:not(.hidden)");
|
|
3873
|
+
section.classList.toggle("hidden", query && !visible);
|
|
3874
|
+
});
|
|
3875
|
+
});
|
|
3876
|
+
</script>
|
|
3877
|
+
</body>
|
|
3878
|
+
</html>
|
|
3879
|
+
HTML
|
|
3880
|
+
end
|
|
3881
|
+
|
|
3882
|
+
private
|
|
3883
|
+
|
|
3884
|
+
def navigation
|
|
3885
|
+
files = @grouped.keys.sort.each_with_index.map do |path, index|
|
|
3886
|
+
%(<li><a href="#file-#{index}" title="#{escape(path)}">#{escape(path)}</a></li>)
|
|
3887
|
+
end
|
|
3888
|
+
([%(<li><a href="#universal-types">Universal capability types</a></li>)] + files).join
|
|
3889
|
+
end
|
|
3890
|
+
|
|
3891
|
+
def parse_failure_section
|
|
3892
|
+
return "" if @parse_errors.empty?
|
|
3893
|
+
|
|
3894
|
+
items = @parse_errors.sort.map do |path, errors|
|
|
3895
|
+
"<li><code>#{escape(path)}</code>: #{escape(errors.join("; "))}</li>"
|
|
3896
|
+
end.join
|
|
3897
|
+
<<~HTML
|
|
3898
|
+
<section class="warnings">
|
|
3899
|
+
<h2>Parse failures (#{@parse_errors.length})</h2>
|
|
3900
|
+
<ul>#{items}</ul>
|
|
3901
|
+
</section>
|
|
3902
|
+
HTML
|
|
3903
|
+
end
|
|
3904
|
+
|
|
3905
|
+
def sections
|
|
3906
|
+
@grouped.sort_by(&:first).each_with_index.map do |(path, entries), index|
|
|
3907
|
+
methods = entries.map { |_, renderer| method_card(renderer) }.join
|
|
3908
|
+
<<~HTML
|
|
3909
|
+
<section class="file-section" id="file-#{index}">
|
|
3910
|
+
<h2>#{escape(path)}</h2>
|
|
3911
|
+
#{methods}
|
|
3912
|
+
</section>
|
|
3913
|
+
HTML
|
|
3914
|
+
end.join
|
|
3915
|
+
end
|
|
3916
|
+
|
|
3917
|
+
def universal_type_section
|
|
3918
|
+
definitions = universal_definitions.map do |definition|
|
|
3919
|
+
parameters = definition.fetch(:parameters)
|
|
3920
|
+
generic = parameters.empty? ? "" : "<#{escape(parameters.join(", "))}>"
|
|
3921
|
+
<<~HTML
|
|
3922
|
+
<div class="universal-type" id="type-#{escape(definition.fetch(:name))}">
|
|
3923
|
+
<code><span class="helper-name">type #{escape(definition.fetch(:name))}#{generic}</span> = <span class="helper-definition">#{escape(definition.fetch(:definition))}</span></code>
|
|
3924
|
+
<span class="uses">Used by #{definition.fetch(:uses)} inferred receiver#{definition.fetch(:uses) == 1 ? "" : "s"}</span>
|
|
3925
|
+
</div>
|
|
3926
|
+
HTML
|
|
3927
|
+
end.join
|
|
3928
|
+
|
|
3929
|
+
<<~HTML
|
|
3930
|
+
<section class="universal-types" id="universal-types">
|
|
3931
|
+
<h2>Universal capability types</h2>
|
|
3932
|
+
<p class="muted">Canonical structural shapes shared by every analyzed file. Free types are represented as generic parameters.</p>
|
|
3933
|
+
#{definitions}
|
|
3934
|
+
</section>
|
|
3935
|
+
HTML
|
|
3936
|
+
end
|
|
3937
|
+
|
|
3938
|
+
def universal_definitions
|
|
3939
|
+
@helper_registry&.definitions || []
|
|
3940
|
+
end
|
|
3941
|
+
|
|
3942
|
+
def method_card(renderer)
|
|
3943
|
+
report = renderer.as_json
|
|
3944
|
+
helpers = report.fetch(:helpers)
|
|
3945
|
+
constraints = report.fetch(:constraints)
|
|
3946
|
+
helper_html = if helpers.empty?
|
|
3947
|
+
""
|
|
3948
|
+
else
|
|
3949
|
+
rows = helpers.map do |helper|
|
|
3950
|
+
<<~HTML
|
|
3951
|
+
<div class="helper">
|
|
3952
|
+
<a class="helper-name" href="#type-#{escape(helper.fetch(:name))}">#{escape(helper.fetch(:name))}</a>
|
|
3953
|
+
<code class="helper-definition">#{escape(helper.fetch(:reference))}</code>
|
|
3954
|
+
</div>
|
|
3955
|
+
HTML
|
|
3956
|
+
end.join
|
|
3957
|
+
%(<section class="helpers"><h4>Uses universal types</h4>#{rows}</section>)
|
|
3958
|
+
end
|
|
3959
|
+
constraint_html = if constraints.empty?
|
|
3960
|
+
helpers.empty? ? %(<div class="empty">No structural send constraints</div>) : ""
|
|
3961
|
+
else
|
|
3962
|
+
items = constraints.map { |constraint| "<li><code>#{escape(constraint)}</code></li>" }.join
|
|
3963
|
+
%(<ol class="constraints">#{items}</ol>)
|
|
3964
|
+
end
|
|
3965
|
+
helper_text = helpers.flat_map { |helper| helper.values }
|
|
3966
|
+
searchable = ([report.fetch(:name), report.fetch(:signature)] + helper_text + constraints).join(" ").downcase
|
|
3967
|
+
|
|
3968
|
+
<<~HTML
|
|
3969
|
+
<article class="method" data-search="#{escape(searchable)}">
|
|
3970
|
+
<header>
|
|
3971
|
+
<h3>#{escape(report.fetch(:name))} <span class="line">line #{report.fetch(:line)}</span></h3>
|
|
3972
|
+
</header>
|
|
3973
|
+
<code class="signature">#{escape(report.fetch(:signature))}</code>
|
|
3974
|
+
#{helper_html}
|
|
3975
|
+
#{constraint_html}
|
|
3976
|
+
</article>
|
|
3977
|
+
HTML
|
|
3978
|
+
end
|
|
3979
|
+
|
|
3980
|
+
def escape(value)
|
|
3981
|
+
CGI.escapeHTML(value.to_s)
|
|
3982
|
+
end
|
|
3983
|
+
end
|
|
3984
|
+
|
|
3985
|
+
# Deterministic, LLM-free naming. Names are derived from the capability
|
|
3986
|
+
# shape itself: a Ruby-vocabulary table for common messages, snake_case
|
|
3987
|
+
# morphology for the rest, and a detail-escalation ladder that resolves
|
|
3988
|
+
# collisions by folding in more of the shape (all sends, then result types,
|
|
3989
|
+
# then argument types, then a full injective mangle of the definition).
|
|
3990
|
+
class HeuristicCapabilityNamer
|
|
3991
|
+
MAX_LEVEL = 5
|
|
3992
|
+
GREEK_WORDS = /(?:Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta|Iota|Kappa|Lambda|Mu|Nu|Xi|Omicron|Pi|Rho|Sigma|Tau|Upsilon|Phi|Chi|Psi|Omega)(?=[A-Z]|$)/
|
|
3993
|
+
KNOWN = {
|
|
3994
|
+
"to_s" => "Stringifiable", "to_str" => "Stringifiable", "inspect" => "Inspectable",
|
|
3995
|
+
"to_i" => "IntegerConvertible", "to_int" => "IntegerConvertible", "to_f" => "FloatConvertible",
|
|
3996
|
+
"to_h" => "HashConvertible", "to_a" => "ArrayConvertible", "to_sym" => "Symbolizable",
|
|
3997
|
+
"to_json" => "JsonSerializable", "to_set" => "SetConvertible", "to_proc" => "ProcConvertible",
|
|
3998
|
+
"each" => "Enumerable", "each_with_index" => "IndexEnumerable", "each_with_object" => "ObjectEnumerable",
|
|
3999
|
+
"each_pair" => "PairEnumerable", "each_key" => "KeyEnumerable", "each_value" => "ValueEnumerable",
|
|
4000
|
+
"map" => "Mappable", "collect" => "Mappable", "flat_map" => "FlatMappable", "filter_map" => "FilterMappable",
|
|
4001
|
+
"select" => "Selectable", "filter" => "Filterable", "reject" => "Rejectable",
|
|
4002
|
+
"reduce" => "Reducible", "inject" => "Reducible", "sum" => "Summable",
|
|
4003
|
+
"find" => "Findable", "detect" => "Findable", "include?" => "MembershipTestable",
|
|
4004
|
+
"size" => "Sizable", "length" => "Measurable", "count" => "Countable",
|
|
4005
|
+
"call" => "Callable", "new" => "Instantiable", "create" => "Creatable", "build" => "Buildable",
|
|
4006
|
+
"[]" => "Indexable", "[]=" => "IndexWritable", "<<" => "Appendable", "push" => "Pushable",
|
|
4007
|
+
"+" => "Addable", "-" => "Subtractable", "*" => "Multipliable", "/" => "Divisible",
|
|
4008
|
+
"%" => "Modulable", "**" => "Exponentiable",
|
|
4009
|
+
"==" => "Equatable", "!=" => "Equatable", "eql?" => "Equatable", "equal?" => "Equatable",
|
|
4010
|
+
"<=>" => "Comparable", "<" => "Comparable", ">" => "Comparable", "<=" => "Comparable", ">=" => "Comparable",
|
|
4011
|
+
"=~" => "Matchable", "match" => "Matchable", "match?" => "Matchable",
|
|
4012
|
+
"!" => "Negatable", "&" => "Conjoinable", "|" => "Disjoinable", "^" => "ExclusiveDisjoinable",
|
|
4013
|
+
"nil?" => "NilTestable", "empty?" => "EmptinessTestable", "any?" => "AnyTestable", "all?" => "AllTestable",
|
|
4014
|
+
"present?" => "PresenceTestable", "blank?" => "BlankTestable", "presence" => "PresenceReadable",
|
|
4015
|
+
"dup" => "Duplicable", "clone" => "Clonable", "freeze" => "Freezable", "frozen?" => "FrozenTestable",
|
|
4016
|
+
"tap" => "Tappable", "then" => "Chainable", "yield_self" => "Chainable", "itself" => "IdentityReadable",
|
|
4017
|
+
"fetch" => "Fetchable", "dig" => "Diggable", "key?" => "KeyTestable", "has_key?" => "KeyTestable",
|
|
4018
|
+
"keys" => "KeysReadable", "values" => "ValuesReadable", "merge" => "Mergeable", "merge!" => "Mergeable",
|
|
4019
|
+
"join" => "Joinable", "split" => "Splittable", "strip" => "Strippable", "chomp" => "Chompable",
|
|
4020
|
+
"gsub" => "GlobalSubstitutable", "sub" => "Substitutable",
|
|
4021
|
+
"downcase" => "Downcaseable", "upcase" => "Upcaseable", "capitalize" => "Capitalizable",
|
|
4022
|
+
"start_with?" => "PrefixTestable", "end_with?" => "SuffixTestable",
|
|
4023
|
+
"first" => "FirstReadable", "last" => "LastReadable", "min" => "MinReadable", "max" => "MaxReadable",
|
|
4024
|
+
"compact" => "Compactable", "flatten" => "Flattenable", "uniq" => "Uniquable", "sort" => "Sortable",
|
|
4025
|
+
"sort_by" => "KeySortable", "group_by" => "Groupable", "reverse" => "Reversible", "raise" => "Raisable",
|
|
4026
|
+
"emit" => "Emittable", "respond_to?" => "RespondTestable",
|
|
4027
|
+
"send" => "MessageSendable", "public_send" => "MessageSendable"
|
|
4028
|
+
}.freeze
|
|
4029
|
+
|
|
4030
|
+
def initialize(registry)
|
|
4031
|
+
@registry = registry
|
|
4032
|
+
end
|
|
4033
|
+
|
|
4034
|
+
def name!
|
|
4035
|
+
levels = Hash.new(1)
|
|
4036
|
+
names = {}
|
|
4037
|
+
(MAX_LEVEL * 4).times do
|
|
4038
|
+
names = @registry.definitions.to_h do |definition|
|
|
4039
|
+
[definition.fetch(:id), name_for(definition, levels[definition.fetch(:id)])]
|
|
4040
|
+
end
|
|
4041
|
+
clusters = @registry.definitions
|
|
4042
|
+
.group_by { |definition| @registry.normalize_semantic_name(names[definition.fetch(:id)]) }
|
|
4043
|
+
.select { |_, members| members.length > 1 }
|
|
4044
|
+
break if clusters.empty?
|
|
4045
|
+
|
|
4046
|
+
progressed = false
|
|
4047
|
+
clusters.each_value do |members|
|
|
4048
|
+
members.sort_by { |definition| [-definition.fetch(:uses), definition.fetch(:id)] }.drop(1).each do |definition|
|
|
4049
|
+
id = definition.fetch(:id)
|
|
4050
|
+
next unless levels[id] < MAX_LEVEL
|
|
4051
|
+
|
|
4052
|
+
levels[id] += 1
|
|
4053
|
+
progressed = true
|
|
4054
|
+
end
|
|
4055
|
+
end
|
|
4056
|
+
break unless progressed
|
|
4057
|
+
end
|
|
4058
|
+
names = de_greek_names!(names)
|
|
4059
|
+
@registry.apply_names!(names)
|
|
4060
|
+
names
|
|
4061
|
+
end
|
|
4062
|
+
|
|
4063
|
+
private
|
|
4064
|
+
|
|
4065
|
+
# Replace any leftover Alpha/Beta/… fragments with words derived from
|
|
4066
|
+
# the helper's concrete slot types, or drop generic slots entirely.
|
|
4067
|
+
def de_greek_names!(names)
|
|
4068
|
+
@registry.definitions.each_with_object(names.dup) do |definition, result|
|
|
4069
|
+
id = definition.fetch(:id)
|
|
4070
|
+
name = result[id]
|
|
4071
|
+
next unless greek?(name)
|
|
4072
|
+
|
|
4073
|
+
replacement = non_greek_name(definition)
|
|
4074
|
+
result[id] = replacement if replacement && !replacement.empty?
|
|
4075
|
+
end
|
|
4076
|
+
end
|
|
4077
|
+
|
|
4078
|
+
def greek?(name)
|
|
4079
|
+
name.match?(GREEK_WORDS)
|
|
4080
|
+
end
|
|
4081
|
+
|
|
4082
|
+
def non_greek_name(definition)
|
|
4083
|
+
MAX_LEVEL.downto(1) do |level|
|
|
4084
|
+
candidate = name_for(definition, level)
|
|
4085
|
+
return candidate unless greek?(candidate)
|
|
4086
|
+
end
|
|
4087
|
+
|
|
4088
|
+
sends = parse_sends(definition.fetch(:definition))
|
|
4089
|
+
compose(sends)
|
|
4090
|
+
end
|
|
4091
|
+
|
|
4092
|
+
def name_for(definition, level)
|
|
4093
|
+
sends = parse_sends(definition.fetch(:definition))
|
|
4094
|
+
slots = definition.fetch(:slots)
|
|
4095
|
+
case level
|
|
4096
|
+
when 1 then compose(sends.first(2))
|
|
4097
|
+
when 2 then compose(sends)
|
|
4098
|
+
when 3 then "#{type_words(sends.map { |send| send.fetch(:result) }, slots: slots)}#{compose(sends)}"
|
|
4099
|
+
when 4
|
|
4100
|
+
argument_words = type_words(sends.flat_map { |send| send.fetch(:arguments) }, slots: slots)
|
|
4101
|
+
result_words = type_words(sends.map { |send| send.fetch(:result) }, slots: slots)
|
|
4102
|
+
"#{argument_words}#{result_words}#{compose(sends)}"
|
|
4103
|
+
else
|
|
4104
|
+
"#{mangle(definition.fetch(:definition), slots: slots)}Able"
|
|
4105
|
+
end
|
|
4106
|
+
end
|
|
4107
|
+
|
|
4108
|
+
def compose(sends)
|
|
4109
|
+
final = single_name(sends.last)
|
|
4110
|
+
stems = sends[0...-1].map { |send| stem(send.fetch(:message)) }.uniq
|
|
4111
|
+
stems.reject! { |candidate| final.start_with?(candidate) }
|
|
4112
|
+
"#{stems.join}#{final}"
|
|
4113
|
+
end
|
|
4114
|
+
|
|
4115
|
+
def single_name(send)
|
|
4116
|
+
message = send.fetch(:message)
|
|
4117
|
+
return KNOWN.fetch(message) if KNOWN.key?(message)
|
|
4118
|
+
return "#{stem(message)}Testable" if message.end_with?("?")
|
|
4119
|
+
return "#{stem(message)}Writable" if message.end_with?("=")
|
|
4120
|
+
|
|
4121
|
+
tokens = message.delete_suffix("!").split("_")
|
|
4122
|
+
if send.fetch(:arguments).any? || message.end_with?("!")
|
|
4123
|
+
objects = tokens.drop(1).map(&:capitalize).join
|
|
4124
|
+
"#{objects}#{verbify(tokens.first).capitalize}"
|
|
4125
|
+
else
|
|
4126
|
+
"#{stem(message)}Readable"
|
|
4127
|
+
end
|
|
4128
|
+
end
|
|
4129
|
+
|
|
4130
|
+
def stem(message)
|
|
4131
|
+
unless message.match?(/[a-z0-9]/i)
|
|
4132
|
+
known = KNOWN[message]
|
|
4133
|
+
return known ? known.sub(/(?:t?able|ible)\z/, "") : "Op"
|
|
4134
|
+
end
|
|
4135
|
+
|
|
4136
|
+
message.delete_suffix("?").delete_suffix("!").delete_suffix("=").split("_").map(&:capitalize).join
|
|
4137
|
+
end
|
|
4138
|
+
|
|
4139
|
+
def verbify(word)
|
|
4140
|
+
if word.match?(/[^aeiouy]y\z/)
|
|
4141
|
+
"#{word[0..-2]}iable"
|
|
4142
|
+
elsif word.end_with?("e")
|
|
4143
|
+
"#{word[0..-2]}able"
|
|
4144
|
+
elsif word.match?(/[^aeiouy][aeiouy][bdgklmnprtv]\z/)
|
|
4145
|
+
"#{word}#{word[-1]}able"
|
|
4146
|
+
else
|
|
4147
|
+
"#{word}able"
|
|
4148
|
+
end
|
|
4149
|
+
end
|
|
4150
|
+
|
|
4151
|
+
def type_words(types, slots: [])
|
|
4152
|
+
dedupe_adjacent(type_tokens(types.join(", "), slots: slots)).join
|
|
4153
|
+
end
|
|
4154
|
+
|
|
4155
|
+
def dedupe_adjacent(words)
|
|
4156
|
+
words.each_with_object([]) { |word, acc| acc << word unless acc.last == word }
|
|
4157
|
+
end
|
|
4158
|
+
|
|
4159
|
+
def type_tokens(type, slots: [], depth: 0, resolving: nil)
|
|
4160
|
+
return [] if depth > 12
|
|
4161
|
+
|
|
4162
|
+
type.scan(/[A-Za-z0-9_]+|::|->|[|\[\],()]/).flat_map do |token|
|
|
4163
|
+
case token
|
|
4164
|
+
when "|" then ["Or"]
|
|
4165
|
+
when "[" then ["Of"]
|
|
4166
|
+
when "->" then ["To"]
|
|
4167
|
+
when "," then ["And"]
|
|
4168
|
+
when "]", "(", ")", "::" then []
|
|
4169
|
+
when "nil" then ["Nil"]
|
|
4170
|
+
when "noreturn" then ["Never"]
|
|
4171
|
+
when "Object" then ["Object"]
|
|
4172
|
+
when "bool" then ["Bool"]
|
|
4173
|
+
when /\AT(\d+)\z/
|
|
4174
|
+
index = Regexp.last_match(1).to_i
|
|
4175
|
+
next [] if resolving == index
|
|
4176
|
+
|
|
4177
|
+
slot_type_words(index, slots: slots, depth: depth + 1)
|
|
4178
|
+
else
|
|
4179
|
+
[token.split("_").map(&:capitalize).join]
|
|
4180
|
+
end
|
|
4181
|
+
end
|
|
4182
|
+
end
|
|
4183
|
+
|
|
4184
|
+
def slot_type_words(index, slots: [], depth: 0)
|
|
4185
|
+
return [] if depth > 12
|
|
4186
|
+
|
|
4187
|
+
slot = slots[index - 1]
|
|
4188
|
+
return [] if slot.nil? || slot.empty?
|
|
4189
|
+
return [] if slot.match?(/\AT\d+\z/)
|
|
4190
|
+
return [] if slot.match?(/\bT\d+\b/) && !slot.match?(/[A-Za-z_]|::|\[/)
|
|
4191
|
+
|
|
4192
|
+
type_tokens(slot, slots: slots, depth: depth + 1, resolving: index)
|
|
4193
|
+
end
|
|
4194
|
+
|
|
4195
|
+
def mangle(definition, slots: [])
|
|
4196
|
+
type_tokens(definition, slots: slots).join
|
|
4197
|
+
end
|
|
4198
|
+
|
|
4199
|
+
def parse_sends(definition)
|
|
4200
|
+
body = definition.delete_prefix("{ ").delete_suffix(" }")
|
|
4201
|
+
body.split("; ").map { |send| parse_send(send) }
|
|
4202
|
+
end
|
|
4203
|
+
|
|
4204
|
+
def parse_send(send)
|
|
4205
|
+
open = send.index("(")
|
|
4206
|
+
message = send[0...open]
|
|
4207
|
+
depth = 0
|
|
4208
|
+
close = open
|
|
4209
|
+
(open...send.length).each do |index|
|
|
4210
|
+
depth += 1 if send[index] == "("
|
|
4211
|
+
depth -= 1 if send[index] == ")"
|
|
4212
|
+
if depth.zero?
|
|
4213
|
+
close = index
|
|
4214
|
+
break
|
|
4215
|
+
end
|
|
4216
|
+
end
|
|
4217
|
+
{
|
|
4218
|
+
message: message,
|
|
4219
|
+
arguments: split_top_level(send[(open + 1)...close]),
|
|
4220
|
+
result: send[(close + 5)..].to_s
|
|
4221
|
+
}
|
|
4222
|
+
end
|
|
4223
|
+
|
|
4224
|
+
def split_top_level(text)
|
|
4225
|
+
return [] if text.empty?
|
|
4226
|
+
|
|
4227
|
+
parts = []
|
|
4228
|
+
depth = 0
|
|
4229
|
+
current = +""
|
|
4230
|
+
text.each_char do |char|
|
|
4231
|
+
depth += 1 if "([".include?(char)
|
|
4232
|
+
depth -= 1 if ")]".include?(char)
|
|
4233
|
+
if depth.zero? && char == "," && !current.empty?
|
|
4234
|
+
parts << current.strip
|
|
4235
|
+
current = +""
|
|
4236
|
+
else
|
|
4237
|
+
current << char
|
|
4238
|
+
end
|
|
4239
|
+
end
|
|
4240
|
+
parts << current.strip unless current.strip.empty?
|
|
4241
|
+
parts
|
|
4242
|
+
end
|
|
4243
|
+
end
|
|
4244
|
+
|
|
4245
|
+
class OpenAiCapabilityNamer
|
|
4246
|
+
DEFAULT_MODEL = "gpt-5.6-luna"
|
|
4247
|
+
DEFAULT_BATCH_SIZE = 100
|
|
4248
|
+
DEFAULT_CONCURRENCY = 16
|
|
4249
|
+
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
4250
|
+
Name every anonymous structural Ruby capability below.
|
|
4251
|
+
|
|
4252
|
+
Return one concise PascalCase interface name per id using the "-able"
|
|
4253
|
+
framework. Describe what values can do, not their implementation or a
|
|
4254
|
+
nominal class.
|
|
4255
|
+
|
|
4256
|
+
Examples: Stringifiable, Enumerable, KeyFetchable, EventEmittable,
|
|
4257
|
+
CurrentQuestionReadable, CacheClearable, ScoreRankable.
|
|
4258
|
+
|
|
4259
|
+
Rules:
|
|
4260
|
+
- Return every input id exactly once.
|
|
4261
|
+
- Use one to four semantic words.
|
|
4262
|
+
- End with Able, Ible, Ifiable, or Izable.
|
|
4263
|
+
- Prefer established Ruby vocabulary when precise.
|
|
4264
|
+
- Name the combined ability when multiple methods are present.
|
|
4265
|
+
- Never include hashes, ids, generic parameter names, or "Capability".
|
|
4266
|
+
- Return only a JSON object mapping each id to its name.
|
|
4267
|
+
PROMPT
|
|
4268
|
+
|
|
4269
|
+
def initialize(registry, cache_path:, model: DEFAULT_MODEL, batch_size: DEFAULT_BATCH_SIZE,
|
|
4270
|
+
concurrency: DEFAULT_CONCURRENCY, runner: nil)
|
|
4271
|
+
@registry = registry
|
|
4272
|
+
@cache_path = cache_path
|
|
4273
|
+
@model = model
|
|
4274
|
+
@batch_size = batch_size
|
|
4275
|
+
@concurrency = concurrency
|
|
4276
|
+
@runner = runner || method(:run_openai)
|
|
4277
|
+
end
|
|
4278
|
+
|
|
4279
|
+
MAX_COLLISION_ROUNDS = 5
|
|
4280
|
+
CLUSTER_PROMPT = <<~PROMPT.freeze
|
|
4281
|
+
These distinct structural Ruby capability types were all given the name
|
|
4282
|
+
"%<shared_name>s". Rename them so every id gets a unique PascalCase name.
|
|
4283
|
+
|
|
4284
|
+
Rules:
|
|
4285
|
+
- Return every input id exactly once, each with a different name.
|
|
4286
|
+
- Distinguish members using their methods, argument types, and result types.
|
|
4287
|
+
- The most general member may stay close to "%<shared_name>s"; the others
|
|
4288
|
+
must be more specific.
|
|
4289
|
+
- Never use numbers, hashes, or the words "Variant" or "Capability".
|
|
4290
|
+
- Use one to five semantic words ending with Able, Ible, Ifiable, or Izable.
|
|
4291
|
+
- Return only a JSON object mapping each id to its name.
|
|
4292
|
+
PROMPT
|
|
4293
|
+
|
|
4294
|
+
def name!
|
|
4295
|
+
names = load_cache
|
|
4296
|
+
missing = @registry.definitions.reject { |definition| names.key?(definition.fetch(:id)) }
|
|
4297
|
+
batches = missing.each_slice(@batch_size).to_a
|
|
4298
|
+
puts "Naming #{missing.length} capabilities in #{batches.length} parallel batches with #{@model}..." if batches.any?
|
|
4299
|
+
parallel_map(batches) { |batch| name_batch(batch) }.each { |batch_names| names.merge!(batch_names) }
|
|
4300
|
+
File.write(@cache_path, JSON.pretty_generate(names.sort.to_h)) if batches.any?
|
|
4301
|
+
|
|
4302
|
+
resolve_collisions!(names)
|
|
4303
|
+
@registry.apply_names!(names)
|
|
4304
|
+
names
|
|
4305
|
+
end
|
|
4306
|
+
|
|
4307
|
+
private
|
|
4308
|
+
|
|
4309
|
+
def resolve_collisions!(names)
|
|
4310
|
+
MAX_COLLISION_ROUNDS.times do
|
|
4311
|
+
clusters = @registry.collisions(names)
|
|
4312
|
+
break if clusters.empty?
|
|
4313
|
+
|
|
4314
|
+
puts "Renaming #{clusters.sum { |_, definitions| definitions.length }} capabilities across " \
|
|
4315
|
+
"#{clusters.length} collision clusters in parallel with #{@model}..."
|
|
4316
|
+
renames = parallel_map(clusters.to_a) do |shared_name, definitions|
|
|
4317
|
+
rename_cluster(shared_name, definitions)
|
|
4318
|
+
end
|
|
4319
|
+
renames.each { |cluster_names| names.merge!(cluster_names) }
|
|
4320
|
+
File.write(@cache_path, JSON.pretty_generate(names.sort.to_h))
|
|
4321
|
+
end
|
|
4322
|
+
|
|
4323
|
+
leftovers = @registry.collisions(names)
|
|
4324
|
+
return if leftovers.empty?
|
|
4325
|
+
|
|
4326
|
+
warn "Naming model left #{leftovers.length} name collisions after #{MAX_COLLISION_ROUNDS} rounds"
|
|
4327
|
+
end
|
|
4328
|
+
|
|
4329
|
+
# Fiber-based fan-out (this repo never uses threads). Failed calls return
|
|
4330
|
+
# an empty result: unresolved names simply stay missing or colliding and
|
|
4331
|
+
# the next round retries them.
|
|
4332
|
+
def parallel_map(items)
|
|
4333
|
+
return [] if items.empty?
|
|
4334
|
+
|
|
4335
|
+
Sync do |parent|
|
|
4336
|
+
semaphore = Async::Semaphore.new(@concurrency, parent: parent)
|
|
4337
|
+
items.map do |item|
|
|
4338
|
+
semaphore.async do
|
|
4339
|
+
yield item
|
|
4340
|
+
rescue StandardError => error
|
|
4341
|
+
warn "Naming call failed: #{error.message}"
|
|
4342
|
+
{}
|
|
4343
|
+
end
|
|
4344
|
+
end.map(&:wait)
|
|
4345
|
+
end
|
|
4346
|
+
end
|
|
4347
|
+
|
|
4348
|
+
def rename_cluster(shared_name, definitions)
|
|
4349
|
+
payload = definitions.map do |definition|
|
|
4350
|
+
{ id: definition.fetch(:id), capability: definition.fetch(:definition) }
|
|
4351
|
+
end
|
|
4352
|
+
prompt = "#{format(CLUSTER_PROMPT, shared_name: shared_name)}\n\n#{JSON.generate(payload)}"
|
|
4353
|
+
parsed = parse_json_object(@runner.call(prompt, @model))
|
|
4354
|
+
parsed.slice(*definitions.map { |definition| definition.fetch(:id) })
|
|
4355
|
+
end
|
|
4356
|
+
|
|
4357
|
+
def load_cache
|
|
4358
|
+
return {} unless File.exist?(@cache_path)
|
|
4359
|
+
|
|
4360
|
+
JSON.parse(File.read(@cache_path))
|
|
4361
|
+
end
|
|
4362
|
+
|
|
4363
|
+
def name_batch(definitions, retry_count: 0)
|
|
4364
|
+
payload = definitions.map do |definition|
|
|
4365
|
+
{ id: definition.fetch(:id), capability: definition.fetch(:definition) }
|
|
4366
|
+
end
|
|
4367
|
+
output = @runner.call("#{SYSTEM_PROMPT}\n\n#{JSON.generate(payload)}", @model)
|
|
4368
|
+
parsed = parse_json_object(output)
|
|
4369
|
+
expected_ids = definitions.map { |definition| definition.fetch(:id) }
|
|
4370
|
+
missing_ids = expected_ids - parsed.keys
|
|
4371
|
+
extra_ids = parsed.keys - expected_ids
|
|
4372
|
+
puts "Ignoring #{extra_ids.length} unknown capability ids..." unless extra_ids.empty?
|
|
4373
|
+
parsed = parsed.slice(*expected_ids)
|
|
4374
|
+
unless missing_ids.empty?
|
|
4375
|
+
raise "Naming model repeatedly omitted #{missing_ids.length} capabilities" if retry_count >= 2
|
|
4376
|
+
|
|
4377
|
+
puts "Retrying #{missing_ids.length} omitted capabilities..."
|
|
4378
|
+
missing_definitions = definitions.select { |definition| missing_ids.include?(definition.fetch(:id)) }
|
|
4379
|
+
parsed.merge!(name_batch(missing_definitions, retry_count: retry_count + 1))
|
|
4380
|
+
end
|
|
4381
|
+
|
|
4382
|
+
parsed
|
|
4383
|
+
end
|
|
4384
|
+
|
|
4385
|
+
def run_openai(prompt, model)
|
|
4386
|
+
connection = Faraday.new(url: ENV.fetch("OPENAI_URI_BASE", "https://api.openai.com"))
|
|
4387
|
+
response = connection.post("/v1/responses") do |request|
|
|
4388
|
+
request.headers["Authorization"] = "Bearer #{ENV.fetch("OPENAI_ACCESS_TOKEN")}"
|
|
4389
|
+
request.headers["Content-Type"] = "application/json"
|
|
4390
|
+
request.options.timeout = 180
|
|
4391
|
+
request.body = {
|
|
4392
|
+
model: model,
|
|
4393
|
+
input: prompt,
|
|
4394
|
+
max_output_tokens: 16_384,
|
|
4395
|
+
reasoning: { effort: "low" },
|
|
4396
|
+
text: { verbosity: "low" },
|
|
4397
|
+
store: false
|
|
4398
|
+
}.to_json
|
|
4399
|
+
end
|
|
4400
|
+
unless response.success?
|
|
4401
|
+
error = JSON.parse(response.body).dig("error", "message")
|
|
4402
|
+
raise "OpenAI Responses API failed (#{response.status}): #{error}"
|
|
4403
|
+
end
|
|
4404
|
+
|
|
4405
|
+
body = JSON.parse(response.body)
|
|
4406
|
+
output_text = body.fetch("output").filter_map do |item|
|
|
4407
|
+
next unless item["type"] == "message"
|
|
4408
|
+
|
|
4409
|
+
item.fetch("content").filter_map do |content|
|
|
4410
|
+
content["text"] if content["type"] == "output_text"
|
|
4411
|
+
end
|
|
4412
|
+
end.join
|
|
4413
|
+
raise "OpenAI response contained no output text" if output_text.empty?
|
|
4414
|
+
|
|
4415
|
+
output_text
|
|
4416
|
+
end
|
|
4417
|
+
|
|
4418
|
+
def parse_json_object(output)
|
|
4419
|
+
JSON.parse(output)
|
|
4420
|
+
rescue JSON::ParserError
|
|
4421
|
+
object = output[/\{.*\}/m]
|
|
4422
|
+
raise "Naming model did not return a JSON object" unless object
|
|
4423
|
+
|
|
4424
|
+
JSON.parse(object)
|
|
4425
|
+
end
|
|
4426
|
+
end
|
|
4427
|
+
|
|
4428
|
+
class CLI
|
|
4429
|
+
def self.skipped_files
|
|
4430
|
+
Autotype.configuration.skipped_files
|
|
4431
|
+
end
|
|
4432
|
+
|
|
4433
|
+
def self.default_profile
|
|
4434
|
+
Autotype.profile
|
|
4435
|
+
end
|
|
4436
|
+
|
|
4437
|
+
def self.expand_referenced_type_files(files, profile: default_profile)
|
|
4438
|
+
expanded = files.dup
|
|
4439
|
+
referenced = Set.new
|
|
4440
|
+
files.each do |path|
|
|
4441
|
+
next unless File.file?(path)
|
|
4442
|
+
|
|
4443
|
+
result = Prism.parse_file(path)
|
|
4444
|
+
next unless result.success?
|
|
4445
|
+
|
|
4446
|
+
collector = Collector.new(path, profile: profile)
|
|
4447
|
+
result.value.accept(collector)
|
|
4448
|
+
collector.referenced_types.each do |type_name|
|
|
4449
|
+
type_path = profile.locate_type_file(type_name)
|
|
4450
|
+
referenced << type_path if type_path
|
|
4451
|
+
end
|
|
4452
|
+
end
|
|
4453
|
+
(expanded + referenced.to_a).uniq
|
|
4454
|
+
end
|
|
4455
|
+
|
|
4456
|
+
def self.build_metadata(collectors, profile: default_profile)
|
|
4457
|
+
profile.finalize!(collectors) if profile.respond_to?(:finalize!)
|
|
4458
|
+
metadata = InferenceMetadata.new(
|
|
4459
|
+
member_type_hints: profile.member_type_hints,
|
|
4460
|
+
option_type_hints: profile.option_type_hints,
|
|
4461
|
+
side_effect_methods: profile.side_effect_methods,
|
|
4462
|
+
structured_type_prefixes: profile.structured_type_prefixes,
|
|
4463
|
+
port_wiring: profile.port_wiring,
|
|
4464
|
+
output_emit: profile.output_emit,
|
|
4465
|
+
config_hash: profile.config_hash,
|
|
4466
|
+
framework_self_fallbacks: profile.framework_self_fallbacks,
|
|
4467
|
+
type_locators: [profile.method(:locate_type_file)]
|
|
4468
|
+
)
|
|
4469
|
+
collectors.each do |collector|
|
|
4470
|
+
metadata.merge_ports!(collector.declared_ports)
|
|
4471
|
+
metadata.merge_member_types!(collector.declared_member_types)
|
|
4472
|
+
metadata.merge_config_options!(collector.declared_config_options)
|
|
4473
|
+
collector.structured_owners.each { |owner| metadata.structured_owners << owner }
|
|
4474
|
+
collector.referenced_types.each { |type| metadata.referenced_types << type }
|
|
4475
|
+
end
|
|
4476
|
+
metadata
|
|
4477
|
+
end
|
|
4478
|
+
|
|
4479
|
+
def self.run(argv)
|
|
4480
|
+
json = false
|
|
4481
|
+
html_path = nil
|
|
4482
|
+
config_path = ENV["AUTOTYPE_CONFIG"]
|
|
4483
|
+
capability_names_path = nil
|
|
4484
|
+
naming_cache_path = nil
|
|
4485
|
+
naming_model = OpenAiCapabilityNamer::DEFAULT_MODEL
|
|
4486
|
+
naming_batch_size = OpenAiCapabilityNamer::DEFAULT_BATCH_SIZE
|
|
4487
|
+
dump_capabilities_path = nil
|
|
4488
|
+
parser = OptionParser.new do |options|
|
|
4489
|
+
options.banner = "Usage: autotype [OPTIONS] FILE..."
|
|
4490
|
+
options.on("--config PATH", "Path to autotype.yml (default: discover from cwd)") do |path|
|
|
4491
|
+
config_path = path
|
|
4492
|
+
end
|
|
4493
|
+
options.on("--json", "Emit machine-readable JSON") { json = true }
|
|
4494
|
+
options.on("--html PATH", "Write a self-contained HTML typedoc") { |path| html_path = path }
|
|
4495
|
+
options.on("--capability-names PATH", "Load semantic names from a JSON file or directory") do |path|
|
|
4496
|
+
capability_names_path = path
|
|
4497
|
+
end
|
|
4498
|
+
options.on("--name-capabilities PATH", "Call OpenAI Responses API and cache semantic names") do |path|
|
|
4499
|
+
naming_cache_path = path
|
|
4500
|
+
end
|
|
4501
|
+
options.on("--capability-model MODEL", "OpenAI model used for naming (default: gpt-5.6-luna)") do |model|
|
|
4502
|
+
naming_model = model
|
|
4503
|
+
end
|
|
4504
|
+
options.on("--capability-batch-size N", Integer, "Capabilities per model call (default: 100)") do |size|
|
|
4505
|
+
naming_batch_size = size
|
|
4506
|
+
end
|
|
4507
|
+
options.on("--dump-capabilities PATH", "Write universal definitions as JSON Lines") do |path|
|
|
4508
|
+
dump_capabilities_path = path
|
|
4509
|
+
end
|
|
4510
|
+
end
|
|
4511
|
+
files = parser.parse(argv)
|
|
4512
|
+
abort parser.to_s if files.empty?
|
|
4513
|
+
abort "--json and --html are mutually exclusive" if json && html_path
|
|
4514
|
+
|
|
4515
|
+
Autotype.configuration.config_path = config_path if config_path
|
|
4516
|
+
Autotype.configuration.reload_profile!
|
|
4517
|
+
|
|
4518
|
+
files.reject! { |path| skipped_files.include?(path.delete_prefix("./")) }
|
|
4519
|
+
profile = default_profile
|
|
4520
|
+
profile.prepare_search!(files) if profile.respond_to?(:prepare_search!)
|
|
4521
|
+
files = expand_referenced_type_files(files, profile: profile)
|
|
4522
|
+
|
|
4523
|
+
parse_errors = {}
|
|
4524
|
+
constants = {}
|
|
4525
|
+
includes = Hash.new { |hash, key| hash[key] = [] }
|
|
4526
|
+
collectors = []
|
|
4527
|
+
method_reports = files.flat_map do |path|
|
|
4528
|
+
result = Prism.parse_file(path)
|
|
4529
|
+
unless result.success?
|
|
4530
|
+
warn "#{path}: parse failed"
|
|
4531
|
+
result.errors.each { |error| warn " L#{error.location.start_line}: #{error.message}" }
|
|
4532
|
+
parse_errors[path] = result.errors.map { |error| "L#{error.location.start_line}: #{error.message}" }
|
|
4533
|
+
next []
|
|
4534
|
+
end
|
|
4535
|
+
|
|
4536
|
+
collector = Collector.new(path, profile: profile)
|
|
4537
|
+
result.value.accept(collector)
|
|
4538
|
+
collectors << collector
|
|
4539
|
+
constants.merge!(collector.constants)
|
|
4540
|
+
collector.includes.each { |owner, mods| includes[owner].concat(mods) }
|
|
4541
|
+
collector.methods.map { |method| [path, method] }
|
|
4542
|
+
end
|
|
4543
|
+
includes.transform_values! { |mods| mods.uniq }
|
|
4544
|
+
metadata = build_metadata(collectors, profile: profile)
|
|
4545
|
+
inferencer = FixedPointInferencer.new(
|
|
4546
|
+
method_reports.map(&:last),
|
|
4547
|
+
constants: constants,
|
|
4548
|
+
includes: includes,
|
|
4549
|
+
metadata: metadata
|
|
4550
|
+
)
|
|
4551
|
+
analyzed_methods = method_reports.map(&:last)
|
|
4552
|
+
solved_methods = inferencer.run
|
|
4553
|
+
method_reports = method_reports.zip(solved_methods, analyzed_methods).map do |(path, _method), solved_method, analyzed_method|
|
|
4554
|
+
[path, solved_method, analyzed_method]
|
|
4555
|
+
end
|
|
4556
|
+
unless json
|
|
4557
|
+
status = inferencer.converged? ? "converged" : "reached its iteration limit"
|
|
4558
|
+
puts "Type inference #{status} after #{inferencer.iterations} iteration(s)"
|
|
4559
|
+
end
|
|
4560
|
+
helper_registry = UniversalHelperRegistry.new(analyzed_methods)
|
|
4561
|
+
# Deterministic LLM-free names first; the OpenAI flags below override.
|
|
4562
|
+
HeuristicCapabilityNamer.new(helper_registry).name!
|
|
4563
|
+
if naming_cache_path
|
|
4564
|
+
OpenAiCapabilityNamer.new(
|
|
4565
|
+
helper_registry,
|
|
4566
|
+
cache_path: naming_cache_path,
|
|
4567
|
+
model: naming_model,
|
|
4568
|
+
batch_size: naming_batch_size
|
|
4569
|
+
).name!
|
|
4570
|
+
end
|
|
4571
|
+
if dump_capabilities_path
|
|
4572
|
+
lines = helper_registry.definitions.map do |definition|
|
|
4573
|
+
JSON.generate(id: definition.fetch(:id), capability: definition.fetch(:definition))
|
|
4574
|
+
end
|
|
4575
|
+
File.write(dump_capabilities_path, "#{lines.join("\n")}\n")
|
|
4576
|
+
if html_path.nil? && !json
|
|
4577
|
+
puts "Wrote #{dump_capabilities_path} (#{lines.length} universal capability types)"
|
|
4578
|
+
return
|
|
4579
|
+
end
|
|
4580
|
+
end
|
|
4581
|
+
if capability_names_path
|
|
4582
|
+
helper_registry.apply_names!(load_capability_names(capability_names_path))
|
|
4583
|
+
end
|
|
4584
|
+
reports = method_reports.map do |path, method, analyzed_method|
|
|
4585
|
+
[path, Renderer.new(method, analyzed_method: analyzed_method, helper_registry: helper_registry)]
|
|
4586
|
+
end
|
|
4587
|
+
|
|
4588
|
+
if html_path
|
|
4589
|
+
document = HtmlDocument.new(reports, analyzed_files: files, parse_errors: parse_errors)
|
|
4590
|
+
File.write(html_path, document.render)
|
|
4591
|
+
puts "Wrote #{html_path} (#{reports.length} methods across #{reports.map(&:first).uniq.length} files)"
|
|
4592
|
+
elsif json
|
|
4593
|
+
puts JSON.pretty_generate(
|
|
4594
|
+
reports.group_by(&:first).transform_values { |entries| entries.map { |_, renderer| renderer.as_json } }
|
|
4595
|
+
)
|
|
4596
|
+
else
|
|
4597
|
+
reports.group_by(&:first).each do |path, entries|
|
|
4598
|
+
puts path
|
|
4599
|
+
puts "=" * path.length
|
|
4600
|
+
entries.each do |_, renderer|
|
|
4601
|
+
puts renderer.text
|
|
4602
|
+
puts
|
|
4603
|
+
end
|
|
4604
|
+
end
|
|
4605
|
+
end
|
|
4606
|
+
end
|
|
4607
|
+
|
|
4608
|
+
def self.load_capability_names(path)
|
|
4609
|
+
paths = File.directory?(path) ? Dir[File.join(path, "*.json")].sort : [path]
|
|
4610
|
+
paths.each_with_object({}) { |file, names| names.merge!(JSON.parse(File.read(file))) }
|
|
4611
|
+
end
|
|
4612
|
+
end
|
|
4613
|
+
end
|