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,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Ryac
6
+ module Pipeline
7
+ # Stage 1: File Collection
8
+ # Discovers all dependencies via static analysis of require/require_relative/autoload
9
+ class FileCollector
10
+ # @param entry_path [String, Array<String>] Path(s) to entry point file(s)
11
+ # @return [DependencyGraph] Graph of all discovered files
12
+ # @raise [FileNotFoundError] If a required file doesn't exist
13
+ # @raise [NoFilesError] If entry_path is nil or empty
14
+ # @raise [DynamicRequireError] If a dynamic require is detected
15
+ def call(entry_path, project_root: nil, gem_names: [], gem_require_paths: [])
16
+ raise NoFilesError.new if entry_path.nil?
17
+
18
+ entry_paths = Array(entry_path) #: Array[String]
19
+ raise NoFilesError.new if entry_paths.empty?
20
+
21
+ @graph = DependencyGraph.new
22
+ @visited = Set.new
23
+ @gem_names = gem_names
24
+ @project_roots = if project_root
25
+ # Array() leaves only Strings whichever of the two shapes came in
26
+ Array(project_root).map { |p| File.expand_path(p) } # steep:ignore ArgumentTypeMismatch
27
+ else
28
+ root = find_project_root(entry_paths)
29
+ root ? [root] : []
30
+ end
31
+
32
+ ensure_load_paths(gem_require_paths)
33
+
34
+ entry_paths.each do |path|
35
+ expanded = File.expand_path(path)
36
+ raise FileNotFoundError.new(expanded) unless File.exist?(expanded)
37
+ collect_file(expanded)
38
+ end
39
+
40
+ collect_rbs_files(entry_paths)
41
+ @graph
42
+ end
43
+
44
+ private
45
+
46
+ # Recursively collect a file and its dependencies
47
+ def collect_file(file_path, required_from: nil, line: nil)
48
+ return if @visited.include?(file_path)
49
+
50
+ unless File.exist?(file_path)
51
+ raise FileNotFoundError.new(file_path, required_from: required_from, line: line)
52
+ end
53
+
54
+ @visited.add(file_path)
55
+ # Ruby source defaults to UTF-8; reading with the ambient locale instead
56
+ # tags non-ASCII sources US-ASCII under a POSIX locale and every later
57
+ # String operation raises on the invalid bytes.
58
+ content = File.read(file_path, encoding: Encoding::UTF_8)
59
+
60
+ # Parse and extract require statements
61
+ require_nodes = extract_require_nodes(file_path, content)
62
+ dependencies = [] #: Array[String]
63
+ in_class_dependencies = [] #: Array[String]
64
+
65
+ require_nodes.each do |node_info|
66
+ dep_path = node_info[:resolved_path]
67
+ next unless dep_path
68
+
69
+ if node_info[:in_class]
70
+ in_class_dependencies << dep_path
71
+ else
72
+ dependencies << dep_path
73
+ end
74
+ collect_file(dep_path, required_from: file_path, line: node_info[:line])
75
+ end
76
+
77
+ entry = FileEntry.new(
78
+ path: file_path,
79
+ content: content,
80
+ dependencies: dependencies,
81
+ in_class_dependencies: in_class_dependencies,
82
+ require_nodes: require_nodes
83
+ )
84
+
85
+ @graph.add_file(entry)
86
+ end
87
+
88
+ # Extract require/require_relative/autoload nodes from source
89
+ def extract_require_nodes(file_path, content)
90
+ result = Prism.parse(content)
91
+ # Collection is the only point that still knows which file a byte
92
+ # came from — a syntax error surfaces here with real coordinates,
93
+ # or downstream as a nameless internal failure.
94
+ error = result.errors[0]
95
+ if error
96
+ raise Ryac::SyntaxError.new(error.message, path: file_path,
97
+ line: error.location.start_line,
98
+ column: error.location.start_column)
99
+ end
100
+ nodes = [] #: Array[require_node_info]
101
+
102
+ traverse_for_requires(result.value, nodes, file_path)
103
+
104
+ nodes
105
+ end
106
+
107
+ # Traverse AST to find require statements
108
+ # @param in_method [Boolean] true when inside a DefNode body; dynamic requires are skipped
109
+ def traverse_for_requires(node, nodes, file_path, in_method: false, in_class: false)
110
+ return unless node
111
+
112
+ case node
113
+ when Prism::CallNode
114
+ handle_call_node(node, nodes, file_path, in_method: in_method, in_class: in_class)
115
+ when Prism::ProgramNode
116
+ traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class)
117
+ when Prism::StatementsNode
118
+ node.body.each { |child| traverse_for_requires(child, nodes, file_path, in_method: in_method, in_class: in_class) }
119
+ when Prism::ClassNode, Prism::ModuleNode
120
+ traverse_for_requires(node.body, nodes, file_path, in_method: in_method, in_class: true)
121
+ when Prism::DefNode
122
+ traverse_for_requires(node.body, nodes, file_path, in_method: true, in_class: in_class)
123
+ when Prism::IfNode
124
+ traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class)
125
+ traverse_for_requires(node.subsequent, nodes, file_path, in_method: in_method, in_class: in_class)
126
+ when Prism::BeginNode
127
+ traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class)
128
+ end
129
+ end
130
+
131
+ def handle_call_node(node, nodes, file_path, in_method: false, in_class: false)
132
+ method_name = node.name
133
+
134
+ case method_name
135
+ when :require_relative
136
+ handle_require_relative(node, nodes, file_path, in_method: in_method, in_class: in_class)
137
+ when :require
138
+ handle_require(node, nodes, file_path, in_method: in_method, in_class: in_class)
139
+ when :autoload
140
+ handle_autoload(node, nodes, file_path, in_method: in_method, in_class: in_class)
141
+ end
142
+
143
+ # Continue traversing for nested calls
144
+ node.arguments&.arguments&.each do |arg|
145
+ traverse_for_requires(arg, nodes, file_path, in_method: in_method, in_class: in_class)
146
+ end
147
+ traverse_for_requires(node.block, nodes, file_path, in_method: in_method, in_class: in_class) if node.block
148
+ end
149
+
150
+ def handle_require_relative(node, nodes, file_path, in_method: false, in_class: false)
151
+ arg = node.arguments&.arguments&.first
152
+ return unless arg
153
+
154
+ if arg.is_a?(Prism::StringNode)
155
+ nodes << {
156
+ type: :require_relative,
157
+ path: arg.unescaped,
158
+ line: node.location.start_line,
159
+ start_offset: node.location.start_offset,
160
+ length: node.location.length,
161
+ in_class: in_class,
162
+ in_method: in_method,
163
+ resolved_path: resolve_relative_path(arg.unescaped, file_path)
164
+ }
165
+ else
166
+ return if in_method
167
+
168
+ raise DynamicRequireError.new(
169
+ file_path,
170
+ line: node.location.start_line,
171
+ expression: node.slice
172
+ )
173
+ end
174
+ end
175
+
176
+ def handle_require(node, nodes, file_path, in_method: false, in_class: false)
177
+ arg = node.arguments&.arguments&.first
178
+ return unless arg
179
+
180
+ if arg.is_a?(Prism::StringNode)
181
+ path = arg.unescaped
182
+ if path.start_with?('./', '../')
183
+ nodes << {
184
+ type: :require,
185
+ path: path,
186
+ line: node.location.start_line,
187
+ start_offset: node.location.start_offset,
188
+ length: node.location.length,
189
+ in_class: in_class,
190
+ in_method: in_method,
191
+ resolved_path: resolve_relative_path(path, file_path)
192
+ }
193
+ elsif (resolved = resolve_bare_require(path))
194
+ nodes << {
195
+ type: :require,
196
+ path: path,
197
+ line: node.location.start_line,
198
+ start_offset: node.location.start_offset,
199
+ length: node.location.length,
200
+ in_class: in_class,
201
+ in_method: in_method,
202
+ resolved_path: resolved
203
+ }
204
+ else
205
+ nodes << {
206
+ type: :require_stdlib,
207
+ path: path,
208
+ line: node.location.start_line,
209
+ start_offset: node.location.start_offset,
210
+ length: node.location.length,
211
+ in_class: in_class,
212
+ in_method: in_method
213
+ }
214
+ end
215
+ else
216
+ return if in_method
217
+
218
+ raise DynamicRequireError.new(
219
+ file_path,
220
+ line: node.location.start_line,
221
+ expression: node.slice
222
+ )
223
+ end
224
+ end
225
+
226
+ def handle_autoload(node, nodes, file_path, in_method: false, in_class: false)
227
+ args = node.arguments&.arguments
228
+ return unless args && args.size >= 2
229
+
230
+ path_arg = args[1]
231
+ path = if path_arg.is_a?(Prism::StringNode)
232
+ path_arg.unescaped
233
+ else
234
+ dir_interpolated_path(path_arg)
235
+ end
236
+
237
+ if path
238
+ # Treat autoload paths like require_relative for local files
239
+ if path.start_with?('./', '../') || !path.include?('/')
240
+ nodes << {
241
+ type: :autoload,
242
+ path: path,
243
+ line: node.location.start_line,
244
+ start_offset: node.location.start_offset,
245
+ length: node.location.length,
246
+ in_class: in_class,
247
+ in_method: in_method,
248
+ resolved_path: resolve_relative_path(path, file_path)
249
+ }
250
+ elsif (resolved = resolve_bare_require(path))
251
+ nodes << {
252
+ type: :autoload,
253
+ path: path,
254
+ line: node.location.start_line,
255
+ start_offset: node.location.start_offset,
256
+ length: node.location.length,
257
+ in_class: in_class,
258
+ in_method: in_method,
259
+ resolved_path: resolved
260
+ }
261
+ end
262
+ else
263
+ return if in_method
264
+
265
+ raise DynamicRequireError.new(
266
+ file_path,
267
+ line: node.location.start_line,
268
+ expression: node.slice
269
+ )
270
+ end
271
+ end
272
+
273
+ # "#{__dir__}/mixin/foo" reads as dynamic but is a constant at
274
+ # collection time: __dir__ is the directory of the file being
275
+ # collected. Recognizes exactly that shape — a sole receiverless,
276
+ # argument-less __dir__ interpolation, then a /-prefixed literal
277
+ # tail — and returns it as the file-relative "./mixin/foo" so the
278
+ # ordinary relative resolution runs; nil for every other
279
+ # interpolation.
280
+ def dir_interpolated_path(path_arg)
281
+ return nil unless path_arg.is_a?(Prism::InterpolatedStringNode)
282
+
283
+ parts = path_arg.parts
284
+ return nil unless parts.size == 2
285
+
286
+ interp, rest = parts
287
+ return nil unless interp.is_a?(Prism::EmbeddedStatementsNode) && rest.is_a?(Prism::StringNode)
288
+
289
+ call = AstUtils.unwrap_statements(interp.statements)
290
+ return nil unless call.is_a?(Prism::CallNode) &&
291
+ call.name == :__dir__ && call.receiver.nil? && call.arguments.nil?
292
+
293
+ tail = rest.unescaped
294
+ tail.start_with?('/') ? ".#{tail}" : nil
295
+ end
296
+
297
+ def ensure_load_paths(require_paths)
298
+ require_paths.each do |path|
299
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
300
+ end
301
+ end
302
+
303
+ def collect_rbs_files(_entry_paths)
304
+ @project_roots.each do |root|
305
+ load_rbs_from(File.join(root, "sig"))
306
+ end
307
+
308
+ collect_rbs_stdlib_files
309
+ end
310
+
311
+ def collect_rbs_stdlib_files
312
+ return if @gem_names.empty?
313
+
314
+ stdlib_root = RBS::Repository::DEFAULT_STDLIB_ROOT
315
+
316
+ @gem_names.each do |gem_name|
317
+ gem_rbs_dir = File.join(stdlib_root, gem_name)
318
+ versions = Dir.children(gem_rbs_dir) rescue next
319
+ next if versions.empty?
320
+
321
+ latest = versions.max_by { |v| Gem::Version.new(v) } #: String
322
+ load_rbs_from(File.join(gem_rbs_dir, latest))
323
+ end
324
+ end
325
+
326
+ def load_rbs_from(dir)
327
+ Dir.glob(File.join(dir, "**", "*.rbs")).each do |path|
328
+ @graph.rbs_files[path] = File.read(path, encoding: Encoding::UTF_8)
329
+ end
330
+ end
331
+
332
+ def find_project_root(entry_paths)
333
+ dir = File.dirname(File.expand_path(entry_paths.first))
334
+ until dir == "/"
335
+ return dir if File.exist?(File.join(dir, "Gemfile")) || File.directory?(File.join(dir, ".git"))
336
+ dir = File.dirname(dir)
337
+ end
338
+ nil
339
+ end
340
+
341
+ def resolve_relative_path(path, from_file)
342
+ path += '.rb' unless path.end_with?('.rb')
343
+ File.expand_path(path, File.dirname(from_file))
344
+ end
345
+
346
+ # Resolve a bare require path (e.g., "foo") via $LOAD_PATH.
347
+ # Returns absolute path if the file is under any project root, nil otherwise.
348
+ def resolve_bare_require(path)
349
+ result = $LOAD_PATH.resolve_feature_path(path)
350
+ return nil unless result
351
+
352
+ type, abs_path = result
353
+ return nil unless type == :rb
354
+ return nil unless @project_roots.any? { |root| abs_path.start_with?("#{root}/") }
355
+
356
+ abs_path
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ # Method renaming: renames method definitions and call sites.
6
+ class MethodRenamer < Stage
7
+ include RenamePatcher
8
+
9
+ def needs_analysis? = true
10
+
11
+ def collect(ctx, patches)
12
+ analysis = analysis(ctx)
13
+ rename_map = analysis.rename_map
14
+ method_alias_map = analysis.method_alias_map
15
+ @negated_transforms = {}
16
+ @source_bytes = analysis.source.content
17
+ collect_patches(ctx.ast, patches, analysis, rename_map, method_alias_map)
18
+ end
19
+
20
+ private
21
+
22
+ def collect_patches(node, patches, analysis, rename_map, method_alias_map)
23
+ # @type var callback: ^(Prism::Node) -> void
24
+ callback = proc { |subnode|
25
+ handle_node(subnode, patches, analysis, rename_map, method_alias_map)
26
+ }
27
+ walk_prism(node, &callback)
28
+ end
29
+
30
+ def handle_node(subnode, patches, analysis, rename_map, method_alias_map)
31
+ case subnode
32
+ when Prism::DefNode
33
+ patch_def_name(subnode, patches, rename_map)
34
+
35
+ when Prism::CallNode
36
+ patch_call_node(subnode, patches, rename_map, method_alias_map, analysis)
37
+
38
+ when Prism::CallOperatorWriteNode,
39
+ Prism::CallOrWriteNode,
40
+ Prism::CallAndWriteNode
41
+ patch_call_operator_write(subnode, patches, rename_map, method_alias_map)
42
+ end
43
+ end
44
+
45
+ def patch_def_name(node, patches, rename_map)
46
+ key = prism_location_key(node)
47
+ short = rename_map[key]
48
+ return unless short
49
+
50
+ name_loc = node.name_loc
51
+ patches << { start: name_loc.start_offset, end: name_loc.end_offset, replacement: short }
52
+ end
53
+
54
+ def patch_call_node(node, patches, rename_map, method_alias_map, analysis)
55
+ # Detect negated transforms: !receiver.empty? → receiver!=[]
56
+ # The ! CallNode is visited before its children, so we mark the inner
57
+ # node here and negate the transform when it's applied later.
58
+ if node.name == :"!" && node.receiver.is_a?(Prism::CallNode)
59
+ inner = node.receiver
60
+ inner_key = prism_location_key(inner)
61
+ transform = analysis.method_transform_map[inner_key]
62
+ if transform&.start_with?('==') && inner.call_operator_loc && !inner.safe_navigation?
63
+ @negated_transforms[inner_key] = true
64
+ patches << { start: node.location.start_offset, end: inner.location.start_offset, replacement: '' }
65
+ end
66
+ end
67
+
68
+ key = prism_location_key(node)
69
+
70
+ # Meta calls are structural, not plain calls: attr declarations are
71
+ # rewritten wholesale by AttrDeclShorten, and include must keep its
72
+ # name. Renaming or transforming them here would corrupt them.
73
+ return if analysis.meta_node_map[key]
74
+
75
+ # Structural transforms (e.g. .first → [0], .empty? → ==[])
76
+ transform = analysis.method_transform_map[key]
77
+ if transform && node.call_operator_loc && !node.safe_navigation?
78
+ replacement = @negated_transforms[key] ? "!#{transform[1..]}" : transform
79
+ end_offset = node.location.end_offset
80
+ # Consume trailing spaces around ternary ? when no longer needed
81
+ # e.g. .empty? ? "x" → ==[]?"x" since ] is not a name char
82
+ if @source_bytes.getbyte(end_offset) == 0x20 && @source_bytes.getbyte(end_offset + 1) == 0x3F # ' ' and '?'
83
+ end_offset += 2 # consume space + ternary ?
84
+ end_offset += 1 if @source_bytes.getbyte(end_offset) == 0x20 # consume space after ?
85
+ replacement = "#{replacement}?"
86
+ end
87
+ # call_operator_loc was checked at the top of this branch
88
+ patches << { start: node.call_operator_loc.start_offset, end: end_offset, replacement: replacement } # steep:ignore NoMethod
89
+ return
90
+ elsif transform && !node.call_operator_loc && node.receiver.is_a?(Prism::CallNode)
91
+ # A comparison-shape transform (.size==0 → ==[]), registered on
92
+ # the operator call whose receiver is the size query: the patch
93
+ # swallows the query and the comparison together.
94
+ receiver_end = node.receiver.receiver.location.end_offset # steep:ignore NoMethod
95
+ patches << { start: receiver_end, end: node.location.end_offset, replacement: transform }
96
+ return
97
+ end
98
+
99
+ return unless node.message_loc
100
+
101
+ short = rename_map[key]
102
+
103
+ # send/public_send/__send__: patch the symbol argument, not the method name
104
+ if short && SEND_METHODS.include?(node.name)
105
+ patch_send_symbol(node, patches, short)
106
+ return
107
+ end
108
+
109
+ alias_name = method_alias_map[key]&.to_s
110
+ replacement = short || alias_name
111
+ return unless replacement
112
+
113
+ if replacement.end_with?('=') && !replacement.end_with?('==') &&
114
+ node.name.to_s.end_with?('=') && !node.name.to_s.end_with?('==')
115
+ msg_slice = node.message_loc.slice
116
+ replacement = replacement.chomp('=') unless msg_slice.end_with?('=')
117
+ end
118
+
119
+ end_offset = node.message_loc.end_offset
120
+ # The compactor separates a ?/!-ending name from an =-starting
121
+ # operator with one protective space. A rename that drops the ?/!
122
+ # must take the space with it — left behind, re-minification removes
123
+ # it and the self-host fixed point drifts by a byte per site.
124
+ if node.name.to_s.end_with?('?', '!') && !replacement.end_with?('?', '!') &&
125
+ @source_bytes.getbyte(end_offset) == 0x20 && @source_bytes.getbyte(end_offset + 1) == 0x3D # ' ' then '='
126
+ end_offset += 1
127
+ end
128
+ patches << { start: node.message_loc.start_offset, end: end_offset, replacement: replacement }
129
+ end
130
+
131
+ SEND_METHODS = %i[send __send__ public_send].freeze
132
+
133
+ def patch_send_symbol(node, patches, short_name)
134
+ first_arg = node.arguments&.arguments&.first
135
+ return unless first_arg.is_a?(Prism::SymbolNode)
136
+
137
+ loc = first_arg.location
138
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: ":#{short_name}" }
139
+ end
140
+
141
+ def patch_call_operator_write(node, patches, rename_map, method_alias_map)
142
+ return unless node.message_loc
143
+ key = prism_location_key(node)
144
+ short = rename_map[key] || method_alias_map[key]&.to_s
145
+ return unless short
146
+ short = short.chomp('=') if short.end_with?('=') && !short.end_with?('==')
147
+ patches << { start: node.message_loc.start_offset, end: node.message_loc.end_offset, replacement: short }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ # Removes unnecessary parentheses from method calls at statement level.
6
+ # Operates as a source patcher: Prism.parse → walk AST → collect patches → apply.
7
+ # Only removes parens; never adds them.
8
+ class ParenOptimizer < Stage
9
+ def collect(ctx, patches)
10
+ walk(ctx.ast, ctx.source, patches, statement_level: true)
11
+ end
12
+
13
+ private
14
+
15
+ def walk(node, source, patches, statement_level:)
16
+ case node
17
+ when Prism::ProgramNode
18
+ walk(node.statements, source, patches, statement_level: true)
19
+
20
+ when Prism::StatementsNode
21
+ node.body.each { |child| walk(child, source, patches, statement_level: statement_level) }
22
+
23
+ when Prism::CallNode
24
+ try_remove_call_parens(node, source, patches) if statement_level
25
+ walk_call_children(node, source, patches)
26
+
27
+ when Prism::YieldNode
28
+ try_remove_yield_parens(node, patches) if statement_level
29
+ node.arguments&.arguments&.each { |arg| walk(arg, source, patches, statement_level: false) }
30
+
31
+ when Prism::IfNode
32
+ walk_if(node, source, patches)
33
+
34
+ when Prism::UnlessNode
35
+ walk(node.predicate, source, patches, statement_level: false)
36
+ walk(node.statements, source, patches, statement_level: true) if node.statements
37
+ walk(node.else_clause, source, patches, statement_level: true) if node.else_clause
38
+
39
+ when Prism::ElseNode
40
+ walk(node.statements, source, patches, statement_level: statement_level) if node.statements
41
+
42
+ when Prism::WhileNode, Prism::UntilNode
43
+ walk(node.predicate, source, patches, statement_level: false)
44
+ walk(node.statements, source, patches, statement_level: true) if node.statements
45
+
46
+ when Prism::ForNode
47
+ walk(node.collection, source, patches, statement_level: false)
48
+ walk(node.statements, source, patches, statement_level: true) if node.statements
49
+
50
+ when Prism::DefNode
51
+ walk(node.body, source, patches, statement_level: true) if node.body
52
+
53
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
54
+ walk(node.body, source, patches, statement_level: true) if node.body
55
+
56
+ when Prism::BeginNode
57
+ walk(node.statements, source, patches, statement_level: true) if node.statements
58
+ walk(node.rescue_clause, source, patches, statement_level: true) if node.rescue_clause
59
+ walk(node.else_clause, source, patches, statement_level: true) if node.else_clause
60
+ walk(node.ensure_clause, source, patches, statement_level: true) if node.ensure_clause
61
+
62
+ when Prism::RescueNode
63
+ walk(node.statements, source, patches, statement_level: true) if node.statements
64
+ walk(node.subsequent, source, patches, statement_level: true) if node.subsequent
65
+
66
+ when Prism::EnsureNode
67
+ walk(node.statements, source, patches, statement_level: true) if node.statements
68
+
69
+ when Prism::CaseNode
70
+ walk(node.predicate, source, patches, statement_level: false) if node.predicate
71
+ node.conditions.each { |cond| walk(cond, source, patches, statement_level: true) }
72
+ walk(node.else_clause, source, patches, statement_level: true) if node.else_clause
73
+
74
+ when Prism::WhenNode
75
+ node.conditions.each { |c| walk(c, source, patches, statement_level: false) }
76
+ walk(node.statements, source, patches, statement_level: true) if node.statements
77
+
78
+ when Prism::CaseMatchNode
79
+ walk(node.predicate, source, patches, statement_level: false) if node.predicate
80
+ node.conditions.each { |cond| walk(cond, source, patches, statement_level: true) }
81
+ walk(node.else_clause, source, patches, statement_level: true) if node.else_clause
82
+
83
+ when Prism::InNode
84
+ walk(node.statements, source, patches, statement_level: true) if node.statements
85
+
86
+ when Prism::BlockNode, Prism::LambdaNode
87
+ walk(node.body, source, patches, statement_level: true) if node.body
88
+
89
+ when Prism::ParenthesesNode
90
+ walk(node.body, source, patches, statement_level: statement_level) if node.body
91
+
92
+ when Prism::LocalVariableWriteNode
93
+ walk(node.value, source, patches, statement_level: statement_level)
94
+
95
+ when Prism::PostExecutionNode
96
+ walk(node.statements, source, patches, statement_level: true) if node.statements
97
+
98
+ else
99
+ node.compact_child_nodes.each { |child| walk(child, source, patches, statement_level: false) }
100
+ end
101
+ end
102
+
103
+ def walk_if(node, source, patches)
104
+ walk(node.predicate, source, patches, statement_level: false)
105
+
106
+ if node.if_keyword_loc
107
+ # Block-if or modifier-if: body is at statement level
108
+ walk(node.statements, source, patches, statement_level: true) if node.statements
109
+ walk(node.subsequent, source, patches, statement_level: true) if node.subsequent
110
+ else
111
+ # Ternary (no if_keyword_loc): arms are NOT at statement level
112
+ walk(node.statements, source, patches, statement_level: false) if node.statements
113
+ walk(node.subsequent, source, patches, statement_level: false) if node.subsequent
114
+ end
115
+ end
116
+
117
+ def walk_call_children(node, source, patches)
118
+ walk(node.receiver, source, patches, statement_level: false) if node.receiver
119
+ node.arguments&.arguments&.each { |arg| walk(arg, source, patches, statement_level: false) }
120
+ if node.block
121
+ if node.block.is_a?(Prism::BlockNode)
122
+ walk(node.block, source, patches, statement_level: true)
123
+ else
124
+ walk(node.block, source, patches, statement_level: false)
125
+ end
126
+ end
127
+ end
128
+
129
+ def try_remove_call_parens(node, source, patches)
130
+ return unless node.opening_loc
131
+ return unless AstUtils.can_omit_parens?(node)
132
+
133
+ # Don't remove parens from calls with keyword args in modifier context.
134
+ # After hash shorthand (`a:` for `a:a`), `foo a: if cond` is ambiguous.
135
+ raw_args = node.arguments&.arguments || []
136
+ if raw_args.any? { |a| a.is_a?(Prism::KeywordHashNode) }
137
+ # opening_loc presence (checked above) implies the paired closing_loc
138
+ after = source.byteslice(node.closing_loc.end_offset, 10) # steep:ignore NoMethod
139
+ return if after&.match?(/\A (?:if|unless|while|until) /)
140
+ end
141
+
142
+ add_paren_removal_patches(node.opening_loc, node.closing_loc, patches) # steep:ignore ArgumentTypeMismatch
143
+ end
144
+
145
+ def try_remove_yield_parens(node, patches)
146
+ return unless node.lparen_loc
147
+ args = node.arguments&.arguments || []
148
+ return if args.empty?
149
+
150
+ # lparen_loc presence (checked above) implies the paired rparen_loc
151
+ add_paren_removal_patches(node.lparen_loc, node.rparen_loc, patches) # steep:ignore ArgumentTypeMismatch
152
+ end
153
+
154
+ def add_paren_removal_patches(open_loc, close_loc, patches)
155
+ patches << { start: open_loc.start_offset, end: open_loc.end_offset, replacement: ' ' }
156
+ patches << { start: close_loc.start_offset, end: close_loc.end_offset, replacement: '' }
157
+ end
158
+
159
+ end
160
+ end
161
+ end