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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tsort'
4
+
5
+ module Ryac
6
+ module Pipeline
7
+ # Stage 2: File Concatenation
8
+ # Performs topological sort and concatenates files in dependency order
9
+ class Concatenator
10
+ # @param graph [DependencyGraph] From Stage 1
11
+ # @return [ConcatenatedSource] Ordered, concatenated source
12
+ # @raise [CircularDependencyError] If cycle detected in graph
13
+ def call(graph)
14
+ sorted_paths = topological_sort(graph)
15
+ concatenate_files(graph, sorted_paths)
16
+ end
17
+
18
+ private
19
+
20
+ # Perform topological sort using Ruby's TSort library
21
+ # @return [Array<String>] Paths in dependency order (dependencies first)
22
+ def topological_sort(graph)
23
+ sorter = GraphSorter.new(graph)
24
+ begin
25
+ sorter.tsort
26
+ rescue TSort::Cyclic
27
+ # Extract cycle from error message
28
+ cycle = extract_cycle_from_graph(graph)
29
+ raise CircularDependencyError.new(cycle)
30
+ end
31
+ end
32
+
33
+ # Extract cycle path from graph for error reporting
34
+ def extract_cycle_from_graph(graph)
35
+ visited = Set.new
36
+ rec_stack = Set.new
37
+ path = [] #: Array[String]
38
+
39
+ graph.paths.each do |start|
40
+ if find_cycle_dfs(graph, start, visited, rec_stack, path)
41
+ return path
42
+ end
43
+ end
44
+
45
+ # Fallback: return all paths
46
+ graph.paths
47
+ end
48
+
49
+ def find_cycle_dfs(graph, node, visited, rec_stack, path)
50
+ return false if visited.include?(node)
51
+
52
+ visited.add(node)
53
+ rec_stack.add(node)
54
+ path << node
55
+
56
+ entry = graph[node]
57
+ deps = entry&.dependencies
58
+ if deps
59
+ deps.each do |dep|
60
+ if rec_stack.include?(dep)
61
+ path << dep
62
+ return true
63
+ end
64
+
65
+ if find_cycle_dfs(graph, dep, visited, rec_stack, path)
66
+ return true
67
+ end
68
+ end
69
+ end
70
+
71
+ rec_stack.delete(node)
72
+ path.pop
73
+ false
74
+ end
75
+
76
+ # Helper class for TSort
77
+ class GraphSorter
78
+ include TSort
79
+
80
+ def initialize(graph)
81
+ @graph = graph
82
+ end
83
+
84
+ def tsort_each_node(&block)
85
+ @graph.paths.each(&block)
86
+ end
87
+
88
+ def tsort_each_child(node, &block)
89
+ entry = @graph[node]
90
+ return unless entry
91
+
92
+ entry.dependencies.each(&block)
93
+ entry.in_class_dependencies.each(&block)
94
+ end
95
+ end
96
+
97
+ # Concatenate files in sorted order
98
+ def concatenate_files(graph, sorted_paths)
99
+ content_parts = [] #: Array[String]
100
+ file_boundaries = [] #: Array[FileBoundary]
101
+ stdlib_requires = [] #: Array[String]
102
+ inlined = Set.new
103
+ current_line = 1
104
+
105
+ # Pre-clean all files: resolve in-class requires by inlining
106
+ cleaned_cache = {} #: Hash[String, String]
107
+ sorted_paths.each do |path|
108
+ entry = graph[path]
109
+ next unless entry
110
+ collect_stdlib_requires(entry, stdlib_requires)
111
+ cleaned_cache[path] = process_require_statements(entry, graph, inlined, cleaned_cache)
112
+ end
113
+
114
+ sorted_paths.each do |path|
115
+ next if inlined.include?(path)
116
+ entry = graph[path]
117
+ next unless entry
118
+
119
+ cleaned_content = cleaned_cache[path]
120
+ lines = cleaned_content.count("\n") + 1
121
+
122
+ file_boundaries << FileBoundary.new(
123
+ path: path,
124
+ start_line: current_line,
125
+ end_line: current_line + lines - 1
126
+ )
127
+
128
+ content_parts << cleaned_content
129
+ current_line += lines
130
+ end
131
+
132
+ original_size = graph.files.values.sum { |f| f.content.bytesize }
133
+
134
+ ConcatenatedSource.new(
135
+ content: content_parts.join("\n"),
136
+ file_boundaries: file_boundaries,
137
+ original_size: original_size,
138
+ stdlib_requires: stdlib_requires.uniq,
139
+ rbs_files: graph.rbs_files
140
+ )
141
+ end
142
+
143
+ # Hoisting a require to the top of the output makes it run at load time.
144
+ # That is fine for one the file already ran at load time, but a require
145
+ # inside a method body runs only when the method is called and is often
146
+ # guarded — optcarrot loads stackprof only under --stackprof-mode, so
147
+ # hoisting it turns an optional dependency into a mandatory one.
148
+ def collect_stdlib_requires(entry, stdlib_requires)
149
+ entry.require_nodes.each do |node|
150
+ next unless node[:type] == :require_stdlib
151
+ next if node[:in_method]
152
+ stdlib_requires << node[:path]
153
+ end
154
+ end
155
+
156
+ # Process require statements: remove top-level requires, inline in-class requires
157
+ def process_require_statements(entry, graph, inlined, cleaned_cache)
158
+ content = entry.content
159
+ require_nodes = entry.require_nodes
160
+ return content if require_nodes.empty?
161
+
162
+ in_class_deps = entry.in_class_dependencies.to_set
163
+ nodes_with_offsets = require_nodes.select { |n| n[:start_offset] }
164
+
165
+ if nodes_with_offsets.size == require_nodes.size
166
+ offset_based_processing(content, nodes_with_offsets, graph, in_class_deps, inlined, cleaned_cache)
167
+ else
168
+ line_based_processing(content, require_nodes)
169
+ end
170
+ end
171
+
172
+ def offset_based_processing(content, nodes, graph, in_class_deps, inlined, cleaned_cache)
173
+ sorted_nodes = nodes.sort_by { |n| n[:start_offset] }.reverse
174
+ # Prism offsets are byte offsets: splice on bytes, or any multibyte
175
+ # character before a require shifts every slice after it.
176
+ result = content.b
177
+ sorted_nodes.each do |node|
178
+ start_pos = node[:start_offset]
179
+ end_pos = start_pos + node[:length]
180
+
181
+ if node[:in_class] && !node[:in_method] && node[:type] != :require_stdlib
182
+ dep_path = resolve_node_path(node, graph)
183
+ if dep_path && graph[dep_path]
184
+ # the `graph[dep_path]` check above guarantees the entry exists
185
+ dep_content = cleaned_cache[dep_path] || graph[dep_path].content # steep:ignore NoMethod
186
+ stripped = strip_outer_nesting(dep_content)
187
+ # Only consume trailing semicolons (not newlines) for inline
188
+ while end_pos < result.size && result[end_pos] == ';'
189
+ end_pos += 1
190
+ end
191
+ result[start_pos...end_pos] = stripped.b
192
+ inlined.add(dep_path)
193
+ next
194
+ end
195
+ end
196
+
197
+ # An in-method stdlib require is not hoisted, so it has to stay where
198
+ # it is — deleting it here would drop the require altogether.
199
+ next if node[:type] == :require_stdlib && node[:in_method]
200
+
201
+ # For removal: consume trailing semicolons and newlines
202
+ while end_pos < result.size && (result[end_pos] == ';' || result[end_pos] == "\n")
203
+ end_pos += 1
204
+ end
205
+ result[start_pos...end_pos] = ''
206
+ end
207
+ result.force_encoding(content.encoding)
208
+ end
209
+
210
+ def line_based_processing(content, require_nodes)
211
+ lines = content.lines
212
+ lines_to_remove = Set.new
213
+ require_nodes.each { |node| lines_to_remove.add(node[:line] - 1) }
214
+ lines.each_with_index.map do |line, idx|
215
+ lines_to_remove.include?(idx) ? '' : line.chomp
216
+ end.join("\n")
217
+ end
218
+
219
+ def resolve_node_path(node, graph)
220
+ resolved = node[:resolved_path]
221
+ return nil unless resolved
222
+ graph.files.key?(resolved) ? resolved : nil
223
+ end
224
+
225
+ # Strip outer module/class nesting from a file so it can be inlined
226
+ # inside the parent's class body. Peels single-child module/class layers
227
+ # until reaching the innermost new scope definition.
228
+ def strip_outer_nesting(content)
229
+ ast = Prism.parse(content).value
230
+ node = ast.statements
231
+ while node.is_a?(Prism::StatementsNode) && node.body.size == 1
232
+ child = node.body.first
233
+ break unless child.is_a?(Prism::ModuleNode) || child.is_a?(Prism::ClassNode)
234
+ inner_body = child.body
235
+ if inner_body.is_a?(Prism::StatementsNode) && inner_body.body.size == 1
236
+ inner_child = inner_body.body.first
237
+ if inner_child.is_a?(Prism::ModuleNode) || inner_child.is_a?(Prism::ClassNode)
238
+ node = inner_body
239
+ next
240
+ end
241
+ end
242
+ break
243
+ end
244
+ node.slice
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Ryac
6
+ module Pipeline
7
+ # Constant aliasing via source patching.
8
+ class ConstantAliaser < Stage
9
+ include RenamePatcher
10
+
11
+ def needs_analysis? = true
12
+
13
+ def initialize(rename_classes: false)
14
+ @rename_classes = rename_classes
15
+ end
16
+
17
+ def collect(ctx, patches)
18
+ analysis = analysis(ctx)
19
+ @class_module_cpath_offsets = Set.new
20
+ # Declared optional on AnalysisResult but always present by the time
21
+ # constant patches are collected.
22
+ @constant_mapping = analysis.constant_mapping #: ConstantRenameMapping
23
+ # Short-name assignment is this stage's policy — it happens exactly
24
+ # once, before any constant patch is emitted, and no other stage
25
+ # reads the mapping.
26
+ if @constant_mapping
27
+ generator = NameGenerator.new([], upcase: true)
28
+ @constant_mapping.assign_short_names(generator, skip_class_modules: !@rename_classes)
29
+ end
30
+ collect_patches(ctx.ast, patches, analysis)
31
+ end
32
+
33
+ def finish(ctx)
34
+ mapping = @constant_mapping
35
+ return unless mapping
36
+
37
+ prefix_decls = mapping.generate_prefix_declarations
38
+ ctx.preamble = [ctx.preamble, prefix_decls.join(';')].reject(&:empty?).join(';') if prefix_decls.any?
39
+
40
+ alias_decls = mapping.generate_alias_declarations
41
+ ctx.aliases = [ctx.aliases, alias_decls.join(';')].reject(&:empty?).join(';') if alias_decls.any?
42
+ end
43
+
44
+ private
45
+
46
+ def collect_patches(node, patches, analysis)
47
+ walk_prism(node) do |subnode|
48
+ case subnode
49
+ when Prism::ConstantReadNode
50
+ patch_constant_ref(subnode, patches, analysis)
51
+ when Prism::ConstantPathNode
52
+ patch_constant_ref(subnode, patches, analysis)
53
+ next :skip_children
54
+ when Prism::ConstantWriteNode
55
+ patch_constant_write(subnode, patches, analysis)
56
+ when Prism::ConstantTargetNode
57
+ # `A, B = ...` — Prism models each target as a ConstantTargetNode,
58
+ # not a ConstantWriteNode. TypeProf still reports one constant per
59
+ # target at the same location, so without this the references get
60
+ # renamed while the definitions keep their original names.
61
+ patch_constant_write(subnode, patches, analysis)
62
+ when Prism::ConstantPathWriteNode
63
+ patch_constant_path_write(subnode, patches, analysis)
64
+ when Prism::ClassNode
65
+ patch_class_node(subnode, patches, analysis)
66
+ when Prism::ModuleNode
67
+ patch_module_node(subnode, patches, analysis)
68
+ when Prism::DefNode
69
+ patch_def_receiver(subnode.receiver, patches, analysis) if subnode.receiver && !subnode.receiver.is_a?(Prism::SelfNode)
70
+ end
71
+ end
72
+ end
73
+
74
+ def patch_constant_ref(node, patches, analysis)
75
+ return if @class_module_cpath_offsets.include?(node.location.start_offset)
76
+
77
+ key = prism_location_key(node)
78
+ resolved_cpath = analysis.const_resolution_map[key]
79
+ full_path = analysis.const_full_path_map[key]
80
+ prefix_alias = full_path && @constant_mapping&.short_name_for_prefix(full_path)
81
+
82
+ if resolved_cpath && @constant_mapping&.user_defined_path?(resolved_cpath)
83
+ short = if node.is_a?(Prism::ConstantReadNode)
84
+ # Bare name reference (e.g., CONST) — use only the leaf short name.
85
+ # Expanding to a fully qualified path would break constants defined
86
+ # in `class << self` (metaclass constants are not accessible as Foo::X).
87
+ @constant_mapping.short_name_for_path(resolved_cpath) || node.name.to_s
88
+ else
89
+ get_short_cpath(resolved_cpath)
90
+ end
91
+ loc = node.location
92
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: short }
93
+ elsif node.is_a?(Prism::ConstantPathNode) && resolved_cpath &&
94
+ (short = build_renamed_via_user_prefix(node, analysis))
95
+ loc = node.location
96
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: short }
97
+ elsif prefix_alias
98
+ short = "#{prefix_alias}::#{node.name}"
99
+ loc = node.location
100
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: short }
101
+ end
102
+ end
103
+
104
+ def patch_constant_write(node, patches, analysis)
105
+ key = prism_location_key(node)
106
+ static_cpath = analysis.const_write_cpath_map[key]
107
+ return unless static_cpath
108
+
109
+ short_name = @constant_mapping.short_name_for_path(static_cpath)
110
+ return unless short_name
111
+
112
+ # A ConstantTargetNode is entirely the name; a ConstantWriteNode also
113
+ # spans `= value`, so only its name_loc belongs to us.
114
+ name_loc = node.is_a?(Prism::ConstantTargetNode) ? node.location : node.name_loc
115
+ patches << { start: name_loc.start_offset, end: name_loc.end_offset, replacement: short_name }
116
+ end
117
+
118
+ def patch_constant_path_write(node, patches, analysis)
119
+ key = prism_location_key(node)
120
+ static_cpath = analysis.const_write_cpath_map[key]
121
+ return unless static_cpath
122
+
123
+ path_str = render_short_cpath(static_cpath)
124
+ target_loc = node.target.location
125
+ patches << { start: target_loc.start_offset, end: target_loc.end_offset, replacement: path_str }
126
+ @class_module_cpath_offsets << target_loc.start_offset
127
+ mark_constant_children(node.target)
128
+ end
129
+
130
+ def patch_class_node(node, patches, analysis)
131
+ key = prism_location_key(node)
132
+ patch_definition_path(node, patches, analysis.class_cpath_map[key], analysis)
133
+
134
+ if node.superclass
135
+ superclass_path = analysis.superclass_resolution_map[key]
136
+ superclass_path ||= analysis.const_resolution_map[prism_location_key(node.superclass)]
137
+ if superclass_path
138
+ short = get_short_cpath(superclass_path)
139
+ sc_loc = node.superclass.location
140
+ patches << { start: sc_loc.start_offset, end: sc_loc.end_offset, replacement: short }
141
+ @class_module_cpath_offsets << sc_loc.start_offset
142
+ mark_constant_children(node.superclass)
143
+ end
144
+ end
145
+ end
146
+
147
+ def patch_module_node(node, patches, analysis)
148
+ patch_definition_path(node, patches, analysis.class_cpath_map[prism_location_key(node)], analysis)
149
+ end
150
+
151
+ # A definition's written path names the class relative to its lexical
152
+ # nesting, and the enclosing modules are renamed right along with it —
153
+ # so the renamed spelling only needs the written segments' short names.
154
+ # Re-qualifying with the full path (`module A; class A::B`) costs bytes
155
+ # and says nothing the nesting doesn't already say: `class B` inside
156
+ # `module A` creates and reopens exactly A::B.
157
+ def patch_definition_path(node, patches, class_cpath, analysis)
158
+ return unless class_cpath
159
+
160
+ # class_cpath exists only when path_segments succeeded on this node,
161
+ # and in the absolute case it equals the segments outright.
162
+ segments, = Nesting.path_segments(node.constant_path) #: [Array[Symbol], bool]
163
+ rendered = render_short_cpath(class_cpath, from: class_cpath.size - segments.size)
164
+ if rendered != node.constant_path.slice
165
+ cpath_loc = node.constant_path.location
166
+ patches << { start: cpath_loc.start_offset, end: cpath_loc.end_offset, replacement: rendered }
167
+ end
168
+
169
+ @class_module_cpath_offsets << node.constant_path.location.start_offset
170
+ mark_constant_children(node.constant_path)
171
+ end
172
+
173
+ # Each prefix of the cpath may carry its own short name; render from
174
+ # `from` onward, falling back to the original segment where none was
175
+ # assigned.
176
+ def render_short_cpath(cpath, from: 0)
177
+ (from...cpath.size).map { |i|
178
+ @constant_mapping.short_name_for_path(cpath[0..i]) || cpath[i].to_s
179
+ }.join('::')
180
+ end
181
+
182
+ def mark_constant_children(node)
183
+ case node
184
+ when Prism::ConstantPathNode
185
+ @class_module_cpath_offsets << node.location.start_offset
186
+ mark_constant_children(node.parent) if node.parent
187
+ when Prism::ConstantReadNode
188
+ @class_module_cpath_offsets << node.location.start_offset
189
+ end
190
+ end
191
+
192
+ def build_renamed_via_user_prefix(node, analysis)
193
+ parent_node = node.parent
194
+ return nil unless parent_node
195
+
196
+ parent_key = prism_location_key(parent_node)
197
+ parent_resolved = analysis.const_resolution_map[parent_key]
198
+ return nil unless parent_resolved
199
+
200
+ if @constant_mapping.user_defined_path?(parent_resolved)
201
+ parent_short = if parent_node.is_a?(Prism::ConstantReadNode)
202
+ @constant_mapping.short_name_for_path(parent_resolved) || parent_node.name.to_s
203
+ else
204
+ get_short_cpath(parent_resolved)
205
+ end
206
+ "#{parent_short}::#{node.name}"
207
+ elsif parent_node.is_a?(Prism::ConstantPathNode)
208
+ parent_renamed = build_renamed_via_user_prefix(parent_node, analysis)
209
+ parent_renamed ? "#{parent_renamed}::#{node.name}" : nil
210
+ end
211
+ end
212
+
213
+ def get_short_cpath(cpath)
214
+ if @constant_mapping.user_defined_path?(cpath)
215
+ render_short_cpath(cpath)
216
+ else
217
+ cpath.map(&:to_s).join('::')
218
+ end
219
+ end
220
+
221
+ def patch_def_receiver(receiver, patches, analysis)
222
+ return unless @constant_mapping
223
+
224
+ @class_module_cpath_offsets.add(receiver.location.start_offset)
225
+ key = prism_location_key(receiver)
226
+ resolved_cpath = case receiver
227
+ when Prism::ConstantReadNode
228
+ analysis.const_resolution_map[key] || [receiver.name]
229
+ when Prism::ConstantPathNode
230
+ analysis.const_resolution_map[key]
231
+ end
232
+ return unless resolved_cpath && @constant_mapping.user_defined_path?(resolved_cpath)
233
+
234
+ short = get_short_cpath(resolved_cpath)
235
+ loc = receiver.location
236
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: short }
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ class ConstantFold < Stage
6
+ FOLDABLE_OPS = %i[+ - * / % ** << >> & | ^].freeze
7
+ INTEGER_ONLY_OPS = %i[<< >> & | ^].freeze
8
+
9
+ def collect(ctx, patches)
10
+ walk(ctx.ast, patches)
11
+ end
12
+
13
+ private
14
+
15
+ def walk(node, patches)
16
+ if node.is_a?(Prism::CallNode)
17
+ folded = try_constant_fold(node)
18
+ if folded
19
+ replacement = folded.to_s
20
+ if replacement.bytesize < node.location.length
21
+ patches << mk(node, replacement)
22
+ return
23
+ end
24
+ end
25
+ end
26
+ node.compact_child_nodes.each { |child| walk(child, patches) }
27
+ end
28
+
29
+ def try_constant_fold(node)
30
+ case node
31
+ when Prism::IntegerNode then node.value
32
+ when Prism::FloatNode then node.value
33
+ when Prism::ParenthesesNode
34
+ body = node.body
35
+ if body.is_a?(Prism::StatementsNode) && body.body.size == 1
36
+ try_constant_fold(body.body.first)
37
+ end
38
+ when Prism::CallNode
39
+ if node.receiver && node.arguments.nil? && node.name == :-@
40
+ inner = try_constant_fold(node.receiver)
41
+ return nil unless inner.is_a?(Numeric)
42
+ -inner
43
+ else
44
+ try_fold_binary_op(node)
45
+ end
46
+ end
47
+ end
48
+
49
+ def try_fold_binary_op(node)
50
+ return nil unless node.receiver
51
+ return nil unless node.arguments&.arguments&.size == 1
52
+ return nil if node.block
53
+
54
+ op = node.name
55
+ return nil unless FOLDABLE_OPS.include?(op)
56
+
57
+ lhs = try_constant_fold(node.receiver)
58
+ return nil unless lhs.is_a?(Numeric)
59
+
60
+ # arguments proved non-nil above
61
+ rhs = try_constant_fold(node.arguments.arguments.first) # steep:ignore NoMethod
62
+ return nil unless rhs.is_a?(Numeric)
63
+
64
+ return nil if INTEGER_ONLY_OPS.include?(op) && !(lhs.is_a?(Integer) && rhs.is_a?(Integer))
65
+ return nil if (op == :/ || op == :%) && rhs == 0
66
+
67
+ result = apply_op(lhs, op, rhs)
68
+ return nil unless result.is_a?(Integer) || result.is_a?(Float)
69
+ return nil if result.is_a?(Float) && (result.nan? || result.infinite?)
70
+
71
+ result
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ def apply_op(lhs, op, rhs)
77
+ case op
78
+ when :+ then lhs + rhs
79
+ when :- then lhs - rhs
80
+ when :* then lhs * rhs
81
+ when :/ then lhs / rhs
82
+ when :% then lhs % rhs
83
+ when :** then lhs ** rhs
84
+ when :<< then lhs << rhs
85
+ when :>> then lhs >> rhs
86
+ when :& then lhs & rhs
87
+ when :| then lhs | rhs
88
+ when :^ then lhs ^ rhs
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end