ryac 0.2.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +145 -0
  4. data/Rakefile +45 -0
  5. data/Steepfile +9 -0
  6. data/bin/console +11 -0
  7. data/bin/ryac +134 -0
  8. data/bin/setup +8 -0
  9. data/lib/ryac/analysis/constant/collection.rb +332 -0
  10. data/lib/ryac/analysis/constant/rename_mapping.rb +346 -0
  11. data/lib/ryac/analysis/cvar/collection.rb +37 -0
  12. data/lib/ryac/analysis/gvar/collection.rb +36 -0
  13. data/lib/ryac/analysis/gvar/rename_mapping.rb +82 -0
  14. data/lib/ryac/analysis/ivar/collection.rb +281 -0
  15. data/lib/ryac/analysis/keyword/collection.rb +213 -0
  16. data/lib/ryac/analysis/keyword/rename_mapping.rb +167 -0
  17. data/lib/ryac/analysis/local_scopes.rb +582 -0
  18. data/lib/ryac/analysis/method/collection.rb +298 -0
  19. data/lib/ryac/analysis/method/rename_mapping.rb +337 -0
  20. data/lib/ryac/analysis/method_aliases.rb +83 -0
  21. data/lib/ryac/analysis/method_aliasing.rb +65 -0
  22. data/lib/ryac/analysis/nesting.rb +123 -0
  23. data/lib/ryac/analysis/scope_management.rb +25 -0
  24. data/lib/ryac/analysis/site_bucket_mapping.rb +127 -0
  25. data/lib/ryac/analysis/type_oracle.rb +386 -0
  26. data/lib/ryac/ast_utils.rb +321 -0
  27. data/lib/ryac/errors.rb +32 -0
  28. data/lib/ryac/gem_resolver.rb +55 -0
  29. data/lib/ryac/minifier.rb +169 -0
  30. data/lib/ryac/name_generator.rb +68 -0
  31. data/lib/ryac/pipeline/analyzer.rb +264 -0
  32. data/lib/ryac/pipeline/attr_decl_shorten.rb +61 -0
  33. data/lib/ryac/pipeline/boolean_shorten.rb +26 -0
  34. data/lib/ryac/pipeline/char_shorten.rb +20 -0
  35. data/lib/ryac/pipeline/compactor.rb +1202 -0
  36. data/lib/ryac/pipeline/concatenator.rb +248 -0
  37. data/lib/ryac/pipeline/constant_aliaser.rb +240 -0
  38. data/lib/ryac/pipeline/constant_fold.rb +93 -0
  39. data/lib/ryac/pipeline/control_flow_simplify.rb +352 -0
  40. data/lib/ryac/pipeline/data_types.rb +179 -0
  41. data/lib/ryac/pipeline/endless_method.rb +60 -0
  42. data/lib/ryac/pipeline/errors.rb +94 -0
  43. data/lib/ryac/pipeline/file_collector.rb +360 -0
  44. data/lib/ryac/pipeline/method_renamer.rb +151 -0
  45. data/lib/ryac/pipeline/paren_optimizer.rb +161 -0
  46. data/lib/ryac/pipeline/rename_patcher.rb +274 -0
  47. data/lib/ryac/pipeline/source_patcher.rb +54 -0
  48. data/lib/ryac/pipeline/spelling_shorten.rb +60 -0
  49. data/lib/ryac/pipeline/stage.rb +72 -0
  50. data/lib/ryac/pipeline/stage_runner.rb +84 -0
  51. data/lib/ryac/pipeline/variable_renamer.rb +125 -0
  52. data/lib/ryac/union_find.rb +42 -0
  53. data/lib/ryac/version.rb +5 -0
  54. data/lib/ryac.rb +29 -0
  55. data/rbs_collection.lock.yaml +224 -0
  56. data/rbs_collection.yaml +25 -0
  57. data/sig/ryac/analysis/constant/collection.rbs +30 -0
  58. data/sig/ryac/analysis/constant/rename_mapping.rbs +60 -0
  59. data/sig/ryac/analysis/cvar/collection.rbs +11 -0
  60. data/sig/ryac/analysis/gvar/collection.rbs +9 -0
  61. data/sig/ryac/analysis/gvar/rename_mapping.rbs +23 -0
  62. data/sig/ryac/analysis/ivar/collection.rbs +26 -0
  63. data/sig/ryac/analysis/keyword/collection.rbs +27 -0
  64. data/sig/ryac/analysis/keyword/rename_mapping.rbs +31 -0
  65. data/sig/ryac/analysis/local_scopes.rbs +77 -0
  66. data/sig/ryac/analysis/method/collection.rbs +25 -0
  67. data/sig/ryac/analysis/method/rename_mapping.rbs +82 -0
  68. data/sig/ryac/analysis/method_aliases.rbs +10 -0
  69. data/sig/ryac/analysis/method_aliasing.rbs +9 -0
  70. data/sig/ryac/analysis/nesting.rbs +9 -0
  71. data/sig/ryac/analysis/scope_management.rbs +5 -0
  72. data/sig/ryac/analysis/site_bucket_mapping.rbs +26 -0
  73. data/sig/ryac/analysis/type_oracle.rbs +61 -0
  74. data/sig/ryac/ast_utils.rbs +34 -0
  75. data/sig/ryac/errors.rbs +21 -0
  76. data/sig/ryac/gem_resolver.rbs +28 -0
  77. data/sig/ryac/minifier.rbs +33 -0
  78. data/sig/ryac/name_generator.rbs +14 -0
  79. data/sig/ryac/pipeline/analyzer.rbs +34 -0
  80. data/sig/ryac/pipeline/attr_decl_shorten.rbs +17 -0
  81. data/sig/ryac/pipeline/boolean_shorten.rbs +11 -0
  82. data/sig/ryac/pipeline/char_shorten.rbs +11 -0
  83. data/sig/ryac/pipeline/compactor.rbs +125 -0
  84. data/sig/ryac/pipeline/concatenator.rbs +28 -0
  85. data/sig/ryac/pipeline/constant_aliaser.rbs +35 -0
  86. data/sig/ryac/pipeline/constant_fold.rbs +20 -0
  87. data/sig/ryac/pipeline/control_flow_simplify.rbs +34 -0
  88. data/sig/ryac/pipeline/data_types.rbs +151 -0
  89. data/sig/ryac/pipeline/endless_method.rbs +14 -0
  90. data/sig/ryac/pipeline/errors.rbs +46 -0
  91. data/sig/ryac/pipeline/file_collector.rbs +34 -0
  92. data/sig/ryac/pipeline/method_renamer.rbs +25 -0
  93. data/sig/ryac/pipeline/paren_optimizer.rbs +16 -0
  94. data/sig/ryac/pipeline/rename_patcher.rbs +38 -0
  95. data/sig/ryac/pipeline/source_patcher.rbs +12 -0
  96. data/sig/ryac/pipeline/spelling_shorten.rbs +14 -0
  97. data/sig/ryac/pipeline/stage.rbs +28 -0
  98. data/sig/ryac/pipeline/stage_runner.rbs +18 -0
  99. data/sig/ryac/pipeline/variable_renamer.rbs +21 -0
  100. data/sig/ryac/union_find.rbs +17 -0
  101. data/sig/ryac.rbs +13 -0
  102. data/sig/shims/env.rbs +19 -0
  103. data/sig/shims/typeprof/core/ast.rbs +299 -0
  104. data/sig/shims/typeprof/core/type.rbs +22 -0
  105. data/sig/shims/typeprof/core.rbs +73 -0
  106. data/tools/regen_pins.rb +45 -0
  107. metadata +201 -0
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Ryac
6
+ module Pipeline
7
+ class ControlFlowSimplify < Stage
8
+ # A collapse can make the enclosing construct collapsible in turn,
9
+ # so this runs to a fixed point.
10
+ def fixpoint? = true
11
+
12
+ def collect(ctx, patches)
13
+ @tail_return_ranges = [] #: Array[[Integer, Integer]]
14
+ walk(ctx.ast, ctx.source, patches)
15
+ end
16
+
17
+ private
18
+
19
+ # statement_position: the node is a direct child of a statements list.
20
+ # A modifier rewrite is only sound there — in value position the
21
+ # modifier captures the surrounding expression: `x = if c; v; end`
22
+ # assigns nil when c is false, but `x = v if c` skips the assignment,
23
+ # leaving x's previous value. (The compactor produces exactly that
24
+ # shape from `x = c ? v : nil`.)
25
+ def walk(node, source, patches, statement_position = false)
26
+ case node
27
+ when Prism::IfNode
28
+ # an IfNode with an end keyword always has its if/elsif keyword location
29
+ if node.end_keyword_loc && !contains_tail_return_patch?(node) &&
30
+ source.byteslice(node.if_keyword_loc.start_offset, 2) == 'if' # steep:ignore NoMethod
31
+ if (replacement = try_if(node, source, statement_position))
32
+ patches << mk(node, replacement)
33
+ return
34
+ end
35
+ end
36
+ when Prism::UnlessNode
37
+ if node.end_keyword_loc && !contains_tail_return_patch?(node)
38
+ if (replacement = try_unless(node, source, statement_position))
39
+ patches << mk(node, replacement)
40
+ return
41
+ end
42
+ end
43
+ when Prism::WhileNode, Prism::UntilNode
44
+ if node.closing_loc
45
+ keyword = node.is_a?(Prism::WhileNode) ? 'while' : 'until'
46
+ if (replacement = try_loop(node, source, statement_position, keyword))
47
+ patches << mk(node, replacement)
48
+ return
49
+ end
50
+ end
51
+ when Prism::DefNode
52
+ try_tail_return(node, patches)
53
+ end
54
+ child_position = node.is_a?(Prism::StatementsNode)
55
+ node.compact_child_nodes.each { |child| walk(child, source, patches, child_position) }
56
+ end
57
+
58
+ # The value of a def is its last expression, and "last expression"
59
+ # extends through every construct whose value the def passes along:
60
+ # both arms of an if/unless, every when/in body, a begin's body,
61
+ # rescue and else. A `return expr` in any of those tail positions
62
+ # only restates the flow, so the keyword goes; `return a, b` is the
63
+ # array [a, b] spelled longer. Loops and blocks are opaque — a
64
+ # return there escapes the method for real — and a bare `return`
65
+ # is not the statement's own value, so both stay. A splat builds a
66
+ # value the bare expression would not.
67
+ def try_tail_return(node, patches)
68
+ collect_tail_returns(node.body, patches)
69
+ end
70
+
71
+ def collect_tail_returns(node, patches)
72
+ case node
73
+ when Prism::ReturnNode
74
+ args = node.arguments&.arguments
75
+ return unless args && !args.empty?
76
+ return if args.any? { |a| a.is_a?(Prism::SplatNode) }
77
+
78
+ replacement = args.size == 1 ? args[0].slice : "[#{args.map(&:slice).join(',')}]"
79
+ patches << mk(node, replacement)
80
+ @tail_return_ranges << [node.location.start_offset, node.location.end_offset]
81
+ when Prism::StatementsNode
82
+ collect_tail_returns(node.body[-1], patches)
83
+ when Prism::IfNode
84
+ collect_tail_returns(node.statements, patches)
85
+ collect_tail_returns(node.subsequent, patches)
86
+ when Prism::UnlessNode
87
+ collect_tail_returns(node.statements, patches)
88
+ collect_tail_returns(node.else_clause, patches)
89
+ when Prism::ElseNode, Prism::WhenNode, Prism::InNode
90
+ collect_tail_returns(node.statements, patches)
91
+ when Prism::CaseNode, Prism::CaseMatchNode
92
+ node.conditions.each { |c| collect_tail_returns(c, patches) }
93
+ collect_tail_returns(node.else_clause, patches)
94
+ when Prism::BeginNode
95
+ # The ensure body is not a value position, but its presence
96
+ # changes nothing about the others: it runs on both spellings.
97
+ collect_tail_returns(node.statements, patches)
98
+ collect_tail_returns(node.rescue_clause, patches)
99
+ collect_tail_returns(node.else_clause, patches)
100
+ when Prism::RescueNode
101
+ collect_tail_returns(node.statements, patches)
102
+ collect_tail_returns(node.subsequent, patches)
103
+ end
104
+ end
105
+
106
+ # A same-pass conflict guard: a construct on a tail path may itself
107
+ # be offered as a modifier/ternary rewrite while the return inside
108
+ # it is already patched. The return wins the pass; the fixpoint
109
+ # re-offers the outer rewrite on the return-free text.
110
+ def contains_tail_return_patch?(node)
111
+ s = node.location.start_offset
112
+ e = node.location.end_offset
113
+ @tail_return_ranges.any? { |rs, re| rs >= s && re <= e }
114
+ end
115
+
116
+ def try_if(node, source, statement_position)
117
+ cond = src(source, node.predicate)
118
+ stmts = node.statements
119
+
120
+ if node.subsequent.nil?
121
+ return nil unless stmts && AstUtils.single_statement_body?(stmts)
122
+ if (folded = try_sole_nested_if(node, stmts.body.first, source))
123
+ return folded
124
+ end
125
+ body = src(source, stmts)
126
+ return nil if body.include?(';')
127
+ return nil if condition_assigns_var_used_in_body?(node.predicate, stmts)
128
+ if statement_position
129
+ return nil if in_collection_context?(node, source)
130
+ "#{body} if #{cond}"
131
+ else
132
+ # As a value, the bare modifier would capture the surrounding
133
+ # expression; parenthesized it stays a self-contained nil-or-body.
134
+ "(#{body} if #{cond})"
135
+ end
136
+ else
137
+ then_body = stmts ? src(source, stmts) : nil
138
+ else_result = else_text_for_ternary(node.subsequent, source)
139
+ return nil if else_result.nil?
140
+
141
+ return nil if too_complex_for_ternary?(then_body)
142
+
143
+ then_node = stmts.body.first if stmts
144
+ return nil if then_node.is_a?(Prism::MultiWriteNode)
145
+ result = build_ternary(node.predicate, cond, then_node, then_body, else_result)
146
+ result = "(#{result})" if AstUtils.ternary_needs_parens?(node, source) || if_end_followed_by_operator?(node, source)
147
+ result
148
+ end
149
+ end
150
+
151
+ # `if a` wrapping nothing but `if b` runs the inner body exactly when
152
+ # a && b: the fold drops a whole if;end frame, and the fixpoint then
153
+ # offers the merged conditional to the modifier and ternary forms.
154
+ # Evaluation order is untouched — a, then b, then the body — so no
155
+ # assignment guard is needed. Only else-less if folds into else-less
156
+ # if: an elsif node is never a body statement, and if_keyword_loc
157
+ # rules out the ternary form.
158
+ def try_sole_nested_if(node, inner, source)
159
+ return nil unless inner.is_a?(Prism::IfNode) && inner.if_keyword_loc
160
+ return nil unless inner.subsequent.nil? && inner.statements
161
+
162
+ left = logic_operand_text(node.predicate, src(source, node.predicate))
163
+ right = logic_operand_text(inner.predicate, src(source, inner.predicate))
164
+ "if #{left}&&#{right};#{src(source, inner.statements)};end"
165
+ end
166
+
167
+ # The operand rule is the Compactor's — CFS emits into its dialect.
168
+ def logic_operand_text(pred, text)
169
+ Compactor.loose_logic_operand?(pred, tight: true) ? "(#{text})" : text
170
+ end
171
+
172
+ def else_text_for_ternary(subsequent, source)
173
+ case subsequent
174
+ # An elsif chain that just ends: the if's value is nil when no
175
+ # branch takes, and `()` says so in ternary position.
176
+ when nil
177
+ '()'
178
+ when Prism::ElseNode
179
+ stmts = subsequent.statements
180
+ text = stmts ? src(source, stmts) : nil
181
+ return nil if too_complex_for_ternary?(text)
182
+ else_node = stmts&.body&.first
183
+ return nil if else_node.is_a?(Prism::MultiWriteNode)
184
+ text = "(#{text})" if else_node && AstUtils.modifier_control_flow?(else_node)
185
+ text
186
+ when Prism::IfNode
187
+ cond = src(source, subsequent.predicate)
188
+ stmts = subsequent.statements
189
+ then_body = stmts ? src(source, stmts) : nil
190
+ return nil if too_complex_for_ternary?(then_body)
191
+
192
+ else_result = else_text_for_ternary(subsequent.subsequent, source)
193
+ return nil if else_result.nil?
194
+
195
+ then_node = stmts.body.first if stmts
196
+ # same rule as the top-level then: a multiple assignment's comma
197
+ # cannot live inside a ternary branch
198
+ return nil if then_node.is_a?(Prism::MultiWriteNode)
199
+ build_ternary(subsequent.predicate, cond, then_node, then_body, else_result)
200
+ end
201
+ end
202
+
203
+ def build_ternary(cond_node, cond, then_node, then_expr, else_expr)
204
+ if then_node && AstUtils.modifier_control_flow?(then_node)
205
+ then_expr = "(#{then_expr})"
206
+ then_node = nil
207
+ end
208
+
209
+ # and/or keywords have lower precedence than ?: — must wrap in parens
210
+ needs_parens = cond_node.is_a?(Prism::AndNode) || cond_node.is_a?(Prism::OrNode)
211
+ cond = "(#{cond})" if needs_parens
212
+
213
+ if !needs_parens && AstUtils.needs_ternary_q_space?(cond_node)
214
+ q_pre = ' '
215
+ q_post = ' '
216
+ else
217
+ q_pre = ''
218
+ q_post = ''
219
+ end
220
+
221
+ if then_node && (AstUtils.ends_with_name_char?(then_node) || AstUtils.ends_with_method_suffix?(then_node))
222
+ colon_pre = ' '
223
+ colon_post = ' '
224
+ elsif else_expr&.start_with?(':')
225
+ colon_pre = ''
226
+ colon_post = ' '
227
+ else
228
+ colon_pre = ''
229
+ colon_post = ''
230
+ end
231
+
232
+ "#{cond}#{q_pre}?#{q_post}#{then_expr}#{colon_pre}:#{colon_post}#{else_expr}"
233
+ end
234
+
235
+ def try_unless(node, source, statement_position)
236
+ stmts = node.statements
237
+ body = stmts ? src(source, stmts) : nil
238
+ cond = src(source, node.predicate)
239
+
240
+ modifier_ok = statement_position && body && !body.include?(';') &&
241
+ !condition_assigns_var_used_in_body?(node.predicate, stmts) &&
242
+ !in_collection_context?(node, source)
243
+
244
+ if AstUtils.simple_negatable?(node.predicate)
245
+ neg_cond = "!#{cond}"
246
+ if modifier_ok
247
+ "#{body} if #{neg_cond}"
248
+ else
249
+ "if #{neg_cond};#{body || ''};end"
250
+ end
251
+ elsif modifier_ok
252
+ "#{body} unless #{cond}"
253
+ end
254
+ end
255
+
256
+ def try_loop(node, source, statement_position, keyword)
257
+ return nil unless statement_position
258
+ stmts = node.statements
259
+ return nil unless stmts && AstUtils.single_statement_body?(stmts)
260
+ body = src(source, stmts)
261
+ return nil if body.include?(';')
262
+ return nil if condition_assigns_var_used_in_body?(node.predicate, stmts)
263
+ return nil if in_collection_context?(node, source)
264
+ cond = src(source, node.predicate)
265
+ "#{body} #{keyword} #{cond}"
266
+ end
267
+
268
+ def too_complex_for_ternary?(text)
269
+ text && (text.include?(';') ||
270
+ text.match?(/\A(?:return|break|next|yield) /))
271
+ end
272
+
273
+ # Modifier if/unless/while/until is invalid inside array, hash, or argument
274
+ # contexts. Check if the byte before the node (skipping whitespace) is a
275
+ # comma, open bracket, or open paren.
276
+ COLLECTION_CONTEXT_BYTES = [','.ord, '['.ord, '('.ord].freeze
277
+
278
+ def in_collection_context?(node, source)
279
+ pos = node.location.start_offset - 1
280
+ while pos >= 0
281
+ byte = source.getbyte(pos)
282
+ if byte == ' '.ord || byte == "\n".ord || byte == "\r".ord || byte == "\t".ord
283
+ pos -= 1
284
+ else
285
+ return COLLECTION_CONTEXT_BYTES.include?(byte)
286
+ end
287
+ end
288
+ false
289
+ end
290
+
291
+ def condition_assigns_var_used_in_body?(predicate, body)
292
+ assigned = collect_assigned_vars(predicate)
293
+ return false if assigned.empty?
294
+ read = collect_read_vars(body)
295
+ assigned.intersect?(read)
296
+ end
297
+
298
+ # Every node kind that binds a local: plain writes, compound writes
299
+ # (||= &&= +=), and multi-assignment targets. In the modifier form the
300
+ # body parses before the condition runs, so a local the condition
301
+ # binds reads as a method call there — any of these kinds counts.
302
+ LOCAL_BINDING_NODES = [
303
+ Prism::LocalVariableWriteNode,
304
+ Prism::LocalVariableOrWriteNode,
305
+ Prism::LocalVariableAndWriteNode,
306
+ Prism::LocalVariableOperatorWriteNode,
307
+ Prism::LocalVariableTargetNode
308
+ ].freeze
309
+
310
+ def collect_assigned_vars(node)
311
+ vars = Set.new
312
+ traverse(node) do |n|
313
+ case n
314
+ when *LOCAL_BINDING_NODES
315
+ # @type var n: Prism::LocalVariableWriteNode | Prism::LocalVariableOrWriteNode | Prism::LocalVariableAndWriteNode | Prism::LocalVariableOperatorWriteNode | Prism::LocalVariableTargetNode
316
+ vars << n.name
317
+ end
318
+ end
319
+ vars
320
+ end
321
+
322
+ def collect_read_vars(node)
323
+ vars = Set.new
324
+ traverse(node) do |n|
325
+ vars << n.name if n.is_a?(Prism::LocalVariableReadNode)
326
+ end
327
+ vars
328
+ end
329
+
330
+ def traverse(node, &block)
331
+ return unless node
332
+ yield node
333
+ node.compact_child_nodes.each { |child| traverse(child, &block) }
334
+ end
335
+
336
+ # Check if `end` keyword is followed by an operator (e.g., `if...end*(expr)`)
337
+ # In that case, ternary conversion needs parentheses.
338
+ OPERATOR_START_BYTES = [
339
+ '*'.ord, '/'.ord, '%'.ord, '+'.ord, '-'.ord, '&'.ord,
340
+ '|'.ord, '^'.ord, '<'.ord, '>'.ord, '.'.ord, '['.ord
341
+ ].freeze
342
+
343
+ def if_end_followed_by_operator?(node, source)
344
+ end_loc = node.end_keyword_loc
345
+ return false unless end_loc
346
+ after = end_loc.end_offset
347
+ return false if after >= source.bytesize
348
+ OPERATOR_START_BYTES.include?(source.getbyte(after))
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ # Represents a single source file discovered during collection
6
+ # Immutable after creation
7
+ FileEntry = Data.define(
8
+ :path, # String: Absolute path to the file
9
+ :content, # String: Raw file content
10
+ :dependencies, # Array<String>: Absolute paths of required files (top-level requires only)
11
+ :require_nodes, # Array<Hash>: {type:, path:, line:, in_class:} for require/autoload nodes
12
+ :in_class_dependencies # Array<String>: Absolute paths of files required inside class/module bodies
13
+ ) do
14
+ # declared on the Data class itself in the RBS
15
+ def initialize(path:, content:, dependencies:, require_nodes:, in_class_dependencies: []) # steep:ignore UndeclaredMethodDefinition
16
+ super
17
+ end
18
+ end
19
+
20
+ # Represents the dependency relationships between files
21
+ # Uses adjacency list for efficient topological sort
22
+ class DependencyGraph
23
+ attr_reader :files, :adjacency, :in_degrees, :rbs_files
24
+
25
+ def initialize
26
+ @files = {} # Hash<String, FileEntry>: path -> entry mapping
27
+ @adjacency = {} # Hash<String, Array<String>>: path -> dependent paths (files that require this one)
28
+ @in_degrees = {} # Hash<String, Integer>: path -> number of dependencies
29
+ @rbs_files = {} # Hash<String, String>: path -> RBS content
30
+ end
31
+
32
+ # Add a file entry to the graph
33
+ # @param entry [FileEntry] The file entry to add
34
+ def add_file(entry)
35
+ @files[entry.path] = entry
36
+ @adjacency[entry.path] ||= []
37
+ @in_degrees[entry.path] ||= 0
38
+
39
+ # Update adjacency and in-degrees based on all dependencies
40
+ (entry.dependencies + entry.in_class_dependencies).each do |dep_path|
41
+ @adjacency[dep_path] ||= []
42
+ @adjacency[dep_path] << entry.path
43
+ @in_degrees[entry.path] += 1
44
+ end
45
+ end
46
+
47
+ # Get all file paths in the graph
48
+ # @return [Array<String>]
49
+ def paths
50
+ @files.keys
51
+ end
52
+
53
+ # Get file entry by path
54
+ # @param path [String]
55
+ # @return [FileEntry, nil]
56
+ def [](path)
57
+ @files[path]
58
+ end
59
+
60
+ # Number of files in the graph
61
+ # @return [Integer]
62
+ def size
63
+ @files.size
64
+ end
65
+
66
+ # Check if graph is empty
67
+ # @return [Boolean]
68
+ def empty?
69
+ @files.empty?
70
+ end
71
+ end
72
+
73
+ # Source map entry for tracking file boundaries in concatenated output
74
+ FileBoundary = Data.define(
75
+ :path, # String: Original file path
76
+ :start_line, # Integer: 1-indexed start line in concatenated output
77
+ :end_line # Integer: 1-indexed end line in concatenated output
78
+ )
79
+
80
+ # Output of Stage 2 (Concatenator), input to Stage 3 (Minifier)
81
+ #
82
+ # Stages that rewrite the text rebuild this with #with(content: ...):
83
+ # original_size and file_boundaries describe the collected input, not the
84
+ # current text, and #with is what keeps them traveling unchanged.
85
+ ConcatenatedSource = Data.define(
86
+ :content, # String: All files joined with separators
87
+ :file_boundaries, # Array<FileBoundary>: Line ranges are true at concatenation only; the pipeline rewrites the text from compaction on
88
+ :original_size, # Integer: Bytes of the input the content descends from
89
+ :stdlib_requires, # Array<String>: Standard library requires to preserve
90
+ :rbs_files # Hash<String, String>: path -> RBS content for TypeProf
91
+ ) do
92
+ # A source built straight from a string (tests, sub-pipelines) has no
93
+ # file ancestry: everything but the content defaults away, and its
94
+ # original size is the content itself.
95
+ def initialize(content:, file_boundaries: [], original_size: nil, stdlib_requires: [], rbs_files: {}) # steep:ignore UndeclaredMethodDefinition
96
+ super(content: content, file_boundaries: file_boundaries,
97
+ original_size: original_size || content.bytesize,
98
+ stdlib_requires: stdlib_requires, rbs_files: rbs_files)
99
+ end
100
+ end
101
+
102
+ # Output of Stage 3 (Analyzer), input to Stage 4 (Minifier)
103
+ AnalysisResult = Data.define(
104
+ :prism_ast, # Prism::ProgramNode for rebuild
105
+ :scope_mappings, # Hash: cref_id -> variable mapping
106
+ :constant_mapping, # ConstantRenameMapping — short names are assigned by ConstantAliaser#collect, the only stage that reads it
107
+ :rename_map, # Hash<location_key, String>: method short names + attr coordinate adjustments
108
+ :method_alias_map, # Hash<location_key, Symbol>: method alias replacements
109
+ :method_transform_map, # Hash<location_key, String>: structural transforms (e.g. .first → [0])
110
+ :source, # ConcatenatedSource: original input preserved
111
+ :attr_rename_map, # Hash<location_key, Hash<Symbol, String>>: attr symbol renames
112
+ :block_param_names_map, # Hash<location_key, Hash<Symbol, String>>: pre-computed block param mangled names
113
+ :syntax_data, # Hash<[line,col], Hash>: Prism-derived syntax metadata
114
+ :const_resolution_map, # Hash<location_key, Array>: resolved constant cpaths
115
+ :const_full_path_map, # Hash<location_key, Array>: resolved constant paths (fallback: syntactic)
116
+ :const_write_cpath_map, # Hash<location_key, Array>: normalized write cpaths
117
+ :class_cpath_map, # Hash<location_key, Array>: class/module cpaths
118
+ :superclass_resolution_map, # Hash<location_key, Array>: resolved superclass paths
119
+ :meta_node_map, # Hash<location_key, Hash>: meta node info (attr_reader, etc.)
120
+ :local_rename_entries, # Hash<location_key, String>: local vars + lambda vars
121
+ :keyword_rename_entries, # Hash<location_key, String>: keyword arg renames
122
+ :ivar_rename_entries, # Hash<location_key, String>: instance variable renames
123
+ :attr_ivar_entries, # Hash<location_key, String>: attr-backed ivar renames (only valid when MethodRenamer runs)
124
+ :cvar_rename_entries, # Hash<location_key, String>: class variable renames
125
+ :gvar_rename_entries # Hash<location_key, String>: global variable renames
126
+ ) do
127
+ def initialize(local_rename_entries: {}, keyword_rename_entries: {}, ivar_rename_entries: {}, attr_ivar_entries: {}, cvar_rename_entries: {}, gvar_rename_entries: {}, **kwargs) # steep:ignore UndeclaredMethodDefinition
128
+ super(local_rename_entries: local_rename_entries, keyword_rename_entries: keyword_rename_entries, ivar_rename_entries: ivar_rename_entries, attr_ivar_entries: attr_ivar_entries, cvar_rename_entries: cvar_rename_entries, gvar_rename_entries: gvar_rename_entries, **kwargs)
129
+ end
130
+ end
131
+
132
+ # Compression statistics for the final output
133
+ CompressionStats = Data.define(
134
+ :original_size, # Integer: Bytes before minification
135
+ :minified_size, # Integer: Bytes after minification
136
+ :compression_ratio, # Float: 0.0-1.0 (1.0 = no compression)
137
+ :file_count # Integer: Number of files processed
138
+ )
139
+
140
+ # Internal return type of the rename stages: the parts of the program,
141
+ # not yet assembled. code deliberately carries no requires and no
142
+ # preamble — the Minifier joins the parts into MinifiedResult#content,
143
+ # which is why the field is not called content.
144
+ RenameResult = Data.define(
145
+ :code, # String: Minified Ruby code alone (no requires, no preamble)
146
+ :aliases, # String: Backward-compatible constant alias declarations (e.g. "MyClass=A;Foo=B")
147
+ :preamble # String: External prefix declarations (e.g. "A=Process") — must execute before code
148
+ ) do
149
+ def initialize(code:, aliases: '', preamble: '') # steep:ignore UndeclaredMethodDefinition
150
+ super
151
+ end
152
+ end
153
+
154
+ # Final output of the pipeline.
155
+ #
156
+ # content is the assembled runnable program: stdlib requires, then the
157
+ # preamble, then the minified code. preamble is a slice of content
158
+ # carried separately so callers that split output can identify it —
159
+ # never something to append to content again. aliases are the one part
160
+ # kept out: optional compatibility shims, attached by full_content or
161
+ # written to their own file.
162
+ MinifiedResult = Data.define(
163
+ :content, # String: requires + preamble + minified code
164
+ :aliases, # String: Backward-compatible constant alias declarations
165
+ :preamble, # String: External prefix declarations (already included in content)
166
+ :stats # CompressionStats
167
+ ) do
168
+ def initialize(content:, aliases: '', preamble: '', stats:) # steep:ignore UndeclaredMethodDefinition
169
+ super
170
+ end
171
+
172
+ # The program with its alias shims attached — what a caller runs when
173
+ # not splitting the aliases into a separate file.
174
+ def full_content # steep:ignore UndeclaredMethodDefinition
175
+ aliases.empty? ? content : "#{content};#{aliases}" # steep:ignore NoMethod
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ class EndlessMethod < Stage
6
+ # A conversion can expose another convertible def (nested singles),
7
+ # so this runs to a fixed point.
8
+ def fixpoint? = true
9
+
10
+ def collect(ctx, patches)
11
+ walk(ctx.ast, ctx.source, patches)
12
+ end
13
+
14
+ private
15
+
16
+ def walk(node, source, patches)
17
+ if node.is_a?(Prism::DefNode) && node.end_keyword_loc
18
+ if (replacement = try_def(node, source))
19
+ patches << mk(node, replacement)
20
+ return
21
+ end
22
+ end
23
+ node.compact_child_nodes.each { |child| walk(child, source, patches) }
24
+ end
25
+
26
+ def try_def(node, source)
27
+ body_node = node.body
28
+ return nil unless body_node.is_a?(Prism::StatementsNode)
29
+ return nil if AstUtils.setter_def_name?(node.name)
30
+
31
+ stmts = body_node.body
32
+ return nil if stmts.any? { |s| s.is_a?(Prism::MultiWriteNode) }
33
+
34
+ body = src(source, body_node)
35
+
36
+ if stmts.size > 1
37
+ body = "(#{body})"
38
+ elsif (stmt = stmts.first)
39
+ if keyword_logical?(stmt, source) || AstUtils.modifier_control_flow?(stmt) ||
40
+ AstUtils.match_rebind?(stmt)
41
+ body = "(#{body})"
42
+ end
43
+ end
44
+
45
+ header_end = body_node.location.start_offset
46
+ # the def header range is always inside the source, so byteslice is non-nil
47
+ header = source.byteslice(node.location.start_offset, header_end - node.location.start_offset).chomp(';') # steep:ignore NoMethod
48
+
49
+ "#{header} =#{body}"
50
+ end
51
+
52
+ def keyword_logical?(node, source)
53
+ return false unless node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
54
+
55
+ op = source.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
56
+ op == 'and' || op == 'or'
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The superclasses below resolve at load: this edge is what places their
4
+ # definitions in front of this file in the concatenated artifact.
5
+ require_relative '../errors'
6
+
7
+ module Ryac
8
+ module Pipeline
9
+ # Base class for input-side pipeline failures: the program being
10
+ # minified (or how it was invoked) is at fault.
11
+ class StageError < Ryac::InputError; end
12
+
13
+ # Raised when a required file cannot be found
14
+ class FileNotFoundError < StageError
15
+ attr_reader :path, :required_from, :line
16
+
17
+ def initialize(path, required_from: nil, line: nil)
18
+ @path = path
19
+ @required_from = required_from
20
+ @line = line
21
+
22
+ message = "File not found: #{path}"
23
+ message += " (required from #{required_from}:#{line})" if required_from && line
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ # Raised when a dynamic require or autoload is detected
29
+ class DynamicRequireError < StageError
30
+ attr_reader :path, :line, :expression
31
+
32
+ def initialize(path, line:, expression:)
33
+ @path = path
34
+ @line = line
35
+ @expression = expression
36
+
37
+ super("Dynamic require at #{path}:#{line}: #{expression}")
38
+ end
39
+ end
40
+
41
+ # Raised when a circular dependency is detected in the graph
42
+ class CircularDependencyError < StageError
43
+ attr_reader :cycle
44
+
45
+ def initialize(cycle)
46
+ @cycle = cycle
47
+
48
+ cycle_str = cycle.map { |p| File.basename(p) }.join(' → ')
49
+ super("Circular dependency: #{cycle_str}")
50
+ end
51
+ end
52
+
53
+ # Raised when no files are provided to the minifier
54
+ class NoFilesError < StageError
55
+ def initialize
56
+ super("No files provided. Please provide an entry point file.")
57
+ end
58
+ end
59
+
60
+ # Raised when a gem cannot be found or has no Ruby entry file
61
+ class GemNotFoundError < StageError
62
+ attr_reader :gem_name
63
+
64
+ def initialize(gem_name)
65
+ @gem_name = gem_name
66
+ super("Gem not found: #{gem_name}")
67
+ end
68
+ end
69
+
70
+ # Raised when the pipeline produced output that does not parse. The broken
71
+ # output is a bug in a stage, and failing here names the producer instead
72
+ # of letting the consumer discover it at load time.
73
+ class InvalidOutputError < Ryac::InternalError
74
+ def initialize(label, errors)
75
+ detail = errors.first(3).map { |e|
76
+ "#{e.location.start_line}:#{e.location.start_column}: #{e.message}"
77
+ }.join("; ")
78
+ super("minified #{label} does not parse (#{detail})")
79
+ end
80
+ end
81
+
82
+ # Raised when two different names in one scope were assigned the same
83
+ # short name. The output may still parse — `def c(a) = c(a) || d(a)` is
84
+ # valid Ruby — so this cannot be left to a syntax check.
85
+ class RenameCollisionError < Ryac::InternalError
86
+ def initialize(scope, collisions)
87
+ detail = collisions.map { |short, originals|
88
+ "#{originals.join(', ')} -> #{short}"
89
+ }.join("; ")
90
+ super("rename collision in #{scope}: #{detail}")
91
+ end
92
+ end
93
+ end
94
+ end