ryac 0.2.0 → 0.3.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 +4 -4
- data/README.md +70 -16
- data/Steepfile +2 -0
- data/bin/ryac +41 -2
- data/lib/ryac/analysis/constant/collection.rb +49 -0
- data/lib/ryac/analysis/constant/rename_mapping.rb +15 -1
- data/lib/ryac/analysis/ivar/collection.rb +28 -2
- data/lib/ryac/analysis/lazy_regions.rb +87 -0
- data/lib/ryac/analysis/local_scopes.rb +110 -12
- data/lib/ryac/analysis/method/collection.rb +100 -3
- data/lib/ryac/analysis/method/rename_mapping.rb +17 -12
- data/lib/ryac/analysis/type_oracle.rb +7 -2
- data/lib/ryac/driver_file.rb +47 -0
- data/lib/ryac/minifier.rb +27 -26
- data/lib/ryac/packer.rb +127 -0
- data/lib/ryac/pipeline/analyzer.rb +25 -3
- data/lib/ryac/pipeline/concatenator.rb +127 -8
- data/lib/ryac/pipeline/data_types.rb +13 -5
- data/lib/ryac/pipeline/file_collector.rb +64 -4
- data/lib/ryac/pipeline/method_renamer.rb +15 -0
- data/lib/ryac/pipeline/stage.rb +4 -0
- data/lib/ryac/pipeline/stage_runner.rb +7 -3
- data/lib/ryac/version.rb +1 -1
- data/lib/ryac.rb +3 -0
- data/sig/ryac/analysis/constant/collection.rbs +3 -0
- data/sig/ryac/analysis/constant/rename_mapping.rbs +2 -1
- data/sig/ryac/analysis/ivar/collection.rbs +1 -0
- data/sig/ryac/analysis/lazy_regions.rbs +10 -0
- data/sig/ryac/analysis/local_scopes.rbs +9 -1
- data/sig/ryac/analysis/method/collection.rbs +3 -0
- data/sig/ryac/analysis/method/rename_mapping.rbs +3 -2
- data/sig/ryac/analysis/type_oracle.rbs +1 -1
- data/sig/ryac/driver_file.rbs +8 -0
- data/sig/ryac/minifier.rbs +1 -2
- data/sig/ryac/packer.rbs +12 -0
- data/sig/ryac/pipeline/analyzer.rbs +6 -0
- data/sig/ryac/pipeline/concatenator.rbs +16 -1
- data/sig/ryac/pipeline/data_types.rbs +8 -5
- data/sig/ryac/pipeline/file_collector.rbs +8 -3
- data/sig/ryac/pipeline/method_renamer.rbs +3 -0
- data/sig/ryac/pipeline/stage.rbs +1 -0
- data/sig/ryac/pipeline/stage_runner.rbs +2 -1
- metadata +15 -8
- data/bin/console +0 -11
- data/bin/setup +0 -8
|
@@ -42,6 +42,8 @@ module Ryac
|
|
|
42
42
|
@program = program_node
|
|
43
43
|
@scopes = []
|
|
44
44
|
@scope_by_node = {}
|
|
45
|
+
@children = {}
|
|
46
|
+
@constraints = {}
|
|
45
47
|
build(program_node)
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -86,6 +88,26 @@ module Ryac
|
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# {scope_id => post-rename local names visible inside that scope}: its own
|
|
92
|
+
# locals plus those of enclosing scopes, looking through block and lambda
|
|
93
|
+
# boundaries the way variable lookup does. Locals a scope keeps (never
|
|
94
|
+
# renamed, or already short) count the same as renamed ones — a bare call
|
|
95
|
+
# whose new name matches any visible local parses as that local, not the
|
|
96
|
+
# method, so collision guards must see the full set.
|
|
97
|
+
def visible_local_names
|
|
98
|
+
@scopes.to_h do |scope|
|
|
99
|
+
names = Set.new #: Set[String]
|
|
100
|
+
current = scope #: Scope?
|
|
101
|
+
while current
|
|
102
|
+
mapping = current.mapping || {}
|
|
103
|
+
current.node.locals.each { |var| names << (mapping[var] || var.to_s) }
|
|
104
|
+
break unless %i[block lambda].include?(current.kind)
|
|
105
|
+
current = current.parent
|
|
106
|
+
end
|
|
107
|
+
[scope.id, names]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
89
111
|
# Phase 3 outputs.
|
|
90
112
|
attr_reader :rename_entries, :def_param_names, :block_param_names, :for_index_names
|
|
91
113
|
|
|
@@ -112,6 +134,7 @@ module Ryac
|
|
|
112
134
|
parent: parent, owner_call: owner_call, mapping: nil)
|
|
113
135
|
@scopes << scope
|
|
114
136
|
@scope_by_node[node.object_id] = scope
|
|
137
|
+
(@children[parent.object_id] ||= []) << scope if parent
|
|
115
138
|
scope
|
|
116
139
|
end
|
|
117
140
|
|
|
@@ -167,9 +190,8 @@ module Ryac
|
|
|
167
190
|
locals = scope.node.locals
|
|
168
191
|
return unless locals.any?
|
|
169
192
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
generator = NameGenerator.new(pinned.map(&:to_s))
|
|
193
|
+
unsafe, pinned = constraints_for(scope)
|
|
194
|
+
generator = NameGenerator.new(pinned.map(&:to_s) + reserved_names(scope))
|
|
173
195
|
mapping = {} #: Hash[Symbol, String]
|
|
174
196
|
locals.each do |var|
|
|
175
197
|
mapping[var] = allocated_name(var, unsafe, pinned, generator)
|
|
@@ -182,7 +204,7 @@ module Ryac
|
|
|
182
204
|
node = scope.node #: Prism::DefNode
|
|
183
205
|
return unless node.body
|
|
184
206
|
|
|
185
|
-
unsafe, pinned =
|
|
207
|
+
unsafe, pinned = constraints_for(scope)
|
|
186
208
|
keyword_params = keyword_param_names(node)
|
|
187
209
|
unused_rescue = unused_rescue_vars(node.body)
|
|
188
210
|
kw_mapping = kw_def_map[scope.id]
|
|
@@ -193,10 +215,16 @@ module Ryac
|
|
|
193
215
|
# point: re-minifying the output (where the hinting call already writes
|
|
194
216
|
# short keywords, so no hint arises) would allocate the burned names.
|
|
195
217
|
hints = hints.reject { |var, _| keyword_params.include?(var) }
|
|
218
|
+
# An unused rescue variable is left as written, and so is every name
|
|
219
|
+
# reserved_names lists; a hint or an allocation landing on one of them
|
|
220
|
+
# would bind two variables to one name.
|
|
221
|
+
kept = unused_rescue.map(&:to_s) + reserved_names(scope)
|
|
222
|
+
hints = hints.reject { |_, name| kept.include?(name) }
|
|
196
223
|
|
|
197
224
|
reserved = hints.values.dup
|
|
198
225
|
reserved.concat(kw_mapping.values) if kw_mapping
|
|
199
226
|
reserved.concat(pinned.map(&:to_s))
|
|
227
|
+
reserved.concat(kept)
|
|
200
228
|
keyword_names = {} #: Hash[Symbol, String]
|
|
201
229
|
keyword_params.each do |kw|
|
|
202
230
|
keyword_names[kw] = kw_mapping&.[](kw) || kw.to_s
|
|
@@ -230,9 +258,9 @@ module Ryac
|
|
|
230
258
|
node = scope.node #: Prism::BlockNode
|
|
231
259
|
return unless node.body
|
|
232
260
|
|
|
233
|
-
unsafe, pinned =
|
|
261
|
+
unsafe, pinned = constraints_for(scope)
|
|
234
262
|
f_args = block_formal_names(node)
|
|
235
|
-
parent_names =
|
|
263
|
+
parent_names = ancestor_visible_names(scope) + reserved_names(scope)
|
|
236
264
|
reserved = parent_names + pinned.map(&:to_s)
|
|
237
265
|
|
|
238
266
|
if !unsafe && use_numbered_params?(node, f_args, parent_names)
|
|
@@ -280,22 +308,92 @@ module Ryac
|
|
|
280
308
|
scope.mapping = mapping
|
|
281
309
|
end
|
|
282
310
|
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
|
|
311
|
+
# The names a scope's allocation must not hand out on account of what
|
|
312
|
+
# the scope itself and the blocks and lambdas nested in it keep as
|
|
313
|
+
# written. A block's body locals and a lambda's names are never renamed,
|
|
314
|
+
# and Ruby resolves them by spelling: an outer name allocated onto one
|
|
315
|
+
# of them turns the inner assignment into a write to the outer variable
|
|
316
|
+
# (optcarrot's palette — `|rf, gf, bf|` renamed to `|a, b, c|` over an
|
|
317
|
+
# inner `b = ...`). A block adds ancestor_visible_names on top.
|
|
318
|
+
def reserved_names(scope)
|
|
319
|
+
kept_local_names(scope) + descendant_kept_names(scope)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# What a block sees from outside: every allocated ancestor's names, all
|
|
323
|
+
# the way to the top — visibility actually ends at the first def
|
|
324
|
+
# boundary, so anything beyond it is over-reservation, but the
|
|
325
|
+
# allocation order downstream is built around this exact set, and
|
|
326
|
+
# narrowing it reshuffles names without making any of them safer — plus
|
|
327
|
+
# the kept locals of the blocks and lambdas up to that boundary. A
|
|
328
|
+
# parameter allocated onto one of those would shadow what its body
|
|
329
|
+
# still reads.
|
|
330
|
+
def ancestor_visible_names(scope)
|
|
289
331
|
names = [] #: Array[String]
|
|
332
|
+
visible = true
|
|
290
333
|
current = scope.parent
|
|
291
334
|
while current
|
|
292
335
|
# allocated? guarantees mapping is non-nil here.
|
|
293
336
|
names.concat(current.mapping.values) if current.allocated? # steep:ignore NoMethod
|
|
337
|
+
names.concat(kept_local_names(current)) if visible
|
|
338
|
+
visible &&= %i[block lambda].include?(current.kind)
|
|
294
339
|
current = current.parent
|
|
295
340
|
end
|
|
296
341
|
names
|
|
297
342
|
end
|
|
298
343
|
|
|
344
|
+
# Kept names of every block and lambda nested in the scope, as far as
|
|
345
|
+
# variable lookup reaches: a def or class boundary ends the descent.
|
|
346
|
+
def descendant_kept_names(scope)
|
|
347
|
+
names = [] #: Array[String]
|
|
348
|
+
stack = (@children[scope.object_id] || []).dup
|
|
349
|
+
until stack.empty?
|
|
350
|
+
child = stack.pop #: Scope
|
|
351
|
+
next unless %i[block lambda].include?(child.kind)
|
|
352
|
+
|
|
353
|
+
names.concat(kept_local_names(child))
|
|
354
|
+
stack.concat(@children[child.object_id] || [])
|
|
355
|
+
end
|
|
356
|
+
names
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# The locals a scope leaves as written: a block renames only its
|
|
360
|
+
# parameters — its body locals, and the parameters it pins or leaves
|
|
361
|
+
# under eval, stay — and a lambda renames nothing.
|
|
362
|
+
def kept_local_names(scope)
|
|
363
|
+
node = scope.node
|
|
364
|
+
case scope.kind
|
|
365
|
+
when :block
|
|
366
|
+
unsafe, pinned = constraints_for(scope)
|
|
367
|
+
return node.locals.map(&:to_s) if unsafe
|
|
368
|
+
|
|
369
|
+
# @type var node: Prism::BlockNode
|
|
370
|
+
renamed = renamed_block_param_names(node)
|
|
371
|
+
node.locals.filter_map { |var| var.to_s if pinned.include?(var) || !renamed.include?(var) }
|
|
372
|
+
when :lambda
|
|
373
|
+
node.locals.map(&:to_s)
|
|
374
|
+
else
|
|
375
|
+
[]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def renamed_block_param_names(node)
|
|
380
|
+
names = block_formal_names(node).compact.reject { |p| p.to_s.match?(/\A_\d*\z/) }
|
|
381
|
+
block_multi_targets(node).each { |mt| names.concat(collect_multi_target_names(mt)) }
|
|
382
|
+
params = block_parameters(node)
|
|
383
|
+
names.concat(collect_extra_block_param_names(params)) if params
|
|
384
|
+
names.to_set
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# scope_constraints, once per scope: reserved_names asks about every
|
|
388
|
+
# nested block repeatedly.
|
|
389
|
+
def constraints_for(scope)
|
|
390
|
+
@constraints[scope.id] ||= begin
|
|
391
|
+
node = scope.node
|
|
392
|
+
body = node.is_a?(Prism::ProgramNode) ? node.statements : node.body
|
|
393
|
+
scope_constraints(body)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
299
397
|
# One walk answers both per-scope safety questions: whether the scope
|
|
300
398
|
# defeats renaming entirely (eval and friends can read any local by its
|
|
301
399
|
# original name) and which locals are name-pinned — a regex named
|
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
module Ryac
|
|
4
4
|
module AnalysisPhases
|
|
5
5
|
def collect_method_definitions(prism_root)
|
|
6
|
+
setter_mids = Set.new
|
|
6
7
|
Nesting.each_method_definition(prism_root) do |node, method_key|
|
|
7
8
|
next if EXCLUDED_METHODS.include?(method_key[2])
|
|
8
9
|
|
|
10
|
+
# An explicit `def foo=` keeps its name: short names are allocated
|
|
11
|
+
# per group, so nothing promises the setter would land on
|
|
12
|
+
# `<getter's name>=`, and the def patcher would strip the `=` from
|
|
13
|
+
# any unpaired spelling. The base name is kept with it — a compound
|
|
14
|
+
# write reads through one spelling and writes through the other, so
|
|
15
|
+
# the pair must move in lockstep or not at all (the same doctrine
|
|
16
|
+
# that keeps accessor pairs in compound writes unrenamed).
|
|
17
|
+
# attr-declared setters are different — they rename textually
|
|
18
|
+
# coupled to their getter.
|
|
19
|
+
if AstUtils.setter_def_name?(method_key[2])
|
|
20
|
+
setter_mids << method_key[2] << method_key[2].to_s.chomp('=').to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
9
23
|
@method_rename_mapping.add_method(method_key, node)
|
|
10
24
|
link_module_function_variant(node, method_key)
|
|
11
25
|
end
|
|
26
|
+
@method_rename_mapping.exclude_methods_by_mid(setter_mids) unless setter_mids.empty?
|
|
12
27
|
|
|
13
28
|
# attr_reader/attr_accessor define getters worth renaming; attr_writer's
|
|
14
29
|
# setter is derived from the getter name and is never registered on its
|
|
@@ -54,7 +69,86 @@ module Ryac
|
|
|
54
69
|
merge_super_groups(super_merges)
|
|
55
70
|
merge_polymorphic_groups(call_node_to_keys)
|
|
56
71
|
merge_unresolved_calls(resolved_call_keys)
|
|
57
|
-
|
|
72
|
+
# Folding blind defs into same-name sited groups is a probability
|
|
73
|
+
# bet, not a proof — the aggressive policy's territory.
|
|
74
|
+
@method_rename_mapping.merge_blind_def_groups if @method_policy == :aggressive
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# An attr declared below a class that touches its ivar — `attr_reader
|
|
78
|
+
# :label_text` in a subclass of the class assigning @label_text — cannot
|
|
79
|
+
# rename: the coordination that moves ivar sites along with a renamed
|
|
80
|
+
# attr walks from the declaring class down, never up to that ancestor,
|
|
81
|
+
# which would go on writing the original name. The pair keeps its name.
|
|
82
|
+
def collect_inherited_attr_exclusions(prism_root)
|
|
83
|
+
touching = Hash.new { |h, k| h[k] = Set.new } #: Hash[Symbol, Set[Array[Symbol]]]
|
|
84
|
+
Nesting.each(prism_root) do |node, cpath, _singleton, _in_def|
|
|
85
|
+
case node
|
|
86
|
+
when Prism::InstanceVariableReadNode, *IVAR_WRITE_NODES
|
|
87
|
+
# @type var node: Prism::InstanceVariableReadNode | ivar_write_node
|
|
88
|
+
touching[node.name] << cpath
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
excluded = Set.new
|
|
93
|
+
each_attr_declaration(prism_root, ATTR_DECLARATION_METHODS, require_class_body: false) do |_node, cpath, _singleton, sym|
|
|
94
|
+
classes = touching.fetch(:"@#{sym}", nil)
|
|
95
|
+
next unless classes
|
|
96
|
+
|
|
97
|
+
@oracle.each_ancestor_cpath(cpath, false) do |ancestor_cpath|
|
|
98
|
+
excluded << sym << :"#{sym}=" if ancestor_cpath != cpath && classes.include?(ancestor_cpath)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
@method_rename_mapping.exclude_methods_by_mid(excluded) unless excluded.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Renaming an attr declaration renames its backing ivar with it. In a
|
|
105
|
+
# class that touches ivars dynamically (optcarrot's Config assigns
|
|
106
|
+
# every option via instance_variable_set), the dynamic side keeps the
|
|
107
|
+
# original spelling and a renamed reader silently returns nil. The
|
|
108
|
+
# ivar renamer already refuses such classes; the attr names there must
|
|
109
|
+
# survive for the same reason, under either policy.
|
|
110
|
+
def collect_dynamic_ivar_attr_exclusions(prism_root)
|
|
111
|
+
dynamic_cpaths = Set.new
|
|
112
|
+
Nesting.each(prism_root) do |node, cpath, _singleton, _in_def|
|
|
113
|
+
next unless node.is_a?(Prism::CallNode) && DYNAMIC_IVAR_METHODS.include?(node.name)
|
|
114
|
+
|
|
115
|
+
recv = node.receiver
|
|
116
|
+
dynamic_cpaths << cpath if recv.nil? || recv.is_a?(Prism::SelfNode)
|
|
117
|
+
end
|
|
118
|
+
return if dynamic_cpaths.empty?
|
|
119
|
+
|
|
120
|
+
excluded = Set.new
|
|
121
|
+
each_attr_declaration(prism_root, ATTR_DECLARATION_METHODS, require_class_body: false) do |_node, cpath, _singleton, sym|
|
|
122
|
+
excluded << sym << :"#{sym}=" if dynamic_cpaths.include?(cpath)
|
|
123
|
+
end
|
|
124
|
+
@method_rename_mapping.exclude_methods_by_mid(excluded) unless excluded.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# eval and send-by-string dispatch from strings, not the syntax tree:
|
|
128
|
+
# a method whose name is spelled inside any string literal may be
|
|
129
|
+
# called from text the renamer cannot rewrite, so under the safe
|
|
130
|
+
# policy it keeps its name. Setters count as mentioned when their
|
|
131
|
+
# base word is.
|
|
132
|
+
def collect_string_literal_mentions(prism_root)
|
|
133
|
+
words = Set.new
|
|
134
|
+
AstUtils.each_node(prism_root) do |node|
|
|
135
|
+
next unless node.is_a?(Prism::StringNode)
|
|
136
|
+
|
|
137
|
+
# Byte escapes (`"\x89PNG"`) leave the literal invalid in the source
|
|
138
|
+
# encoding, and String#scan refuses invalid text; the identifier
|
|
139
|
+
# pattern is pure ASCII, so scanning the bytes finds the same words.
|
|
140
|
+
# The pattern has no capture groups, so scan only produces strings;
|
|
141
|
+
# the is_a? narrows the union scan's signature declares.
|
|
142
|
+
node.unescaped.b.scan(/[a-zA-Z_][a-zA-Z0-9_]*[?!]?/).each do |w|
|
|
143
|
+
words << w.to_sym if w.is_a?(String)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
return if words.empty?
|
|
147
|
+
|
|
148
|
+
mentioned = @method_rename_mapping.method_mids.select { |mid|
|
|
149
|
+
words.include?(mid) || words.include?(mid.to_s.chomp('=').to_sym)
|
|
150
|
+
}
|
|
151
|
+
@method_rename_mapping.exclude_methods_by_mid(mentioned.to_set) unless mentioned.empty?
|
|
58
152
|
end
|
|
59
153
|
|
|
60
154
|
# Visibility resets at every reopen of this module, so each collection
|
|
@@ -154,7 +248,9 @@ module Ryac
|
|
|
154
248
|
end
|
|
155
249
|
end
|
|
156
250
|
|
|
157
|
-
|
|
251
|
+
# An unresolved call the aggressive policy folds into the group is
|
|
252
|
+
# exactly the bet the safe policy refuses: the name goes untouched.
|
|
253
|
+
if should_exclude || (@method_policy == :safe && mapped_calls.any?)
|
|
158
254
|
exclude_mids << mid
|
|
159
255
|
elsif mapped_calls.any?
|
|
160
256
|
@method_rename_mapping.merge_all_by_mid(mid)
|
|
@@ -211,7 +307,8 @@ module Ryac
|
|
|
211
307
|
@method_rename_mapping.exclude_methods_by_mid(punned) if punned.any?
|
|
212
308
|
end
|
|
213
309
|
|
|
214
|
-
VISIBILITY_MODIFIERS = %i[private protected public module_function
|
|
310
|
+
VISIBILITY_MODIFIERS = %i[private protected public module_function
|
|
311
|
+
private_class_method public_class_method].freeze
|
|
215
312
|
|
|
216
313
|
def collect_visibility_modifier_methods(prism_root)
|
|
217
314
|
excluded_mids = Set.new
|
|
@@ -101,6 +101,19 @@ module Ryac
|
|
|
101
101
|
(blind_mids & sited_mids).each { |mid| merge_all_by_mid(mid) }
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# A def no resolved call reaches is either dead or called from outside
|
|
105
|
+
# the program (a library's public surface, a runner script requiring
|
|
106
|
+
# the bundle) — renaming it is unsound both ways, so the safe policy
|
|
107
|
+
# drops those names before assignment.
|
|
108
|
+
def exclude_uncalled_methods
|
|
109
|
+
mids = Set.new
|
|
110
|
+
groups_by_root.each_value do |keys|
|
|
111
|
+
sites = keys.sum { |key| @methods[key][:call_sites].size }
|
|
112
|
+
keys.each { |key| mids << key[2] } if sites.zero?
|
|
113
|
+
end
|
|
114
|
+
exclude_methods_by_mid(mids) unless mids.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
104
117
|
def add_unresolved_sites_for_mid(mid, call_nodes)
|
|
105
118
|
target_key = @methods.keys.find { |k| k[2] == mid }
|
|
106
119
|
return unless target_key
|
|
@@ -123,11 +136,13 @@ module Ryac
|
|
|
123
136
|
end
|
|
124
137
|
end
|
|
125
138
|
|
|
126
|
-
|
|
139
|
+
# scope_vars: LocalScopes#visible_local_names — every local name visible
|
|
140
|
+
# at a scope after renaming, so implicit-receiver sites can refuse a
|
|
141
|
+
# short name a bare call would resolve as a variable read.
|
|
142
|
+
def assign_short_names(scope_vars, oracle = nil)
|
|
127
143
|
group_entries = build_group_entries(groups_by_root)
|
|
128
144
|
group_entries.sort_by! { |entry| -(entry.original_name.size * entry.total_occurrences) }
|
|
129
145
|
|
|
130
|
-
scope_vars = self.class.build_scope_vars(scope_mappings)
|
|
131
146
|
existing_methods, hierarchy = oracle ? build_existing_method_names(oracle) : [{}, {}] #: [Hash[class_key, Set[String]], hierarchy]
|
|
132
147
|
|
|
133
148
|
group_entries.each do |entry|
|
|
@@ -241,16 +256,6 @@ module Ryac
|
|
|
241
256
|
result
|
|
242
257
|
end
|
|
243
258
|
|
|
244
|
-
# Class method: the attr coordination inverts scope_mappings the same
|
|
245
|
-
# way for its own collision check, so there is exactly one inversion.
|
|
246
|
-
def self.build_scope_vars(scope_mappings)
|
|
247
|
-
scope_vars = Hash.new { |h, k| h[k] = Set.new } #: Hash[scope_id, Set[String]]
|
|
248
|
-
scope_mappings.each do |cref_id, mapping|
|
|
249
|
-
mapping.each_value { |mangled| scope_vars[cref_id] << mangled }
|
|
250
|
-
end
|
|
251
|
-
scope_vars
|
|
252
|
-
end
|
|
253
|
-
|
|
254
259
|
def groups_by_root
|
|
255
260
|
groups = Hash.new { |h, k| h[k] = [] } #: Hash[method_key, Array[method_key]]
|
|
256
261
|
@methods.each_key { |key| groups[uf_root(key)] << key }
|
|
@@ -19,13 +19,18 @@ module Ryac
|
|
|
19
19
|
# internals the boot requires — the Service construction and the
|
|
20
20
|
# private @rb_text_nodes table behind update_rb_file — so an upstream
|
|
21
21
|
# change to either breaks here and nowhere else.
|
|
22
|
-
|
|
22
|
+
#
|
|
23
|
+
# TypeProf does not register a class defined inside a block, and a lazy
|
|
24
|
+
# region (LazyRegions) is exactly that. It reads the view with the
|
|
25
|
+
# region wrappers blanked instead — same byte positions, which is what
|
|
26
|
+
# the coordinate join below relies on.
|
|
27
|
+
def self.boot(content, rbs_files, prism_root)
|
|
23
28
|
path = '(minify_concat)'
|
|
24
29
|
service = TypeProf::Core::Service.new({})
|
|
25
30
|
rbs_files.each do |rbs_path, rbs_content|
|
|
26
31
|
service.update_rbs_file(rbs_path, rbs_content)
|
|
27
32
|
end
|
|
28
|
-
service.update_rb_file(path, content)
|
|
33
|
+
service.update_rb_file(path, LazyRegions.typeprof_view(content, prism_root))
|
|
29
34
|
new(service.genv, service.instance_variable_get(:@rb_text_nodes)[path])
|
|
30
35
|
end
|
|
31
36
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ryac
|
|
4
|
+
# The two-file layout of a program with lazy regions: the minified program
|
|
5
|
+
# as a pure library — loader, core and aliases, nothing that runs — and a
|
|
6
|
+
# runner holding the regions, which loads the library, registers them and
|
|
7
|
+
# starts the program with an expression given on its command line:
|
|
8
|
+
#
|
|
9
|
+
# ruby driver.rb minify.rb --exec "Optcarrot::NES.new.run" game.nes
|
|
10
|
+
#
|
|
11
|
+
# The runner strips its own arguments before anything else reads ARGV, so
|
|
12
|
+
# the program's option parsing sees only what follows. Registering after
|
|
13
|
+
# the load means a dynamic require the core runs while loading — rather
|
|
14
|
+
# than from a method called later, the shape that made the file lazy —
|
|
15
|
+
# would miss its region and fall through to the loader's real require.
|
|
16
|
+
module DriverFile
|
|
17
|
+
HEADER = <<~'RUBY'
|
|
18
|
+
# Generated by ryac. Usage: ruby driver.rb CORE [--exec EXPR] [ARGS...]
|
|
19
|
+
# Loads CORE, registers the regions below, then evals EXPR at top level
|
|
20
|
+
# with ARGS left in ARGV. Arguments after "--" are never read.
|
|
21
|
+
core = ARGV.shift or abort("usage: ruby #{$0} CORE [--exec EXPR] [ARGS...]")
|
|
22
|
+
stop = ARGV.index("--")
|
|
23
|
+
at = ARGV.index("--exec")
|
|
24
|
+
expr = ARGV.slice!(at, 2)[1] if at && (!stop || at < stop)
|
|
25
|
+
load core
|
|
26
|
+
RUBY
|
|
27
|
+
|
|
28
|
+
FOOTER = "eval(expr, TOPLEVEL_BINDING, \"--exec\") if expr\n"
|
|
29
|
+
|
|
30
|
+
# Splits the program's full content into [core, driver]: the region
|
|
31
|
+
# registrations go to the driver, one per line, everything else stays
|
|
32
|
+
# the core in its original order.
|
|
33
|
+
def self.split(full_content)
|
|
34
|
+
result = Prism.parse(full_content)
|
|
35
|
+
raise Pipeline::InvalidOutputError.new('program', result.errors) unless result.errors.none?
|
|
36
|
+
|
|
37
|
+
registrations = [] #: Array[String]
|
|
38
|
+
core = [] #: Array[String]
|
|
39
|
+
result.value.statements.body.each do |statement|
|
|
40
|
+
(LazyRegions.registration_lambda(statement) ? registrations : core) << statement.slice
|
|
41
|
+
end
|
|
42
|
+
raise MinifyError, 'cannot write a driver file: the program has no lazy regions' if registrations.empty?
|
|
43
|
+
|
|
44
|
+
[core.join(';'), "#{HEADER}#{registrations.join("\n")}\n#{FOOTER}"]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/ryac/minifier.rb
CHANGED
|
@@ -52,44 +52,44 @@ module Ryac
|
|
|
52
52
|
raise ArgumentError, "Invalid compress level: #{value} (valid: #{STAGES.keys.join(', ')})"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
ALL_VAR_FEATURES = { features: { keywords: true, ivars: true, cvars: true, gvars: true } }.freeze
|
|
56
55
|
ALL_VAR_WITH_ATTR = { features: { keywords: true, ivars: true, cvars: true, gvars: true, attr_ivars: true } }.freeze
|
|
57
56
|
|
|
58
57
|
# :unstable is derived from :stable, never hand-copied — the superset
|
|
59
|
-
# law lives here as code.
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# transform flips all three together; a hand-composed list must too.
|
|
58
|
+
# law lives here as code. The one switch between them is the method
|
|
59
|
+
# renamer's policy: :safe touches only names whose every caller type
|
|
60
|
+
# inference resolved; :aggressive also takes the bets.
|
|
63
61
|
def self.derive_unstable(stable)
|
|
64
|
-
stable.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
[[Pipeline::VariableRenamer, ALL_VAR_WITH_ATTR], [Pipeline::MethodRenamer]] #: Array[stage_entry]
|
|
69
|
-
else
|
|
70
|
-
[entry]
|
|
71
|
-
end
|
|
72
|
-
end.freeze
|
|
62
|
+
stable.map { |entry|
|
|
63
|
+
entry[0].equal?(Pipeline::MethodRenamer) ? [Pipeline::MethodRenamer] #: stage_entry
|
|
64
|
+
: entry
|
|
65
|
+
}.freeze
|
|
73
66
|
end
|
|
74
67
|
|
|
75
68
|
# The two levels, named for their promise rather than a number.
|
|
76
69
|
#
|
|
77
|
-
# :stable is the boundary the optcarrot test certifies frame-for-frame
|
|
78
|
-
# a real program:
|
|
79
|
-
#
|
|
70
|
+
# :stable is the boundary the optcarrot test certifies frame-for-frame
|
|
71
|
+
# on a real program: class, constant and variable renaming, plus method
|
|
72
|
+
# renaming under the :safe policy — a group renames only when type
|
|
73
|
+
# inference resolved every caller and no dynamic escape hatch (a string
|
|
74
|
+
# mention, a dynamic-ivar class, an uncalled def) touches its name.
|
|
75
|
+
# "attrs renamed" is one fact spelled by three co-moving switches:
|
|
76
|
+
# AttrDeclShorten rename_attrs (declarations), attr_ivars (backing
|
|
77
|
+
# ivars) and MethodRenamer (call sites) — all three live here together.
|
|
80
78
|
#
|
|
81
|
-
# :unstable
|
|
82
|
-
# construction — names survive inside strings, eval'd
|
|
83
|
-
# send targets, out of reach of any static
|
|
84
|
-
# self-hosting and works only when the
|
|
79
|
+
# :unstable switches method renaming to :aggressive, which a program
|
|
80
|
+
# can defeat by construction — names survive inside strings, eval'd
|
|
81
|
+
# source and computed send targets, out of reach of any static
|
|
82
|
+
# analysis. It is certified by self-hosting and works only when the
|
|
83
|
+
# program plays along.
|
|
85
84
|
#
|
|
86
85
|
# Finer configurations are not levels: individual steps stay composable
|
|
87
86
|
# by passing an explicit stage list in place of a level name.
|
|
88
87
|
STABLE_STAGES = [
|
|
89
88
|
*OPTIMIZE_PRE,
|
|
90
89
|
[Pipeline::ConstantAliaser, { rename_classes: true }],
|
|
91
|
-
[Pipeline::AttrDeclShorten],
|
|
92
|
-
[Pipeline::VariableRenamer,
|
|
90
|
+
[Pipeline::AttrDeclShorten, { rename_attrs: true }],
|
|
91
|
+
[Pipeline::VariableRenamer, ALL_VAR_WITH_ATTR],
|
|
92
|
+
[Pipeline::MethodRenamer, { policy: :safe }],
|
|
93
93
|
*OPTIMIZE_POST,
|
|
94
94
|
].freeze
|
|
95
95
|
|
|
@@ -100,8 +100,8 @@ module Ryac
|
|
|
100
100
|
|
|
101
101
|
# code is raw (uncompacted) text — compaction is the runner's fixed
|
|
102
102
|
# first step, so every stage list starts from the same dialect.
|
|
103
|
-
def self.run_stages(code, stages, stdlib_requires: [], rbs_files: {})
|
|
104
|
-
Pipeline::StageRunner.new(stdlib_requires: stdlib_requires, rbs_files: rbs_files)
|
|
103
|
+
def self.run_stages(code, stages, stdlib_requires: [], rbs_files: {}, lazy_files: [])
|
|
104
|
+
Pipeline::StageRunner.new(stdlib_requires: stdlib_requires, rbs_files: rbs_files, lazy_files: lazy_files)
|
|
105
105
|
.call(code, stages)
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -133,7 +133,8 @@ module Ryac
|
|
|
133
133
|
stages = target_level.is_a?(Array) ? target_level : STAGES.fetch(self.class.resolve_level(target_level))
|
|
134
134
|
self.class.run_stages(source.content, stages,
|
|
135
135
|
stdlib_requires: source.stdlib_requires,
|
|
136
|
-
rbs_files: source.rbs_files
|
|
136
|
+
rbs_files: source.rbs_files,
|
|
137
|
+
lazy_files: source.lazy_files
|
|
137
138
|
)
|
|
138
139
|
end
|
|
139
140
|
|
data/lib/ryac/packer.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ryac
|
|
4
|
+
# Self-extracting output encoding, applied after minification. The emitted
|
|
5
|
+
# file is a short plain-Ruby stub followed by the real program compressed
|
|
6
|
+
# after __END__; running it inflates the bytes and evals them with $0 as
|
|
7
|
+
# the file name, so a `$PROGRAM_NAME == __FILE__` launcher still fires and
|
|
8
|
+
# relative requires resolve as they would from the plain artifact.
|
|
9
|
+
#
|
|
10
|
+
# This is an output format, not a stage: stages rewrite Ruby into
|
|
11
|
+
# equivalent Ruby and re-parse to a fixed point, while a packed file is
|
|
12
|
+
# opaque bytes. Two formats:
|
|
13
|
+
#
|
|
14
|
+
# :self — an LZSS decoder (4KB window, 3..18-byte matches) inlined in
|
|
15
|
+
# the stub; no require at all, runs on any Ruby.
|
|
16
|
+
# :zlib — deflate via the zlib default gem; smaller, but dead on a Ruby
|
|
17
|
+
# built without it.
|
|
18
|
+
module Packer
|
|
19
|
+
FORMATS = %i[self zlib].freeze
|
|
20
|
+
|
|
21
|
+
SELF_STUB = 's=DATA.binmode.read;o="".b;i=0;while i<s.size;f=s.getbyte i;i+=1;' \
|
|
22
|
+
'8.times{break if i>=s.size;if f&1>0;o<<s.getbyte(i);i+=1;else;' \
|
|
23
|
+
'a=s.getbyte i;b=s.getbyte i+1;i+=2;d=(a<<4|b>>4)+1;l=(b&15)+3;' \
|
|
24
|
+
'l.times{o<<o.getbyte(o.size-d)};end;f>>=1};end;' \
|
|
25
|
+
'eval o.force_encoding("UTF-8"),nil,$0' \
|
|
26
|
+
"\n__END__\n"
|
|
27
|
+
|
|
28
|
+
ZLIB_STUB = 'require"zlib";eval Zlib.inflate(DATA.binmode.read).force_encoding("UTF-8"),nil,$0' \
|
|
29
|
+
"\n__END__\n"
|
|
30
|
+
|
|
31
|
+
def self.resolve_format(value)
|
|
32
|
+
format = value.to_sym
|
|
33
|
+
return format if FORMATS.include?(format)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "Invalid pack format: #{value} (valid: #{FORMATS.join(', ')})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.pack(source, format)
|
|
39
|
+
reject_data_readers(source)
|
|
40
|
+
case resolve_format(format)
|
|
41
|
+
when :zlib
|
|
42
|
+
require 'zlib'
|
|
43
|
+
ZLIB_STUB.b + Zlib::Deflate.deflate(source, Zlib::BEST_COMPRESSION)
|
|
44
|
+
else
|
|
45
|
+
SELF_STUB.b + lzss_compress(source)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The stub consumes the packed file's one __END__/DATA stream, so a
|
|
50
|
+
# program carrying its own data section or reading DATA would read
|
|
51
|
+
# compressed garbage instead.
|
|
52
|
+
def self.reject_data_readers(source)
|
|
53
|
+
result = Prism.parse(source)
|
|
54
|
+
offending = result.data_loc ? '__END__' : nil
|
|
55
|
+
unless offending
|
|
56
|
+
AstUtils.each_node(result.value) do |node|
|
|
57
|
+
case node
|
|
58
|
+
when Prism::ConstantReadNode
|
|
59
|
+
offending = 'DATA' if node.name == :DATA
|
|
60
|
+
when Prism::ConstantPathNode
|
|
61
|
+
offending = 'DATA' if node.parent.nil? && node.name == :DATA
|
|
62
|
+
end
|
|
63
|
+
break if offending
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
return unless offending
|
|
67
|
+
|
|
68
|
+
raise MinifyError, "cannot pack: the program uses #{offending}, and the self-extracting stub owns the packed file's data section"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Greedy LZSS: flag byte per 8 tokens (1 bit = literal), matches encoded
|
|
72
|
+
# as 12-bit distance-1 / 4-bit length-3. The decoder in SELF_STUB is the
|
|
73
|
+
# exact inverse.
|
|
74
|
+
def self.lzss_compress(data)
|
|
75
|
+
n = data.bytesize
|
|
76
|
+
out = +''.b
|
|
77
|
+
index = Hash.new { |h, k| h[k] = [] } #: Hash[String, Array[Integer]]
|
|
78
|
+
i = 0
|
|
79
|
+
flags = 0
|
|
80
|
+
nflag = 0
|
|
81
|
+
chunk = +''.b
|
|
82
|
+
flush = lambda do
|
|
83
|
+
out << flags.chr << chunk
|
|
84
|
+
flags = 0
|
|
85
|
+
nflag = 0
|
|
86
|
+
chunk = +''.b
|
|
87
|
+
end
|
|
88
|
+
while i < n
|
|
89
|
+
best_len = 0
|
|
90
|
+
best_dist = 0
|
|
91
|
+
if i + 3 <= n
|
|
92
|
+
index[data.byteslice(i, 3) || ''].reverse_each do |j|
|
|
93
|
+
d = i - j
|
|
94
|
+
break if d > 4096
|
|
95
|
+
|
|
96
|
+
l = 0
|
|
97
|
+
l += 1 while l < 18 && i + l < n && data.getbyte(j + l) == data.getbyte(i + l)
|
|
98
|
+
if l > best_len
|
|
99
|
+
best_len = l
|
|
100
|
+
best_dist = d
|
|
101
|
+
break if l == 18
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
if best_len >= 3
|
|
106
|
+
dm = best_dist - 1
|
|
107
|
+
chunk << (dm >> 4).chr << (((dm & 15) << 4) | (best_len - 3)).chr
|
|
108
|
+
best_len.times do
|
|
109
|
+
index[data.byteslice(i, 3) || ''] << i if i + 3 <= n
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
flags |= (1 << nflag)
|
|
114
|
+
# getbyte(i) is non-nil for every i < n
|
|
115
|
+
chunk << data.getbyte(i).chr # steep:ignore NoMethod
|
|
116
|
+
index[data.byteslice(i, 3) || ''] << i if i + 3 <= n
|
|
117
|
+
i += 1
|
|
118
|
+
end
|
|
119
|
+
nflag += 1
|
|
120
|
+
flush.call if nflag == 8
|
|
121
|
+
end
|
|
122
|
+
flush.call if nflag.positive?
|
|
123
|
+
out
|
|
124
|
+
end
|
|
125
|
+
private_class_method :lzss_compress
|
|
126
|
+
end
|
|
127
|
+
end
|