activerecord-minimizer 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/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +143 -0
- data/lib/activerecord-minimizer.rb +3 -0
- data/lib/ar_minimizer/build.rb +54 -0
- data/lib/ar_minimizer/census.rb +52 -0
- data/lib/ar_minimizer/enums.rb +61 -0
- data/lib/ar_minimizer/index.rb +218 -0
- data/lib/ar_minimizer/loader.rb +43 -0
- data/lib/ar_minimizer/manifest.rb +65 -0
- data/lib/ar_minimizer/profile.rb +62 -0
- data/lib/ar_minimizer/railtie.rb +111 -0
- data/lib/ar_minimizer/rewriter.rb +470 -0
- data/lib/ar_minimizer/slim.rb +258 -0
- data/lib/ar_minimizer/small_records.rb +97 -0
- data/lib/ar_minimizer/version.rb +5 -0
- data/lib/ar_minimizer/views.rb +50 -0
- data/lib/ar_minimizer.rb +34 -0
- metadata +112 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module ArMinimizer
|
|
6
|
+
# Source-to-source compiler. Replaces calls to attribute pattern methods
|
|
7
|
+
# (anything in the Index: name?, name_changed?, saved_change_to_name?, ...)
|
|
8
|
+
# with the proxy ActiveRecord itself would call, guarded so that any other
|
|
9
|
+
# receiver gets the original call:
|
|
10
|
+
#
|
|
11
|
+
# user.name_changed?
|
|
12
|
+
# # becomes
|
|
13
|
+
# (::User.equal?(user.class) || ::Post.equal?(user.class) ? user.attribute_changed?(-"name") : user.name_changed?)
|
|
14
|
+
#
|
|
15
|
+
# listing the models that have the attribute (when there are more than
|
|
16
|
+
# MAX_CLASS_GUARD: `::ActiveRecord::Base === user && user.class._has_attribute?("name")`).
|
|
17
|
+
#
|
|
18
|
+
# Inside an instance method of a model that has the attribute, self is
|
|
19
|
+
# known, so the guard goes away:
|
|
20
|
+
#
|
|
21
|
+
# attribute_changed?(-"name")
|
|
22
|
+
#
|
|
23
|
+
# The guard checks the class, not the instance: generated methods exist for
|
|
24
|
+
# every attribute of the class, loaded by this query or not. Line numbers
|
|
25
|
+
# don't change: each replacement stays on its line, and the marker comment
|
|
26
|
+
# is appended at the end of the file.
|
|
27
|
+
class Rewriter
|
|
28
|
+
MARKER = "# ar_minimizer v#{VERSION} (rewritten)"
|
|
29
|
+
GENERATED_LINE = /^# ar_minimizer v\S+ \(rewritten\)$/
|
|
30
|
+
MAX_CLASS_GUARD = 4
|
|
31
|
+
|
|
32
|
+
Result = Data.define(:source, :sites, :skipped)
|
|
33
|
+
|
|
34
|
+
# marker: false for source that is compiled, not written back (templates).
|
|
35
|
+
# guards: also compile calls whose receiver class isn't known, behind a
|
|
36
|
+
# runtime class check (off by default: each such site costs code for a
|
|
37
|
+
# small gain, and a miss runs the check and the original call).
|
|
38
|
+
def initialize(index:, path: "(source)", marker: true, guards: false)
|
|
39
|
+
@index = index
|
|
40
|
+
@path = path
|
|
41
|
+
@marker = marker
|
|
42
|
+
@guards = guards
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rewrite(source)
|
|
46
|
+
return Result.new(source, 0, []) if source.match?(GENERATED_LINE)
|
|
47
|
+
|
|
48
|
+
parse = Prism.parse(source)
|
|
49
|
+
return Result.new(source, 0, ["#{@path}: does not parse, left untouched"]) unless parse.success?
|
|
50
|
+
|
|
51
|
+
@source = source
|
|
52
|
+
@sites = 0
|
|
53
|
+
@temps = 0
|
|
54
|
+
@skipped = []
|
|
55
|
+
@nesting = []
|
|
56
|
+
@nesting_kinds = []
|
|
57
|
+
@self_kinds = [:unknown]
|
|
58
|
+
@scopes = [scan_locals(parse.value.statements)]
|
|
59
|
+
@block_types = nil
|
|
60
|
+
body = splice(parse.value, 0, source.bytesize)
|
|
61
|
+
return Result.new(source, 0, @skipped) if @sites.zero?
|
|
62
|
+
|
|
63
|
+
rewritten = @marker ? append_marker(body, parse) : body
|
|
64
|
+
# Never hand back code that would break the app on boot.
|
|
65
|
+
unless Prism.parse(rewritten).success?
|
|
66
|
+
return Result.new(source, 0, @skipped + ["#{@path}: rewritten source does not parse, left untouched"])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Result.new(rewritten, @sites, @skipped)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# --- traversal -----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# The node's source with every compilable call inside it replaced.
|
|
77
|
+
def render(node)
|
|
78
|
+
case node
|
|
79
|
+
when Prism::DefinedNode
|
|
80
|
+
# defined?(user.name?) is "method", defined?((... ? ... : ...)) is "expression".
|
|
81
|
+
text(node)
|
|
82
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
83
|
+
@nesting.push(constant_name(node.constant_path))
|
|
84
|
+
@nesting_kinds.push(node.is_a?(Prism::ClassNode) ? :class : :module)
|
|
85
|
+
scoped(scan_locals(node.body)) { within(:unknown) { splice(node) } }.tap { @nesting.pop; @nesting_kinds.pop }
|
|
86
|
+
when Prism::SingletonClassNode
|
|
87
|
+
scoped(scan_locals(node.body)) { within(:unknown) { splice(node) } }
|
|
88
|
+
when Prism::DefNode
|
|
89
|
+
scoped(scan_locals(node.body, untyped: parameter_names(node.parameters))) do
|
|
90
|
+
within(node.receiver ? :unknown : :instance) { splice(node) }
|
|
91
|
+
end
|
|
92
|
+
when Prism::BlockNode, Prism::LambdaNode
|
|
93
|
+
typed, @block_types = @block_types, nil
|
|
94
|
+
params = parameter_names(node.parameters.respond_to?(:parameters) ? node.parameters.parameters : nil)
|
|
95
|
+
# instance_exec/instance_eval DSLs can change self inside a block.
|
|
96
|
+
typed ||= {}
|
|
97
|
+
scoped(scan_locals(node.body, untyped: params - typed.keys, typed: typed)) { within(:unknown) { splice(node) } }
|
|
98
|
+
when Prism::CallNode
|
|
99
|
+
if (entry = compilable(node))
|
|
100
|
+
compile(node, entry)
|
|
101
|
+
else
|
|
102
|
+
@block_types = iteration_types(node) if node.block.is_a?(Prism::BlockNode)
|
|
103
|
+
splice(node)
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
splice(node)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def splice(node, from = node.location.start_offset, to = node.location.end_offset)
|
|
111
|
+
out = +""
|
|
112
|
+
cursor = from
|
|
113
|
+
node.compact_child_nodes.sort_by { it.location.start_offset }.each do |child|
|
|
114
|
+
start = child.location.start_offset
|
|
115
|
+
stop = child.location.end_offset
|
|
116
|
+
# Heredoc bodies live outside their node's range; leave them as they are.
|
|
117
|
+
next if start < cursor || stop > to
|
|
118
|
+
|
|
119
|
+
out << @source.byteslice(cursor, start - cursor) << render(child)
|
|
120
|
+
cursor = stop
|
|
121
|
+
end
|
|
122
|
+
out << @source.byteslice(cursor, to - cursor)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def within(kind)
|
|
126
|
+
@self_kinds.push(kind)
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
@self_kinds.pop
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def scoped(locals)
|
|
133
|
+
@scopes.push(locals)
|
|
134
|
+
yield
|
|
135
|
+
ensure
|
|
136
|
+
@scopes.pop
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def text(node) = @source.byteslice(node.location.start_offset, node.location.length)
|
|
140
|
+
|
|
141
|
+
# --- matching ------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def compilable(node)
|
|
144
|
+
entry = @index[node.name]
|
|
145
|
+
return unless entry
|
|
146
|
+
return if node.block # a block (or &blk) changes what the call means
|
|
147
|
+
return if node.arguments && !entry.args
|
|
148
|
+
return if node.receiver && !node.call_operator # `Foo::bar` style
|
|
149
|
+
return if heredoc?(node)
|
|
150
|
+
|
|
151
|
+
entry
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def heredoc?(node)
|
|
155
|
+
return true if node.respond_to?(:opening_loc) && node.opening_loc && node.opening_loc.slice.start_with?("<<")
|
|
156
|
+
|
|
157
|
+
node.compact_child_nodes.any? { heredoc?(it) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- code generation -----------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def compile(node, entry)
|
|
163
|
+
@sites += 1
|
|
164
|
+
args = node.arguments && splice(node.arguments)
|
|
165
|
+
# -"name": a deduplicated frozen string, no allocation per call even
|
|
166
|
+
# without the frozen_string_literal comment (generated methods don't allocate either).
|
|
167
|
+
proxy_args = ["-#{entry.attr.inspect}", args].compact.join(", ")
|
|
168
|
+
original = "#{node.name}#{"(#{args})" if args}"
|
|
169
|
+
|
|
170
|
+
receiver = node.receiver
|
|
171
|
+
if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
172
|
+
dot = receiver ? "self." : ""
|
|
173
|
+
# Private proxies are callable on self, implicit or explicit.
|
|
174
|
+
call = query_on("self", entry) || "#{dot}#{entry.public_proxy || entry.proxy}(#{proxy_args})"
|
|
175
|
+
case self_type(entry)
|
|
176
|
+
when :known then return call # self is a record of a model with the attribute: no guard
|
|
177
|
+
when :not_a_model then return as_written(node) # never the attribute method
|
|
178
|
+
end
|
|
179
|
+
return as_written(node) unless @guards
|
|
180
|
+
|
|
181
|
+
return "(#{guard(entry, "self", "self")} ? #{call} : #{dot}#{original})"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
type = expr_type(receiver)
|
|
185
|
+
if type&.kind == :record
|
|
186
|
+
case coverage(entry, with_descendants(type.model))
|
|
187
|
+
when :known then return typed_call(node, entry, type, proxy_args, original)
|
|
188
|
+
when :not_a_model then return as_written(node) # a record of a model without it: something else
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
return as_written(node) unless @guards
|
|
192
|
+
|
|
193
|
+
first, again = receiver_refs(receiver)
|
|
194
|
+
fast = fast_call(again, entry, proxy_args)
|
|
195
|
+
body = "#{guard(entry, first, again)} ? #{fast} : #{again}.#{original}"
|
|
196
|
+
if node.safe_navigation?
|
|
197
|
+
"(nil == #{first} ? nil : #{body.sub(first, again)})"
|
|
198
|
+
else
|
|
199
|
+
"(#{body})"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def fast_call(ref, entry, proxy_args)
|
|
204
|
+
query_on(ref, entry) ||
|
|
205
|
+
(entry.public_proxy ? "#{ref}.#{entry.public_proxy}(#{proxy_args})" : "#{ref}.__send__(:#{entry.proxy}, #{proxy_args})")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The call as written (nested rewrites inside it still apply).
|
|
209
|
+
def as_written(node)
|
|
210
|
+
@sites -= 1
|
|
211
|
+
splice(node)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A receiver of known model: no class check. Never nil: the call alone.
|
|
215
|
+
# Maybe nil: nil takes the original call, which raises (or, with &.,
|
|
216
|
+
# returns nil) exactly as before.
|
|
217
|
+
def typed_call(node, entry, type, proxy_args, original)
|
|
218
|
+
first, again = receiver_refs(node.receiver)
|
|
219
|
+
fast = fast_call(again, entry, proxy_args)
|
|
220
|
+
if type.nilable || node.safe_navigation?
|
|
221
|
+
fallback = node.safe_navigation? ? "nil" : "#{again}.#{original}"
|
|
222
|
+
"(nil == #{first} ? #{fallback} : #{fast})"
|
|
223
|
+
elsif first == again
|
|
224
|
+
fast
|
|
225
|
+
else
|
|
226
|
+
"(#{first}; #{fast})"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# name? straight from the reader, when the column type allows (Index::QUERY).
|
|
231
|
+
# Parenthesized: `age?.to_s` and `!age?` must still apply to the whole thing.
|
|
232
|
+
def query_on(receiver, entry)
|
|
233
|
+
reader = "#{receiver}.#{entry.attr}"
|
|
234
|
+
case entry.query
|
|
235
|
+
when :truthy then "(!!#{reader})"
|
|
236
|
+
when :numeric then "((#{reader} || 0) != 0)"
|
|
237
|
+
when :string then "(!#{reader}.blank?)"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Few models with the attribute: compare classes (pointer checks, inline
|
|
242
|
+
# cached constants). Subclasses and models the build didn't see take the
|
|
243
|
+
# original call. Many models: ask the class.
|
|
244
|
+
def guard(entry, first, again)
|
|
245
|
+
if entry.classes.size.between?(1, MAX_CLASS_GUARD)
|
|
246
|
+
entry.classes.each_with_index.map { |name, i| "::#{name}.equal?(#{i.zero? ? first : again}.class)" }.join(" || ")
|
|
247
|
+
else
|
|
248
|
+
"::ActiveRecord::Base === #{first} && #{again}.class._has_attribute?(#{entry.attr.inspect})"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# How to refer to the receiver the first time and afterwards. Plain
|
|
253
|
+
# variables are repeated; anything else is evaluated once into a temp.
|
|
254
|
+
def receiver_refs(receiver)
|
|
255
|
+
case receiver
|
|
256
|
+
when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode
|
|
257
|
+
[text(receiver), text(receiver)]
|
|
258
|
+
else
|
|
259
|
+
temp = "__arm#{@temps += 1}"
|
|
260
|
+
["(#{temp} = #{render(receiver)})", temp]
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# What self is in this instance method, from the class or module around it:
|
|
265
|
+
# :known a model with the attribute (and its subclasses), or an app
|
|
266
|
+
# concern whose including models all have it: no guard;
|
|
267
|
+
# :not_a_model a class that isn't a model: the call is never the
|
|
268
|
+
# attribute method, so it stays as written;
|
|
269
|
+
# nil can't tell (modules not known to be concerns,
|
|
270
|
+
# `class Foo::Bar` inside a namespace): guard.
|
|
271
|
+
# From the index alone, without loading constants: the Loader runs while
|
|
272
|
+
# files are being required, models included.
|
|
273
|
+
def self_type(entry)
|
|
274
|
+
return unless @self_kinds.last == :instance
|
|
275
|
+
return if @nesting.empty? || @nesting.any?(&:nil?)
|
|
276
|
+
return if @nesting.size > 1 && @nesting.any? { it.include?("::") }
|
|
277
|
+
|
|
278
|
+
name = @nesting.join("::").delete_prefix("::")
|
|
279
|
+
if model?(name) || entry.classes.include?(name)
|
|
280
|
+
coverage(entry, with_descendants(name))
|
|
281
|
+
elsif app_module?(name)
|
|
282
|
+
includers = @index.includers_of(name)
|
|
283
|
+
coverage(entry, includers) if includers.any?
|
|
284
|
+
elsif @nesting_kinds.last == :class && @index.respond_to?(:model?) # only a full index knows every model
|
|
285
|
+
:not_a_model
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# :known when every possible class has the attribute method, :not_a_model
|
|
290
|
+
# when none does (the call is something else: an enum predicate, a
|
|
291
|
+
# hand-written method), nil when some do.
|
|
292
|
+
def coverage(entry, classes)
|
|
293
|
+
covered = classes.count { entry.classes.include?(it) }
|
|
294
|
+
if covered == classes.size then :known
|
|
295
|
+
elsif covered.zero? then :not_a_model
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The index may be a plain Hash (tests): no model knowledge then.
|
|
300
|
+
def model?(name) = @index.respond_to?(:model?) && @index.model?(name)
|
|
301
|
+
def app_module?(name) = @index.respond_to?(:app_module?) && @index.app_module?(name)
|
|
302
|
+
def with_descendants(name) = @index.respond_to?(:with_descendants) ? @index.with_descendants(name) : [name]
|
|
303
|
+
def covers?(entry, classes) = classes.all? { entry.classes.include?(it) }
|
|
304
|
+
|
|
305
|
+
# --- types ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
# What an expression is, when provable from the source and the index:
|
|
308
|
+
# a model's class, a relation of its records, or one record (maybe nil).
|
|
309
|
+
Type = Data.define(:model, :nilable, :kind)
|
|
310
|
+
|
|
311
|
+
# Calls on a model or relation that return one of its records, never nil.
|
|
312
|
+
RECORD_METHODS = %i[find find_by! first! last! take! sole find_sole_by].freeze
|
|
313
|
+
# ... or a record or nil.
|
|
314
|
+
MAYBE_RECORD_METHODS = %i[find_by first last take].freeze
|
|
315
|
+
# Relation methods whose result is still a relation of the same model
|
|
316
|
+
# (friendly: friendly_id's finder relation).
|
|
317
|
+
RELATION_METHODS = %i[all where not order reorder includes preload eager_load joins left_joins left_outer_joins
|
|
318
|
+
limit offset distinct readonly references unscope rewhere none friendly].freeze
|
|
319
|
+
# Iterations whose block gets the relation's records as first parameter.
|
|
320
|
+
ITERATIONS = %i[each find_each map flat_map filter_map each_with_index each_with_object select reject sort_by
|
|
321
|
+
group_by index_by partition min_by max_by sum find detect any? all? none? count].freeze
|
|
322
|
+
|
|
323
|
+
def expr_type(node)
|
|
324
|
+
case node
|
|
325
|
+
when Prism::LocalVariableReadNode then local_type(node)
|
|
326
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
327
|
+
(model = model_constant(node)) && Type.new(model, false, :class)
|
|
328
|
+
when Prism::ParenthesesNode
|
|
329
|
+
body = node.body
|
|
330
|
+
expr_type(body.body.last) if body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
331
|
+
when Prism::SelfNode then self_record_type
|
|
332
|
+
when Prism::CallNode
|
|
333
|
+
return if node.block || node.safe_navigation?
|
|
334
|
+
|
|
335
|
+
receiver_type = node.receiver ? expr_type(node.receiver) : self_record_type
|
|
336
|
+
receiver_type && call_type(receiver_type, node)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def call_type(receiver, node)
|
|
341
|
+
args = node.arguments&.arguments || []
|
|
342
|
+
model = receiver.model
|
|
343
|
+
case receiver.kind
|
|
344
|
+
when :class, :relation
|
|
345
|
+
if RELATION_METHODS.include?(node.name) || scope?(model, node.name)
|
|
346
|
+
Type.new(model, false, :relation)
|
|
347
|
+
elsif RECORD_METHODS.include?(node.name)
|
|
348
|
+
Type.new(model, false, :record) if record_arguments?(node.name, args)
|
|
349
|
+
elsif %i[new create create!].include?(node.name) && receiver.kind == :class
|
|
350
|
+
# an Array of attributes would return an Array
|
|
351
|
+
Type.new(model, false, :record) if args.empty? || (args.size == 1 && (args[0].is_a?(Prism::KeywordHashNode) || args[0].is_a?(Prism::HashNode)))
|
|
352
|
+
elsif MAYBE_RECORD_METHODS.include?(node.name)
|
|
353
|
+
Type.new(model, true, :record) if node.name == :find_by || args.empty? # first(2) is an Array
|
|
354
|
+
end
|
|
355
|
+
when :record
|
|
356
|
+
return if receiver.nilable || !args.empty?
|
|
357
|
+
|
|
358
|
+
target, many = association(model, node.name)
|
|
359
|
+
return unless target
|
|
360
|
+
|
|
361
|
+
many == "many" ? Type.new(target, false, :relation) : Type.new(target, true, :record)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def record_arguments?(name, args)
|
|
366
|
+
case name
|
|
367
|
+
when :find # find([1, 2]) and find(1, 2) return Arrays
|
|
368
|
+
args.size == 1 && [Prism::ArrayNode, Prism::SplatNode, Prism::RangeNode].none? { args[0].is_a?(it) }
|
|
369
|
+
when :first!, :last!, :take! then args.empty?
|
|
370
|
+
else true
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# self inside an instance method of one model (not a concern: several models).
|
|
375
|
+
def self_record_type
|
|
376
|
+
return unless @self_kinds.last == :instance && @nesting_kinds.last == :class
|
|
377
|
+
return if @nesting.empty? || @nesting.any?(&:nil?) || (@nesting.size > 1 && @nesting.any? { it.include?("::") })
|
|
378
|
+
|
|
379
|
+
name = @nesting.join("::").delete_prefix("::")
|
|
380
|
+
Type.new(name, false, :record) if model?(name)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def scope?(model, name) = @index.respond_to?(:scope?) && @index.scope?(model, name)
|
|
384
|
+
def association(model, name) = @index.respond_to?(:association) ? @index.association(model, name) : nil
|
|
385
|
+
|
|
386
|
+
def local_type(node)
|
|
387
|
+
scope = @scopes[-1 - node.depth]
|
|
388
|
+
scope && scope[node.name]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# { name => Type } for this scope's locals whose every assignment has
|
|
392
|
+
# the same provable type; untyped: names that start unknown
|
|
393
|
+
# (parameters); typed: block parameters known from the iteration.
|
|
394
|
+
def scan_locals(body, untyped: [], typed: {})
|
|
395
|
+
writes = Hash.new { |h, k| h[k] = [] }
|
|
396
|
+
untyped.each { writes[it] << nil }
|
|
397
|
+
typed.each { |name, type| writes[name] << type }
|
|
398
|
+
collect_writes(body, 0, writes) if body
|
|
399
|
+
writes.filter_map { |name, types| [name, types.first] if types.first&.kind == :record && types.uniq.size == 1 }.to_h
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def collect_writes(node, depth, writes)
|
|
403
|
+
case node
|
|
404
|
+
when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
|
|
405
|
+
return # their own scopes
|
|
406
|
+
when Prism::BlockNode, Prism::LambdaNode
|
|
407
|
+
depth += 1
|
|
408
|
+
when Prism::LocalVariableWriteNode
|
|
409
|
+
writes[node.name] << expr_type(node.value) if node.depth == depth
|
|
410
|
+
when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
|
|
411
|
+
Prism::LocalVariableTargetNode
|
|
412
|
+
writes[node.name] << nil if node.depth == depth
|
|
413
|
+
end
|
|
414
|
+
node.compact_child_nodes.each { collect_writes(it, depth, writes) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def parameter_names(params)
|
|
418
|
+
return [] unless params
|
|
419
|
+
|
|
420
|
+
[*params.requireds, *params.optionals, params.rest, *params.posts, *params.keywords, params.keyword_rest, params.block]
|
|
421
|
+
.compact.filter_map { it.respond_to?(:name) ? it.name : nil }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# { block parameter => record Type } for <relation>.each { |record| }.
|
|
425
|
+
def iteration_types(node)
|
|
426
|
+
return unless ITERATIONS.include?(node.name)
|
|
427
|
+
|
|
428
|
+
params = node.block.parameters
|
|
429
|
+
first = params.respond_to?(:parameters) && params.parameters&.requireds&.first
|
|
430
|
+
return unless first.is_a?(Prism::RequiredParameterNode)
|
|
431
|
+
|
|
432
|
+
type = node.receiver && expr_type(node.receiver)
|
|
433
|
+
{ first.name => Type.new(type.model, false, :record) } if type && %i[class relation].include?(type.kind)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# A model, by its constant as written. Unqualified names inside a namespace
|
|
437
|
+
# could mean Namespace::Name, so they count only when no such model or
|
|
438
|
+
# concern exists.
|
|
439
|
+
def model_constant(node)
|
|
440
|
+
return unless (name = constant_name(node))
|
|
441
|
+
|
|
442
|
+
name = name.delete_prefix("::")
|
|
443
|
+
return unless model?(name)
|
|
444
|
+
unless node.is_a?(Prism::ConstantPathNode) && node.parent.nil? # ::Name is unambiguous
|
|
445
|
+
prefixes = @nesting.compact
|
|
446
|
+
return if (1..prefixes.size).any? { |n| (other = "#{prefixes.first(n).join("::")}::#{name}") != name && (model?(other) || app_module?(other)) }
|
|
447
|
+
end
|
|
448
|
+
name
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def constant_name(path)
|
|
452
|
+
case path
|
|
453
|
+
when Prism::ConstantReadNode then path.name.to_s
|
|
454
|
+
when Prism::ConstantPathNode
|
|
455
|
+
parent = path.parent ? constant_name(path.parent) : ""
|
|
456
|
+
parent && "#{parent}::#{path.name}"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def append_marker(body, parse)
|
|
461
|
+
if (data = parse.data_loc)
|
|
462
|
+
head = body.byteslice(0, body.bytesize - (@source.bytesize - data.start_offset))
|
|
463
|
+
tail = @source.byteslice(data.start_offset, @source.bytesize - data.start_offset)
|
|
464
|
+
"#{head}#{"\n" unless head.end_with?("\n")}#{MARKER}\n#{tail}"
|
|
465
|
+
else
|
|
466
|
+
"#{body}#{"\n" unless body.end_with?("\n")}#{MARKER}\n"
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|