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,1202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ # Level 0: Compaction — full AST rebuild with no syntax optimizations.
6
+ # Removes comments and whitespace, joins with ";". Preserves original paren form.
7
+ # No renaming, no TypeProf needed. Self-contained — no shared mixins.
8
+ class Compactor
9
+ BINARY_OPERATORS = %i[== != < > <= >= + - * / % & | ^ << >> === =~ !~ <=>].freeze
10
+ NON_ASSOCIATIVE_OPS = %i[== === != =~ !~ <=>].to_set.freeze
11
+
12
+ OPERATOR_PRECEDENCE = {
13
+ :"|" => 1, :'^' => 1, :'&' => 2,
14
+ :'<=>' => 3, :== => 3, :=== => 3, :!= => 3, :=~ => 3, :'!~' => 3,
15
+ :> => 4, :>= => 4, :< => 4, :<= => 4,
16
+ :<< => 5, :>> => 5,
17
+ :+ => 6, :- => 6,
18
+ :* => 7, :/ => 7, :% => 7,
19
+ :** => 8
20
+ }.freeze
21
+
22
+ COMPOUND_WRITE_NODES = [
23
+ Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
24
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
25
+ Prism::GlobalVariableOperatorWriteNode, Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode,
26
+ Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode,
27
+ Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode,
28
+ Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode,
29
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
30
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
31
+ ].freeze
32
+
33
+ def call(input_string)
34
+ @prism_ast = Prism.parse(input_string).value
35
+ @standard_error_redefined = nil
36
+ rebuild.join(";")
37
+ end
38
+
39
+ private
40
+
41
+ # --- Dispatch ---
42
+
43
+ def rebuild
44
+ render_statement_list(@prism_ast.statements.body)
45
+ end
46
+
47
+ def r(node)
48
+ case node
49
+ when Prism::CallNode then r_call(node)
50
+ when Prism::DefNode then r_def(node)
51
+ when Prism::YieldNode then r_yield(node)
52
+ when Prism::IfNode then r_if(node)
53
+ when Prism::UnlessNode then r_unless(node)
54
+ when Prism::WhileNode then r_while(node)
55
+ when Prism::CaseNode then r_case(node)
56
+ when Prism::ReturnNode then r_return(node)
57
+ when Prism::ClassNode then r_class(node)
58
+ when Prism::ModuleNode then r_module(node)
59
+ when Prism::AndNode then r_and(node)
60
+ when Prism::FlipFlopNode then r_flipflop(node)
61
+ when Prism::OrNode then r_or(node)
62
+ when Prism::LocalVariableWriteNode then "#{node.name}=#{r(node.value)}"
63
+ when Prism::InstanceVariableWriteNode, Prism::GlobalVariableWriteNode,
64
+ Prism::ClassVariableWriteNode then "#{node.name}=#{r(node.value)}"
65
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
66
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
67
+ Prism::GlobalVariableOperatorWriteNode, Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode,
68
+ Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode,
69
+ Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode,
70
+ Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode,
71
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
72
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode
73
+ r_compound_write(node)
74
+ when Prism::ConstantPathNode
75
+ node.parent ? "#{r(node.parent)}::#{node.name}" : "::#{node.name}"
76
+ when Prism::ConstantWriteNode then "#{node.name}=#{r(node.value)}"
77
+ when Prism::ConstantPathWriteNode then "#{r(node.target)}=#{r(node.value)}"
78
+ when Prism::StringNode then r_string(node)
79
+ when Prism::IntegerNode then node.value.to_s
80
+ when Prism::FloatNode then r_float(node)
81
+ when Prism::ArrayNode then r_array(node)
82
+ when Prism::RangeNode then r_range(node)
83
+ when Prism::SymbolNode then r_symbol(node)
84
+ when Prism::HashNode then r_hash(node)
85
+ when Prism::InterpolatedStringNode then r_interp_string(node)
86
+ when Prism::InterpolatedSymbolNode then r_interp_symbol(node)
87
+ when Prism::InterpolatedRegularExpressionNode then r_interp_regexp(node)
88
+ when Prism::RegularExpressionNode then r_regexp(node)
89
+ when Prism::StatementsNode then r_stmt(node)
90
+ when Prism::ParenthesesNode then r_parens(node)
91
+ when Prism::SuperNode then r_super(node)
92
+ when Prism::BeginNode then r_begin(node)
93
+ when Prism::RescueModifierNode then "(#{r(node.expression)} rescue #{r(node.rescue_expression)})"
94
+ when Prism::BreakNode then r_break(node)
95
+ when Prism::NextNode then r_next(node)
96
+ when Prism::SplatNode then node.expression ? "*#{r_delimited(node.expression)}" : '*'
97
+ when Prism::UntilNode then r_until(node)
98
+ when Prism::AliasMethodNode then "alias #{r_alias_name(node.new_name)} #{r_alias_name(node.old_name)}"
99
+ when Prism::UndefNode then "undef #{node.names.map { |n| r_alias_name(n) }.join(',')}"
100
+ when Prism::LambdaNode then r_lambda(node)
101
+ when Prism::PostExecutionNode then "END{#{r_stmt(node.statements)}}"
102
+ when Prism::MultiWriteNode then r_multi_write(node)
103
+ when Prism::MatchWriteNode then r(node.call)
104
+ when Prism::DefinedNode then r_defined(node)
105
+ when Prism::ForNode then r_for(node)
106
+ when Prism::MatchRequiredNode then "#{r(node.value)}=>#{r_pattern(node.pattern)}"
107
+ when Prism::MatchPredicateNode then "#{r(node.value)} in #{r_pattern(node.pattern)}"
108
+ when Prism::SingletonClassNode then r_singleton_class(node)
109
+ when Prism::CaseMatchNode then r_case_match(node)
110
+ when Prism::ArrayPatternNode then r_array_pattern(node)
111
+ when Prism::HashPatternNode then r_hash_pattern(node)
112
+ when Prism::FindPatternNode then r_find_pattern(node)
113
+ when Prism::AlternationPatternNode, Prism::CapturePatternNode then r_pattern(node)
114
+ when Prism::PinnedVariableNode then "^#{r(node.variable)}"
115
+ when Prism::PinnedExpressionNode then "^(#{r_delimited(node.expression)})"
116
+ when Prism::ImplicitNode then r(node.value)
117
+ when Prism::ShareableConstantNode then r(node.write)
118
+ when Prism::CallTargetNode then "#{r(node.receiver)}.#{node.name.to_s.chomp('=')}"
119
+ when Prism::KeywordHashNode then r_keyword_hash(node)
120
+ # nil spelled as the empty grouped expression: same value, one byte
121
+ # shorter. Pattern positions reject it and go through r_pattern.
122
+ when Prism::NilNode then '()'
123
+ when Prism::TrueNode, Prism::FalseNode, Prism::SelfNode,
124
+ Prism::RedoNode, Prism::RetryNode, Prism::ForwardingSuperNode,
125
+ Prism::ItLocalVariableReadNode, Prism::NumberedReferenceReadNode,
126
+ Prism::SourceEncodingNode, Prism::SourceFileNode, Prism::SourceLineNode,
127
+ Prism::LocalVariableReadNode, Prism::LocalVariableTargetNode,
128
+ Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
129
+ Prism::ClassVariableReadNode, Prism::ConstantReadNode,
130
+ Prism::RationalNode, Prism::ImaginaryNode, Prism::MatchLastLineNode,
131
+ Prism::InterpolatedMatchLastLineNode,
132
+ Prism::BackReferenceReadNode, Prism::AliasGlobalVariableNode
133
+ node.slice
134
+ else
135
+ raise Ryac::MinifyError, "Unknown node: #{node.class}"
136
+ end
137
+ end
138
+
139
+ def r_stmt(nodes)
140
+ return '' if nodes.nil?
141
+
142
+ if nodes.is_a?(Prism::StatementsNode)
143
+ render_statement_list(nodes.body).join(';')
144
+ else
145
+ result = r(nodes)
146
+ result.nil? || result.empty? ? '' : result
147
+ end
148
+ end
149
+
150
+ # `def self.x` runs of GROUP_MIN or more become one `class<<self`
151
+ # block. Only CONSECUTIVE defs form a run — grouping across other
152
+ # statements would reorder their side effects.
153
+ GROUP_MIN = 4
154
+
155
+ def singleton_self_def?(node)
156
+ node.is_a?(Prism::DefNode) && node.receiver.is_a?(Prism::SelfNode)
157
+ end
158
+
159
+ def render_statement_list(stmts)
160
+ results = [] #: Array[String]
161
+ run = [] #: Array[Prism::DefNode]
162
+ stmts.each do |subnode|
163
+ if singleton_self_def?(subnode)
164
+ # @type var subnode: Prism::DefNode
165
+ run << subnode
166
+ next
167
+ end
168
+ results.concat(flush_singleton_run(run))
169
+ result = r(subnode)
170
+ results << result unless result.nil? || result.empty?
171
+ break if node_returns_bot?(subnode)
172
+ end
173
+ results.concat(flush_singleton_run(run))
174
+ results
175
+ end
176
+
177
+ # Renders and clears the pending run of `def self.` statements: one
178
+ # class<<self group when the run is long enough to pay for the
179
+ # wrapper, the plain renders otherwise.
180
+ def flush_singleton_run(run)
181
+ rendered = if run.size >= GROUP_MIN
182
+ inner = run.map { |n| r_def(n, drop_self_receiver: true) }
183
+ inner.reject! { |s| s.nil? || s.empty? }
184
+ ["class<<self;#{inner.join(';')};end"]
185
+ else
186
+ run.map { |n| r(n) }.reject { |s| s.nil? || s.empty? }
187
+ end
188
+ run.clear
189
+ rendered
190
+ end
191
+
192
+ # --- Method calls ---
193
+
194
+ def r_call(node)
195
+ return r_binary_op(node) if AstUtils.middle_method?(node.name)
196
+ # has_block? guarantees a BlockNode/BlockArgumentNode, so r_block_call
197
+ # cannot actually return nil here.
198
+ return r_block_call(node) if AstUtils.has_block?(node) # steep:ignore ReturnTypeMismatch
199
+ return r_index_access(node) if node.name == :'[]'
200
+ return r_index_assign(node) if node.name == :'[]='
201
+ return r_unary(node, '!') if node.name == :'!'
202
+ return r_unary(node, node.name == :'-@' ? '-' : '+') if node.name == :'-@' || node.name == :'+@'
203
+ call_op = node.safe_navigation? ? '&.' : '.'
204
+ # `x.call(args)` has proc-call sugar: `x.(args)` dispatches to the
205
+ # exact same method on any receiver. Erasing the message this early
206
+ # is sound only because :call sits in EXCLUDED_METHODS — no renamer
207
+ # ever needs to find these sites by name.
208
+ if node.name == :call && node.receiver
209
+ raw_args = node.arguments&.arguments || []
210
+ return "#{recv_with_op(node.receiver, call_op)}(#{raw_args.map { |a| r_delimited(a) }.join(',')})"
211
+ end
212
+ method_name = node.name
213
+ if setter_call?(node)
214
+ # setter_call? implies exactly one argument is present
215
+ "#{recv_with_op(node.receiver, call_op)}#{method_name}#{r(node.arguments.arguments.first)}" # steep:ignore NoMethod
216
+ else
217
+ "#{recv_with_op(node.receiver, call_op)}#{method_name}#{build_call_args(node)}"
218
+ end
219
+ end
220
+
221
+ def r_binary_op(node)
222
+ recv_str, recv_wrapped = wrap_operand(node.receiver, node.name, :left)
223
+ # binary operator calls always carry exactly one argument
224
+ arg_str, = wrap_operand(node.arguments.arguments.first, node.name, :right) # steep:ignore NoMethod
225
+ op = node.name
226
+ sep = binary_op_separator(node.receiver, recv_wrapped, op)
227
+ "#{recv_str}#{sep}#{op}#{arg_str}"
228
+ end
229
+
230
+ def r_block_call(node)
231
+ method_name = node.name
232
+ call_op = node.safe_navigation? ? '&.' : '.'
233
+ block = node.block
234
+ if block.is_a?(Prism::BlockNode)
235
+ block_params = extract_block_params(block)
236
+ uses_numbered = block_params.any? { |p| p.match?(/^_\d+$/) }
237
+ block_params_str = if block_params.empty? || uses_numbered || block_uses_it?(block.body)
238
+ ""
239
+ else
240
+ "|#{block_params.join(',')}|"
241
+ end
242
+ "#{recv_with_op(node.receiver, call_op)}#{method_name}#{build_call_args(node)}{#{block_params_str}#{r_stmt(block.body)}}"
243
+ elsif block.is_a?(Prism::BlockArgumentNode)
244
+ bp = block.expression ? "&#{r(block.expression)}" : "&"
245
+ "#{recv_with_op(node.receiver, call_op)}#{method_name}#{build_call_args(node, bp)}"
246
+ end
247
+ end
248
+
249
+ def r_index_access(node)
250
+ raw_args = node.arguments&.arguments || []
251
+ args_str = raw_args.map { |a| r_delimited(a) }.join(',')
252
+ if node.safe_navigation?
253
+ "#{r(node.receiver)}&.[](#{args_str})"
254
+ else
255
+ "#{r(node.receiver)}[#{args_str}]"
256
+ end
257
+ end
258
+
259
+ def r_index_assign(node)
260
+ raw_args = node.arguments&.arguments || []
261
+ if node.safe_navigation?
262
+ "#{r(node.receiver)}&.[]=(#{raw_args.map { |a| r_delimited(a) }.join(',')})"
263
+ else
264
+ keys = raw_args[0..-2].map { |a| r_delimited(a) }.join(',') # steep:ignore NoMethod
265
+ "#{r(node.receiver)}[#{keys}]=#{r(raw_args.last)}"
266
+ end
267
+ end
268
+
269
+ def r_unary(node, op)
270
+ recv_str = r(node.receiver)
271
+ inner = AstUtils.unwrap_statements(node.receiver)
272
+ needs_wrap = binary_operator_call?(inner) || AstUtils.logical_op?(inner)
273
+ needs_wrap ? "#{op}(#{recv_str})" : "#{op}#{recv_str}"
274
+ end
275
+
276
+ # --- Definitions ---
277
+
278
+ # drop_self_receiver: the def sits directly inside `class<<self`, so
279
+ # its written `self.` disappears; anything nested in its body renders
280
+ # through the plain path and keeps its own receiver.
281
+ def r_def(node, drop_self_receiver: false)
282
+ method_name = node.name
283
+ receiver_prefix = if node.receiver
284
+ if drop_self_receiver && node.receiver.is_a?(Prism::SelfNode)
285
+ ""
286
+ else
287
+ "#{r(node.receiver)}."
288
+ end
289
+ else
290
+ ""
291
+ end
292
+ all_params = build_def_params(node.parameters)
293
+ body_node = node.body
294
+ if body_node.is_a?(Prism::StatementsNode) && body_node.body.size == 1 &&
295
+ body_node.body.first.is_a?(Prism::ParenthesesNode)
296
+ body_node = body_node.body.first.body # steep:ignore NoMethod
297
+ end
298
+ # A sole-nil body and an empty body are the same method, and the
299
+ # empty spelling is what `def f =()` parses back into — emitting it
300
+ # directly keeps re-minification at a fixed point.
301
+ if body_node.is_a?(Prism::StatementsNode) && body_node.body.size == 1 &&
302
+ body_node.body.first.is_a?(Prism::NilNode)
303
+ body_node = nil
304
+ end
305
+ body = r_stmt(body_node)
306
+ params_str = all_params.empty? ? "" : "(#{all_params.join(',')})"
307
+ "def #{receiver_prefix}#{method_name}#{params_str}#{fmt_body(body)};end"
308
+ end
309
+
310
+ def r_lambda(node)
311
+ params_str = build_lambda_params(node.parameters)
312
+ body = r_stmt(node.body)
313
+ "->#{params_str}{#{body}}"
314
+ end
315
+
316
+ # --- Control flow ---
317
+
318
+ def r_yield(node)
319
+ args = node.arguments&.arguments || []
320
+ return 'yield' if args.empty?
321
+ "yield(#{args.map { |a| r_delimited(a) }.join(',')})"
322
+ end
323
+
324
+ def r_if(node)
325
+ then_body = r_stmt(node.statements)
326
+ else_node = node.subsequent
327
+ else_body = case else_node
328
+ when nil then nil
329
+ when Prism::IfNode then r(else_node)
330
+ when Prism::ElseNode
331
+ inner = else_node.statements ? AstUtils.unwrap_statements(else_node.statements) : nil
332
+ (inner.nil? || inner.is_a?(Prism::NilNode)) ? nil : r_stmt(else_node.statements)
333
+ end
334
+
335
+ if else_body.nil?
336
+ "if #{r(node.predicate)};#{then_body || ''};end"
337
+ elsif else_node.is_a?(Prism::IfNode)
338
+ build_if_chain(node)
339
+ else
340
+ "if #{r(node.predicate)};#{then_body || ''};else;#{else_body};end"
341
+ end
342
+ end
343
+
344
+ def r_unless(node)
345
+ then_body = r_stmt(node.statements)
346
+ "unless #{r(node.predicate)};#{then_body || ''};end"
347
+ end
348
+
349
+ def r_while(node)
350
+ cond = r(node.predicate)
351
+ body = r_stmt(node.statements)
352
+ if node.begin_modifier?
353
+ "#{body} while #{cond}"
354
+ else
355
+ "while #{cond}#{fmt_body(body)};end"
356
+ end
357
+ end
358
+
359
+ def r_until(node)
360
+ cond = r(node.predicate)
361
+ body = r_stmt(node.statements)
362
+ if node.begin_modifier?
363
+ "#{body} until #{cond}"
364
+ else
365
+ "until #{cond}#{fmt_body(body)};end"
366
+ end
367
+ end
368
+
369
+ def r_case(node)
370
+ predicate = node.predicate ? " #{r(node.predicate)}" : ''
371
+ body = "case#{predicate};" + node.conditions.map { |wn|
372
+ conditions = wn.conditions.map { |c| r_delimited(c) }.join(',')
373
+ "when #{conditions}#{fmt_body(r_stmt(wn.statements))}"
374
+ }.join(";")
375
+ if node.else_clause
376
+ eb = node.else_clause.statements ? r_stmt(node.else_clause.statements) : nil
377
+ body += ";else#{fmt_body(eb)}"
378
+ end
379
+ body + ";end"
380
+ end
381
+
382
+ def r_case_match(node)
383
+ body = "case #{r(node.predicate)};"
384
+ body += node.conditions.map { |in_node|
385
+ "in #{r_in_pattern(in_node.pattern)}#{fmt_body(r_stmt(in_node.statements))}"
386
+ }.join(";")
387
+ if node.else_clause
388
+ body += ";else#{fmt_body(r_stmt(node.else_clause.statements))}"
389
+ end
390
+ body + ";end"
391
+ end
392
+
393
+ # `in PATTERN if GUARD` parses with the guard as an If/Unless node
394
+ # wrapping the pattern — rendering that as a statement-if corrupts the
395
+ # clause into `in if guard;pattern;end`. Guards cannot nest, so the
396
+ # wrapped node is always a plain pattern.
397
+ def r_in_pattern(pattern)
398
+ case pattern
399
+ when Prism::IfNode, Prism::UnlessNode
400
+ keyword = pattern.is_a?(Prism::IfNode) ? 'if' : 'unless'
401
+ # a guard's IfNode always wraps a non-empty statements body
402
+ "#{r_pattern(pattern.statements.body[0])} #{keyword} #{r(pattern.predicate)}" # steep:ignore NoMethod
403
+ else
404
+ r_pattern(pattern)
405
+ end
406
+ end
407
+
408
+ # Patterns are their own little language: `nil` there is the nil
409
+ # pattern and `()` is a syntax error, so pattern positions dispatch
410
+ # through here. Anything that reads the same in both languages —
411
+ # other literals, constants, pins, capture targets — falls through
412
+ # to r.
413
+ def r_pattern(node)
414
+ case node
415
+ when Prism::NilNode then 'nil'
416
+ when Prism::ParenthesesNode
417
+ # pattern parens always wrap exactly one pattern
418
+ inner = AstUtils.unwrap_statements(node) #: Prism::Node
419
+ "(#{r_pattern(inner)})"
420
+ when Prism::AlternationPatternNode then "#{r_pattern(node.left)} | #{r_pattern(node.right)}"
421
+ when Prism::CapturePatternNode then "#{r_pattern(node.value)}=>#{r_multi_target(node.target)}"
422
+ else r(node)
423
+ end
424
+ end
425
+
426
+ def r_for(node)
427
+ idx = node.index
428
+ target = idx.is_a?(Prism::LocalVariableTargetNode) ? idx.name.to_s : idx.slice
429
+ "for #{target} in #{r(node.collection)}#{fmt_body(r_stmt(node.statements))};end"
430
+ end
431
+
432
+ # --- Return / Break / Next ---
433
+
434
+ MODIFIER_KEYWORD_NODES = [Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode].freeze
435
+
436
+ def r_return(node) = r_jump('return', node.arguments)
437
+ def r_break(node) = r_jump('break', node.arguments)
438
+
439
+ # A plain symbol drops its colon (`alias a b`); anything else — an
440
+ # interpolated symbol — must keep its own syntax.
441
+ def r_alias_name(node) = node.is_a?(Prism::SymbolNode) ? node.value.to_s : r(node)
442
+ def r_next(node) = r_jump('next', node.arguments)
443
+
444
+ def r_jump(keyword, arguments)
445
+ args = arguments&.arguments
446
+ return keyword if args.nil? || args.empty?
447
+ result = args.map { |a| r_delimited(a) }.join(',')
448
+ first = args.first
449
+ unwrapped = first.is_a?(Prism::ParenthesesNode) ? AstUtils.unwrap_statements(first) : first
450
+ MODIFIER_KEYWORD_NODES.any? { |t| unwrapped.is_a?(t) } ? "#{keyword}(#{result})" : "#{keyword} #{result}"
451
+ end
452
+
453
+ # --- Compound assignments ---
454
+
455
+ def r_compound_write(node)
456
+ target = case node
457
+ when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
458
+ r(node.target)
459
+ when Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
460
+ call_target(node)
461
+ when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode
462
+ idx_target(node)
463
+ else
464
+ node.name
465
+ end
466
+ # binary_operator exists exactly on the *Operator* variants selected here
467
+ op = node.class.name.include?('Operator') ? node.binary_operator : (node.class.name.include?('Or') ? '||' : '&&') # steep:ignore NoMethod
468
+ compound(target, op, node)
469
+ end
470
+
471
+ def compound(target, op, node) = "#{target}#{op}=#{r(node.value)}"
472
+
473
+ def call_target(node)
474
+ recv_str = r(node.receiver)
475
+ base = node.read_name.to_s.delete_suffix('=')
476
+ call_op = node.safe_navigation? ? '&.' : '.'
477
+ "#{recv_str}#{call_op}#{base}"
478
+ end
479
+
480
+ def idx_target(node)
481
+ keys = (node.arguments&.arguments || []).map { |a| r_delimited(a) }.join(',')
482
+ "#{r(node.receiver)}[#{keys}]"
483
+ end
484
+
485
+ # --- Literals ---
486
+
487
+ def r_symbol(node)
488
+ sym = node.value.to_sym # steep:ignore NoMethod
489
+ name = sym.to_s
490
+ if name.match?(/\A[a-zA-Z_]\w*[?!=]?\z/) || BINARY_OPERATORS.include?(sym) || %i[[] []=].include?(sym)
491
+ ":#{name}"
492
+ elsif name.start_with?('$') && !name.include?('\\')
493
+ # A backslash never survives the bare form — `:$\` does not parse —
494
+ # so those fall through to the quoted form, where the raw content
495
+ # (Prism's value keeps escapes as written) reproduces itself.
496
+ ":#{name}"
497
+ else
498
+ ":\"#{name.gsub('"', '\\"')}\""
499
+ end
500
+ end
501
+
502
+ def r_regexp(node)
503
+ content = node.content
504
+ content = escape_regexp_slash(content) unless node.opening == '/'
505
+ flags = node.closing[1..]
506
+ "/#{content}/#{flags}"
507
+ end
508
+
509
+ def escape_regexp_slash(content)
510
+ result = +""
511
+ i = 0
512
+ while i < content.size
513
+ if content[i] == '\\'
514
+ result << content[i] # steep:ignore ArgumentTypeMismatch
515
+ i += 1
516
+ result << content[i] if i < content.size # steep:ignore ArgumentTypeMismatch
517
+ i += 1
518
+ elsif content[i] == '/'
519
+ result << '\\/'
520
+ i += 1
521
+ else
522
+ result << content[i] # steep:ignore ArgumentTypeMismatch
523
+ i += 1
524
+ end
525
+ end
526
+ result
527
+ end
528
+
529
+ # --- Collections ---
530
+
531
+ # A word must survive %w/%i verbatim: no whitespace, brackets, or
532
+ # backslash (those are the notation's escapes).
533
+ PERCENT_SAFE_WORD = /\A[^\s\[\]\\]+\z/
534
+
535
+ def r_array(node)
536
+ bracket = "[#{node.elements.map { r_delimited(_1) }.join(',')}]"
537
+ percent = percent_array_form(node)
538
+ percent && percent.bytesize < bracket.bytesize ? percent : bracket
539
+ end
540
+
541
+ # The %-spelling of an all-static word array, whatever the source
542
+ # wrote — bracket, %w/%i, or a %W/%I that never interpolates. nil
543
+ # when the elements aren't uniformly plain words; the caller keeps
544
+ # whichever spelling is shorter (bracket on ties, so single-char
545
+ # strings stay available to CharShorten).
546
+ def percent_array_form(node)
547
+ elements = node.elements
548
+ words, kind = if elements.all? { |e| e.is_a?(Prism::SymbolNode) }
549
+ [elements.map { |e| e.value.to_s }, 'i'] # steep:ignore NoMethod
550
+ elsif elements.all? { |e| e.is_a?(Prism::StringNode) }
551
+ [elements.map(&:unescaped), 'w'] # steep:ignore BlockTypeMismatch
552
+ end
553
+ return nil unless words&.any?
554
+ return nil unless words.all? { |w| w.match?(PERCENT_SAFE_WORD) }
555
+
556
+ "%#{kind}[#{words.join(' ')}]"
557
+ end
558
+
559
+ # The shortest spelling that denotes the same decimal value: such a
560
+ # respelling parses to the identical double, so only the bytes change.
561
+ # One shape covers plain and exponent forms — pull the digits
562
+ # together, fold leading/trailing zeros into the exponent — and the
563
+ # final to_f comparison is a belt-and-suspenders check.
564
+ def r_float(node)
565
+ s = node.value.to_s
566
+ m = s.match(/\A(-?)(\d+)\.(\d+)(?:e([+-]?\d+))?\z/)
567
+ return s unless m
568
+
569
+ frac = m[3].to_s
570
+ exp = m[4].to_i - frac.size
571
+ digits = "#{m[2]}#{frac}".sub(/\A0+/, '')
572
+ digits = digits.sub(/0+\z/) { |z| exp += z.size; '' }
573
+ return s if digits.empty? || exp == 0
574
+
575
+ cand = "#{m[1]}#{digits}e#{exp}"
576
+ cand.size < s.size && cand.to_f == node.value ? cand : s
577
+ end
578
+
579
+ # Parenthesized by default: `..` binds below almost everything, so in
580
+ # an unknown position the parens are what keep the parse. A position
581
+ # with its own delimiters (argument lists, subscripts, array and hash
582
+ # literals, when conditions, splats, pins) takes the bare form.
583
+ def r_range(node, bare: false)
584
+ op = node.exclude_end? ? '...' : '..'
585
+ left = node.left ? r(node.left) : ''
586
+ right = node.right ? r(node.right) : ''
587
+ bare ? "#{left}#{op}#{right}" : "(#{left}#{op}#{right})"
588
+ end
589
+
590
+ def r_delimited(node)
591
+ inner = AstUtils.unwrap_statements(node)
592
+ inner.is_a?(Prism::RangeNode) ? r_range(inner, bare: true) : r(node)
593
+ end
594
+
595
+ # A flip-flop keeps its operands parenthesized — their own rendering,
596
+ # not a slice of the source, so interior spacing compacts like
597
+ # everything else.
598
+ def r_flipflop(node)
599
+ op = node.exclude_end? ? '...' : '..'
600
+ left = node.left ? "(#{r(node.left)})" : ''
601
+ right = node.right ? "(#{r(node.right)})" : ''
602
+ "#{left}#{op}#{right}"
603
+ end
604
+
605
+ def r_hash(node)
606
+ "{" + node.elements.map { |e|
607
+ e.is_a?(Prism::AssocSplatNode) ? "**#{r(e.value)}" : r_assoc(e)
608
+ }.join(',') + "}"
609
+ end
610
+
611
+ def r_keyword_hash(node)
612
+ node.elements.map { |e|
613
+ e.is_a?(Prism::AssocSplatNode) ? "**#{r(e.value)}" : r_assoc(e)
614
+ }.join(',')
615
+ end
616
+
617
+ def r_assoc(element)
618
+ key = element.key
619
+ val = element.value
620
+ val_str = r_delimited(val)
621
+ if key.is_a?(Prism::SymbolNode) && key.value.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
622
+ key_name = key.value.to_s
623
+ return "#{key_name}:" if val.is_a?(Prism::ImplicitNode)
624
+ sep = val_str.start_with?(':') ? ': ' : ':'
625
+ "#{key_name}#{sep}#{val_str}"
626
+ else
627
+ key_str = r(key)
628
+ separator = if key.is_a?(Prism::SymbolNode) && !key.value.to_s.match?(/[a-zA-Z0-9_]\z/)
629
+ ' =>'
630
+ else
631
+ '=>'
632
+ end
633
+ "#{key_str}#{separator}#{val_str}"
634
+ end
635
+ end
636
+
637
+ # --- Strings ---
638
+
639
+ def r_string(node)
640
+ if node.opening == '"' || node.opening.nil?
641
+ "\"#{node.content}\""
642
+ else
643
+ node.unescaped.inspect
644
+ end
645
+ end
646
+
647
+ # --- Interpolation ---
648
+
649
+ def r_interp_string(node)
650
+ needs_escape = node.opening != '"'
651
+ "\"" + node.parts.map { |p| interp_part(p, escape_quotes: needs_escape) }.join + "\""
652
+ end
653
+
654
+ def r_interp_symbol(node)
655
+ ":\"" + node.parts.map { |p| interp_part(p) }.join + "\""
656
+ end
657
+
658
+ def r_interp_regexp(node)
659
+ needs_slash_escape = node.opening != '/'
660
+ parts = node.parts.map { |p|
661
+ if needs_slash_escape && p.is_a?(Prism::StringNode)
662
+ escape_regexp_slash(p.content)
663
+ else
664
+ interp_part(p)
665
+ end
666
+ }
667
+ "/" + parts.join + "/#{node.closing[1..]}"
668
+ end
669
+
670
+ def interp_part(part, escape_quotes: false)
671
+ case part
672
+ when Prism::StringNode
673
+ if escape_quotes
674
+ escape_for_dquote(part.unescaped)
675
+ else
676
+ part.content
677
+ end
678
+ when Prism::EmbeddedStatementsNode then "\#{#{r_stmt(part.statements)}}"
679
+ when Prism::EmbeddedVariableNode then "\#{#{r(part.variable)}}"
680
+ else "\#{#{r(part)}}"
681
+ end
682
+ end
683
+
684
+ def escape_for_dquote(str)
685
+ result = +""
686
+ i = 0
687
+ while i < str.size
688
+ c = str[i]
689
+ case c
690
+ when '\\' then result << '\\\\'
691
+ when '"' then result << '\\"'
692
+ when "\n" then result << '\\n'
693
+ when "\t" then result << '\\t'
694
+ when "\r" then result << '\\r'
695
+ when "\0" then result << '\\0'
696
+ when '#'
697
+ if i + 1 < str.size && '{@$'.include?(str[i + 1]) # steep:ignore ArgumentTypeMismatch
698
+ result << '\\#'
699
+ else
700
+ result << '#'
701
+ end
702
+ else
703
+ result << c # steep:ignore ArgumentTypeMismatch
704
+ end
705
+ i += 1
706
+ end
707
+ result
708
+ end
709
+
710
+ # --- Classes / Modules ---
711
+
712
+ def r_class(node)
713
+ class_name = node.constant_path.slice
714
+ superclass_str = node.superclass ? "<#{r(node.superclass)}" : ''
715
+ "class #{class_name}#{superclass_str}#{fmt_body(r_stmt(node.body))};end"
716
+ end
717
+
718
+ def r_module(node)
719
+ "module #{node.constant_path.slice}#{fmt_body(r_stmt(node.body))};end"
720
+ end
721
+
722
+ def r_singleton_class(node)
723
+ "class<<#{r(node.expression)}#{fmt_body(r_stmt(node.body))};end"
724
+ end
725
+
726
+ # --- Logic ---
727
+
728
+ # Always the operator spelling. The keyword forms bind below
729
+ # assignment, so `and`/`or` accept operands `&&`/`||` cannot take
730
+ # bare — assignments, jumps with a value, control expressions — and
731
+ # those operands are parenthesized here. Rendering the keyword would
732
+ # need the surrounding position to be known (`z=(a and b)` unwrapped
733
+ # is a different program), and the renderer is position-free.
734
+ def r_and(node)
735
+ "#{logic_operand(node.left, tight: true)}&&#{logic_operand(node.right, tight: true)}"
736
+ end
737
+
738
+ def r_or(node)
739
+ "#{logic_operand(node.left, tight: false)}||#{logic_operand(node.right, tight: false)}"
740
+ end
741
+
742
+ def logic_operand(operand, tight:)
743
+ # Classified through source parens: `x&&(y||z)` arrives as a
744
+ # ParenthesesNode operand whose parens must survive.
745
+ inner = AstUtils.unwrap_statements(operand)
746
+ Compactor.loose_logic_operand?(inner, tight: tight) ? "(#{r(inner)})" : r(operand)
747
+ end
748
+
749
+ # --- Error handling ---
750
+
751
+ def r_begin(node)
752
+ parts = ["begin#{fmt_body(r_stmt(node.statements))}"]
753
+ rescue_node = node.rescue_clause
754
+ while rescue_node
755
+ rp = ';rescue'
756
+ exceptions = rescue_node.exceptions || []
757
+ # `rescue StandardError` is what a bare rescue already means —
758
+ # unless the program redefines StandardError, when the bare form
759
+ # would resolve differently.
760
+ unless exceptions.empty? || (bare_standard_error?(exceptions) && !standard_error_redefined?)
761
+ rp += " #{exceptions.map { |e| r(e) }.join(',')}"
762
+ end
763
+ if rescue_node.reference && rescue_var_used?(rescue_node)
764
+ # the reference is a name-bearing target node in valid rescue clauses
765
+ rp += "=>#{rescue_node.reference.name}" # steep:ignore NoMethod
766
+ end
767
+ parts << "#{rp}#{fmt_body(r_stmt(rescue_node.statements))}"
768
+ rescue_node = rescue_node.subsequent
769
+ end
770
+ parts << ";else#{fmt_body(r_stmt(node.else_clause.statements))}" if node.else_clause
771
+ parts << ";ensure#{fmt_body(r_stmt(node.ensure_clause.statements))}" if node.ensure_clause
772
+ parts << ';end'
773
+ parts.join
774
+ end
775
+
776
+ def bare_standard_error?(exceptions)
777
+ exceptions.size == 1 &&
778
+ exceptions[0].is_a?(Prism::ConstantReadNode) &&
779
+ exceptions[0].name == :StandardError
780
+ end
781
+
782
+ # Lazy: only a literal `rescue StandardError` clause ever needs the
783
+ # answer, so most programs never pay for the walk. Any definition-ish
784
+ # node whose written name is StandardError counts — over-matching a
785
+ # nested Foo::StandardError only forgoes a few bytes.
786
+ def standard_error_redefined?
787
+ # a non-nil memo is always one of the two bools
788
+ return @standard_error_redefined unless @standard_error_redefined.nil? # steep:ignore ReturnTypeMismatch
789
+
790
+ found = false
791
+ AstUtils.each_node(@prism_ast) do |node|
792
+ case node
793
+ when Prism::ClassNode
794
+ path = node.constant_path
795
+ name = path.is_a?(Prism::ConstantReadNode) || path.is_a?(Prism::ConstantPathNode) ? path.name : nil
796
+ found = true if name == :StandardError
797
+ when Prism::ConstantWriteNode, Prism::ConstantTargetNode
798
+ found = true if node.name == :StandardError
799
+ when Prism::ConstantPathWriteNode
800
+ found = true if node.target.name == :StandardError
801
+ when Prism::ConstantPathTargetNode
802
+ found = true if node.name == :StandardError
803
+ end
804
+ end
805
+ @standard_error_redefined = found
806
+ end
807
+
808
+ # --- Other ---
809
+
810
+ def r_super(node)
811
+ args = node.arguments&.arguments&.map { |a| r_delimited(a) } || []
812
+ args_str = args.join(',')
813
+ args_str.empty? ? 'super()' : "super(#{args_str})"
814
+ end
815
+
816
+ def r_defined(node)
817
+ "defined?(#{r(node.value)})"
818
+ end
819
+
820
+ # --- Multi-write ---
821
+
822
+ def r_multi_write(node)
823
+ targets = node.lefts.map { |t| r_multi_target(t) }
824
+ if node.rest
825
+ case node.rest
826
+ when Prism::SplatNode
827
+ targets << (node.rest.expression ? "*#{r_multi_target(node.rest.expression)}" : '*')
828
+ when Prism::ImplicitRestNode
829
+ targets << '*'
830
+ end
831
+ end
832
+ targets.concat(node.rights.map { |t| r_multi_target(t) })
833
+ value = node.value
834
+ sep = targets.last&.end_with?('*') ? ' ' : ''
835
+ if value.is_a?(Prism::ArrayNode) && (value.opening_loc.nil? || value.elements.size >= 2)
836
+ elements = value.elements.map { |e| r(e) }
837
+ "#{targets.join(',')}#{sep}=#{elements.join(',')}"
838
+ else
839
+ "#{targets.join(',')}#{sep}=#{r(value)}"
840
+ end
841
+ end
842
+
843
+ def r_multi_target(target)
844
+ case target
845
+ when Prism::LocalVariableTargetNode, Prism::InstanceVariableTargetNode,
846
+ Prism::ClassVariableTargetNode, Prism::GlobalVariableTargetNode
847
+ target.name.to_s
848
+ when Prism::ConstantTargetNode
849
+ target.name.to_s
850
+ when Prism::ConstantPathTargetNode
851
+ target.parent ? "#{r(target.parent)}::#{target.name}" : "::#{target.name}"
852
+ when Prism::MultiTargetNode
853
+ parts = target.lefts.map { |t| r_multi_target(t) }
854
+ if target.rest
855
+ case target.rest
856
+ when Prism::SplatNode
857
+ parts << (target.rest.expression ? "*#{r_multi_target(target.rest.expression)}" : '*')
858
+ when Prism::ImplicitRestNode
859
+ parts << '*'
860
+ end
861
+ end
862
+ parts.concat(target.rights.map { |t| r_multi_target(t) })
863
+ parts.join(',')
864
+ when Prism::IndexTargetNode
865
+ args = target.arguments&.arguments || []
866
+ "#{r(target.receiver)}[#{args.map { |a| r(a) }.join(',')}]"
867
+ when Prism::SplatNode
868
+ target.expression ? "*#{r_multi_target(target.expression)}" : '*'
869
+ else
870
+ r(target)
871
+ end
872
+ end
873
+
874
+ # --- Pattern matching ---
875
+
876
+ def r_array_pattern(node)
877
+ parts = node.requireds.map { |n| r_pattern(n) }
878
+ if node.rest
879
+ parts << if node.rest.is_a?(Prism::SplatNode) && node.rest.expression
880
+ "*#{r(node.rest.expression)}"
881
+ else
882
+ '*'
883
+ end
884
+ end
885
+ parts.concat(node.posts.map { |p| r_pattern(p) })
886
+ "[#{parts.join(',')}]"
887
+ end
888
+
889
+ def r_hash_pattern(node)
890
+ # hash-pattern keys are always plain SymbolNodes
891
+ parts = node.elements.map { |a| "#{a.key.value}: #{r_pattern(a.value)}" } # steep:ignore NoMethod
892
+ if node.rest
893
+ case node.rest
894
+ when Prism::AssocSplatNode
895
+ parts << (node.rest.value ? "**#{r(node.rest.value)}" : '**')
896
+ when Prism::NoKeywordsParameterNode
897
+ parts << '**nil'
898
+ end
899
+ end
900
+ "{#{parts.join(',')}}"
901
+ end
902
+
903
+ def r_find_pattern(node)
904
+ parts = [] #: Array[String]
905
+ if node.left
906
+ parts << if node.left.is_a?(Prism::SplatNode) && node.left.expression
907
+ "*#{r(node.left.expression)}"
908
+ else
909
+ '*'
910
+ end
911
+ end
912
+ parts.concat(node.requireds.map { |n| r_pattern(n) })
913
+ if node.right
914
+ parts << if node.right.is_a?(Prism::SplatNode) && node.right.expression
915
+ "*#{r(node.right.expression)}"
916
+ else
917
+ '*'
918
+ end
919
+ end
920
+ "[#{parts.join(',')}]"
921
+ end
922
+
923
+ # --- Helpers ---
924
+
925
+ def build_call_args(node, block_pass = nil)
926
+ raw_args = node.arguments&.arguments || []
927
+ args = [] #: Array[String]
928
+ raw_args.each do |arg|
929
+ case arg
930
+ when Prism::SplatNode
931
+ args << (arg.expression ? "*#{r_delimited(arg.expression)}" : '*')
932
+ when Prism::KeywordHashNode
933
+ arg.elements.each do |el|
934
+ args << (el.is_a?(Prism::AssocSplatNode) ? "**#{r(el.value)}" : r_assoc(el))
935
+ end
936
+ when Prism::ForwardingArgumentsNode
937
+ args << '...'
938
+ else
939
+ args << r_delimited(arg)
940
+ end
941
+ end
942
+ args << block_pass if block_pass
943
+ return '' if args.empty?
944
+ "(#{args.join(',')})"
945
+ end
946
+
947
+ def build_def_params(params)
948
+ return [] unless params
949
+
950
+ req = (params.requireds || []).map { |p|
951
+ case p
952
+ when Prism::RequiredParameterNode then p.name.to_s
953
+ when Prism::MultiTargetNode then r_multi_target_param(p)
954
+ else p.slice
955
+ end
956
+ }
957
+ opt = (params.optionals || []).map { |p| "#{p.name}=#{r(p.value)}" }
958
+ rest = if params.rest.is_a?(Prism::RestParameterNode)
959
+ [params.rest.name ? "*#{params.rest.name}" : '*']
960
+ else
961
+ [] #: Array[String]
962
+ end
963
+ post = (params.posts || []).map { |p|
964
+ p.is_a?(Prism::RequiredParameterNode) ? p.name.to_s : p.slice
965
+ }
966
+ req_kw = [] #: Array[String]
967
+ opt_kw = [] #: Array[String]
968
+ (params.keywords || []).each do |p|
969
+ case p
970
+ when Prism::RequiredKeywordParameterNode then req_kw << "#{p.name}:"
971
+ when Prism::OptionalKeywordParameterNode
972
+ val = r(p.value)
973
+ sep = val.start_with?(':') ? ' ' : ''
974
+ opt_kw << "#{p.name}:#{sep}#{val}"
975
+ end
976
+ end
977
+ rest_kw = if params.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
978
+ [params.keyword_rest.name ? "**#{params.keyword_rest.name}" : '**']
979
+ elsif params.keyword_rest.is_a?(Prism::NoKeywordsParameterNode)
980
+ ['**nil']
981
+ elsif params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
982
+ ['...']
983
+ else
984
+ [] #: Array[String]
985
+ end
986
+ block_param = if params.block.is_a?(Prism::BlockParameterNode)
987
+ [params.block.name ? "&#{params.block.name}" : '&']
988
+ else
989
+ [] #: Array[String]
990
+ end
991
+ req + opt + rest + post + req_kw + opt_kw + rest_kw + block_param
992
+ end
993
+
994
+ def r_multi_target_param(target)
995
+ parts = target.lefts.map { |t|
996
+ case t
997
+ when Prism::RequiredParameterNode then t.name.to_s
998
+ when Prism::MultiTargetNode then r_multi_target_param(t)
999
+ else t.slice
1000
+ end
1001
+ }
1002
+ if target.rest
1003
+ parts << (target.rest.is_a?(Prism::SplatNode) && target.rest.expression ? "*#{target.rest.expression.slice}" : '*')
1004
+ end
1005
+ parts.concat(target.rights.map { |t| t.respond_to?(:name) ? t.name.to_s : t.slice }) # steep:ignore NoMethod
1006
+ "(#{parts.join(',')})"
1007
+ end
1008
+
1009
+ def build_lambda_params(block_params)
1010
+ return "" unless block_params.is_a?(Prism::BlockParametersNode)
1011
+ inner = block_params.parameters
1012
+ return "()" unless inner
1013
+ all = build_def_params(inner)
1014
+ all.empty? ? "" : "(#{all.join(',')})"
1015
+ end
1016
+
1017
+ def extract_block_params(block)
1018
+ return [] unless block.parameters.is_a?(Prism::BlockParametersNode) && block.parameters.parameters
1019
+ build_def_params(block.parameters.parameters)
1020
+ end
1021
+
1022
+ def recv_with_op(recv_node, call_op)
1023
+ return '' if recv_node.nil?
1024
+ recv_str = r(recv_node)
1025
+ inner = AstUtils.unwrap_statements(recv_node)
1026
+ # Unary calls must keep their parens under a chain: `(-vec).to_a`
1027
+ # stripped to `-vec.to_a` rebinds as `-(vec.to_a)` — String#-@ even
1028
+ # makes that run silently instead of failing.
1029
+ needs_wrap = binary_operator_call?(inner) || AstUtils.logical_op?(inner) ||
1030
+ inner.is_a?(Prism::TrueNode) || inner.is_a?(Prism::FalseNode) ||
1031
+ (inner.is_a?(Prism::CallNode) && %i[! -@ +@].include?(inner.name))
1032
+ needs_wrap ? "(#{recv_str})#{call_op}" : "#{recv_str}#{call_op}"
1033
+ end
1034
+
1035
+ def wrap_operand(node, parent_op, side)
1036
+ return ['', true] if node.nil?
1037
+ str = r(node)
1038
+ inner = AstUtils.unwrap_statements(node)
1039
+ return ["(#{str})", true] if AstUtils.logical_op?(inner)
1040
+ # ** binds tighter than unary minus: `(-a)**b` must not become -a**b.
1041
+ if parent_op == :** && inner.is_a?(Prism::CallNode) && %i[-@ +@].include?(inner.name)
1042
+ return ["(#{str})", true]
1043
+ end
1044
+ if binary_operator_call?(inner)
1045
+ pp = OPERATOR_PRECEDENCE[parent_op]
1046
+ # binary_operator_call? has established inner is a CallNode
1047
+ cp = OPERATOR_PRECEDENCE[inner.name] # steep:ignore NoMethod
1048
+ if pp && cp
1049
+ return ["(#{str})", true] if cp < pp
1050
+ return ["(#{str})", true] if cp == pp && side == :right
1051
+ return ["(#{str})", true] if cp == pp && NON_ASSOCIATIVE_OPS.include?(parent_op)
1052
+ end
1053
+ end
1054
+ [str, false]
1055
+ end
1056
+
1057
+ def binary_op_separator(recv_node, recv_wrapped, op)
1058
+ return '' if recv_wrapped
1059
+ inner = AstUtils.unwrap_statements(recv_node)
1060
+ if op == :'!~'
1061
+ AstUtils.ends_with_name_char?(inner) ? ' ' : ''
1062
+ elsif op.to_s.start_with?('=') && inner.is_a?(Prism::CallNode) && inner.name.to_s.end_with?('?', '!')
1063
+ ' '
1064
+ else
1065
+ ''
1066
+ end
1067
+ end
1068
+
1069
+ def build_if_chain(node)
1070
+ parts = [] #: Array[String]
1071
+ current = node
1072
+ keyword = "if"
1073
+ loop do
1074
+ inner = current.statements ? AstUtils.unwrap_statements(current.statements) : nil
1075
+ then_body = (inner.nil? || inner.is_a?(Prism::NilNode)) ? nil : r_stmt(current.statements)
1076
+ parts << "#{keyword} #{r(current.predicate)}#{fmt_body(then_body)}"
1077
+ subsequent = current.subsequent
1078
+ if subsequent.is_a?(Prism::IfNode)
1079
+ current = subsequent
1080
+ keyword = "elsif"
1081
+ else
1082
+ if subsequent.is_a?(Prism::ElseNode)
1083
+ inner = subsequent.statements ? AstUtils.unwrap_statements(subsequent.statements) : nil
1084
+ unless inner.nil? || inner.is_a?(Prism::NilNode)
1085
+ eb = r_stmt(subsequent.statements)
1086
+ parts << "else#{fmt_body(eb)}" if eb && !eb.empty?
1087
+ end
1088
+ end
1089
+ break
1090
+ end
1091
+ end
1092
+ parts.join(";") + ";end"
1093
+ end
1094
+
1095
+ ASSIGNMENT_NODES = [
1096
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
1097
+ Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode,
1098
+ Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::MultiWriteNode,
1099
+ *COMPOUND_WRITE_NODES,
1100
+ ].freeze
1101
+
1102
+ # Match nodes keep parens like assignments do: an endless def body of
1103
+ # `(value in pattern)` stripped bare rebinds the `in` to the def
1104
+ # statement itself and no longer parses.
1105
+ PARENS_KEPT_NODES = [*ASSIGNMENT_NODES, *AstUtils::MATCH_REBIND_NODES].freeze
1106
+
1107
+ PARENS_KEPT_CLASSES = PARENS_KEPT_NODES.to_set.freeze
1108
+
1109
+ JUMP_NODES = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode].freeze
1110
+
1111
+ # Operand classes that bind at or below the keyword logic forms —
1112
+ # bare under && or || they would re-associate or fail to parse.
1113
+ # Derived from the paren-keeping and modifier-keyword families, so a
1114
+ # class added there joins this rule with it.
1115
+ LOGIC_LOOSE_OPERANDS = [
1116
+ *PARENS_KEPT_NODES, *MODIFIER_KEYWORD_NODES,
1117
+ Prism::CaseNode, Prism::CaseMatchNode, Prism::ForNode, Prism::BeginNode,
1118
+ Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
1119
+ Prism::FlipFlopNode, Prism::LambdaNode
1120
+ ].freeze
1121
+ LOGIC_LOOSE_OPERAND_CLASSES = LOGIC_LOOSE_OPERANDS.to_set.freeze
1122
+
1123
+ # A jump is loose only when it carries a value: `x&&return` parses,
1124
+ # `x&&return 5` does not.
1125
+ def self.jump_with_value?(node)
1126
+ JUMP_NODES.include?(node.class) && !node.arguments.nil? # steep:ignore NoMethod
1127
+ end
1128
+
1129
+ # The one owner of "does this operand need parens under && / ||".
1130
+ # ControlFlowSimplify asks the same question when it folds nested
1131
+ # conditionals into the compacted dialect. An OrNode operand needs
1132
+ # parens under && but not under ||. Prism node classes are concrete,
1133
+ # so a class-keyed Set replaces an is_a? scan.
1134
+ def self.loose_logic_operand?(node, tight:)
1135
+ LOGIC_LOOSE_OPERAND_CLASSES.include?(node.class) ||
1136
+ (tight && node.is_a?(Prism::OrNode)) ||
1137
+ jump_with_value?(node)
1138
+ end
1139
+
1140
+ def r_parens(node)
1141
+ body = r_stmt(node.body)
1142
+ # Empty parens ARE the nil literal this renderer emits — keeping
1143
+ # them (instead of collapsing to '') is what makes a second pass
1144
+ # over already-minified source round-trip.
1145
+ return '()' if body.empty?
1146
+ inner = AstUtils.unwrap_statements(node)
1147
+ keep = (node.body.is_a?(Prism::StatementsNode) && node.body.body.size > 1) ||
1148
+ PARENS_KEPT_CLASSES.include?(inner.class) ||
1149
+ # `(return 5)` bare after `&&` (or another operator) fails
1150
+ # to parse; a valueless jump is fine bare.
1151
+ Compactor.jump_with_value?(inner)
1152
+ keep ? "(#{body})" : body
1153
+ end
1154
+
1155
+ def fmt_body(body) = body.nil? || body.empty? ? "" : ";#{body}"
1156
+
1157
+ def binary_operator_call?(node)
1158
+ node.is_a?(Prism::CallNode) && node.receiver &&
1159
+ node.arguments&.arguments&.size == 1 && BINARY_OPERATORS.include?(node.name)
1160
+ end
1161
+
1162
+ def setter_call?(node)
1163
+ name = node.name.to_s
1164
+ node.receiver && name.end_with?('=') && name.size > 1 &&
1165
+ !%w[== != <= >= ===].include?(name) && name != '[]='
1166
+ end
1167
+
1168
+ def node_returns_bot?(node)
1169
+ case node
1170
+ when Prism::ReturnNode, Prism::BreakNode, Prism::NextNode then true
1171
+ when Prism::CallNode
1172
+ # Only the Kernel spellings end the flow; `logger.raise(1)` is an
1173
+ # ordinary method call.
1174
+ node.receiver.nil? && (node.name == :raise || node.name == :fail)
1175
+ else false
1176
+ end
1177
+ end
1178
+
1179
+ def rescue_var_used?(rescue_node)
1180
+ # only called when the (name-bearing) reference is present
1181
+ var = rescue_node.reference.name # steep:ignore NoMethod
1182
+ return false unless rescue_node.statements
1183
+ prism_traverse(rescue_node.statements) { |n|
1184
+ return true if n.is_a?(Prism::LocalVariableReadNode) && n.name == var
1185
+ }
1186
+ false
1187
+ end
1188
+
1189
+ def block_uses_it?(body)
1190
+ return false unless body
1191
+ prism_traverse(body) { |n| return true if n.is_a?(Prism::ItLocalVariableReadNode) }
1192
+ false
1193
+ end
1194
+
1195
+ def prism_traverse(node, &block)
1196
+ return unless node
1197
+ yield node
1198
+ node.compact_child_nodes.each { |child| prism_traverse(child, &block) }
1199
+ end
1200
+ end
1201
+ end
1202
+ end