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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -16
  3. data/Steepfile +2 -0
  4. data/bin/ryac +41 -2
  5. data/lib/ryac/analysis/constant/collection.rb +49 -0
  6. data/lib/ryac/analysis/constant/rename_mapping.rb +15 -1
  7. data/lib/ryac/analysis/ivar/collection.rb +28 -2
  8. data/lib/ryac/analysis/lazy_regions.rb +87 -0
  9. data/lib/ryac/analysis/local_scopes.rb +110 -12
  10. data/lib/ryac/analysis/method/collection.rb +100 -3
  11. data/lib/ryac/analysis/method/rename_mapping.rb +17 -12
  12. data/lib/ryac/analysis/type_oracle.rb +7 -2
  13. data/lib/ryac/driver_file.rb +47 -0
  14. data/lib/ryac/minifier.rb +27 -26
  15. data/lib/ryac/packer.rb +127 -0
  16. data/lib/ryac/pipeline/analyzer.rb +25 -3
  17. data/lib/ryac/pipeline/concatenator.rb +127 -8
  18. data/lib/ryac/pipeline/data_types.rb +13 -5
  19. data/lib/ryac/pipeline/file_collector.rb +64 -4
  20. data/lib/ryac/pipeline/method_renamer.rb +15 -0
  21. data/lib/ryac/pipeline/stage.rb +4 -0
  22. data/lib/ryac/pipeline/stage_runner.rb +7 -3
  23. data/lib/ryac/version.rb +1 -1
  24. data/lib/ryac.rb +3 -0
  25. data/sig/ryac/analysis/constant/collection.rbs +3 -0
  26. data/sig/ryac/analysis/constant/rename_mapping.rbs +2 -1
  27. data/sig/ryac/analysis/ivar/collection.rbs +1 -0
  28. data/sig/ryac/analysis/lazy_regions.rbs +10 -0
  29. data/sig/ryac/analysis/local_scopes.rbs +9 -1
  30. data/sig/ryac/analysis/method/collection.rbs +3 -0
  31. data/sig/ryac/analysis/method/rename_mapping.rbs +3 -2
  32. data/sig/ryac/analysis/type_oracle.rbs +1 -1
  33. data/sig/ryac/driver_file.rbs +8 -0
  34. data/sig/ryac/minifier.rbs +1 -2
  35. data/sig/ryac/packer.rbs +12 -0
  36. data/sig/ryac/pipeline/analyzer.rbs +6 -0
  37. data/sig/ryac/pipeline/concatenator.rbs +16 -1
  38. data/sig/ryac/pipeline/data_types.rbs +8 -5
  39. data/sig/ryac/pipeline/file_collector.rbs +8 -3
  40. data/sig/ryac/pipeline/method_renamer.rbs +3 -0
  41. data/sig/ryac/pipeline/stage.rbs +1 -0
  42. data/sig/ryac/pipeline/stage_runner.rbs +2 -1
  43. metadata +15 -8
  44. data/bin/console +0 -11
  45. 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
- # A :top scope's node is always a ProgramNode, which has #statements.
171
- unsafe, pinned = scope_constraints(scope.node.statements) # steep:ignore NoMethod
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 = scope_constraints(node.body)
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 = scope_constraints(node.body)
261
+ unsafe, pinned = constraints_for(scope)
234
262
  f_args = block_formal_names(node)
235
- parent_names = ancestor_mapping_values(scope)
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
- # Names a block avoids: every allocated ancestor's, all the way to the
284
- # top. Visibility actually ends at the first def boundary, so anything
285
- # beyond it is over-reservation but the allocation order downstream is
286
- # built around this exact set, and narrowing it reshuffles names without
287
- # making any of them safer.
288
- def ancestor_mapping_values(scope)
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
- @method_rename_mapping.merge_blind_def_groups
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
- if should_exclude
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].freeze
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
- def assign_short_names(scope_mappings, oracle = nil)
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
- def self.boot(content, rbs_files)
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. "attrs renamed" is one fact spelled by three
60
- # co-moving switches: AttrDeclShorten rename_attrs (declarations),
61
- # attr_ivars (backing ivars) and MethodRenamer (call sites). This
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.flat_map do |entry|
65
- if entry[0].equal?(Pipeline::AttrDeclShorten)
66
- [[Pipeline::AttrDeclShorten, { rename_attrs: true }]] #: Array[stage_entry]
67
- elsif entry[0].equal?(Pipeline::VariableRenamer)
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 on
78
- # a real program: everything up to class, constant and variable renaming,
79
- # which closed-world analysis can keep sound.
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 adds method renaming, which a program can defeat by
82
- # construction — names survive inside strings, eval'd source and computed
83
- # send targets, out of reach of any static analysis. It is certified by
84
- # self-hosting and works only when the program plays along.
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, ALL_VAR_FEATURES],
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
 
@@ -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