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