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,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ryac
|
|
4
|
+
module AnalysisPhases
|
|
5
|
+
# One classified walk of every constant fact in the tree, shared by
|
|
6
|
+
# definition collection, usage counting, external-reference collection and
|
|
7
|
+
# the analyzer's resolution precompute — so the node-shape case analysis
|
|
8
|
+
# lives in exactly one place. Yields (kind, node, cpath, singleton, in_def):
|
|
9
|
+
#
|
|
10
|
+
# :class_def class/module definition; cpath is the path it creates
|
|
11
|
+
# :read a constant read at the node's own position (cpath nil)
|
|
12
|
+
# :write an assignment defining cpath
|
|
13
|
+
#
|
|
14
|
+
# A compound write (`X ||= 1`) and a qualified target in multiple
|
|
15
|
+
# assignment (`Foo::A, b = ary`) read as well as write, so they yield
|
|
16
|
+
# both a :read and a :write. Writes with no static path yield nothing.
|
|
17
|
+
def each_constant_event(prism_root)
|
|
18
|
+
Nesting.each(prism_root) do |node, nesting, singleton, in_def|
|
|
19
|
+
case node
|
|
20
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
21
|
+
cpath = class_definition_cpath(node, nesting)
|
|
22
|
+
yield :class_def, node, cpath, singleton, in_def if cpath
|
|
23
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
24
|
+
yield :read, node, nil, singleton, in_def
|
|
25
|
+
when Prism::ConstantWriteNode
|
|
26
|
+
yield :write, node, nesting + [node.name], singleton, in_def
|
|
27
|
+
when Prism::ConstantTargetNode
|
|
28
|
+
yield :write, node, nesting + [node.name], singleton, in_def
|
|
29
|
+
when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
|
|
30
|
+
yield :read, node, nil, singleton, in_def
|
|
31
|
+
yield :write, node, nesting + [node.name], singleton, in_def
|
|
32
|
+
when Prism::ConstantPathTargetNode
|
|
33
|
+
yield :read, node, nil, singleton, in_def
|
|
34
|
+
cpath = qualified_write_cpath(node, nesting)
|
|
35
|
+
yield :write, node, cpath, singleton, in_def if cpath
|
|
36
|
+
when Prism::ConstantPathWriteNode
|
|
37
|
+
cpath = qualified_write_cpath(node.target, nesting, strip_doubled_nesting: true)
|
|
38
|
+
yield :write, node, cpath, singleton, in_def if cpath
|
|
39
|
+
when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
|
|
40
|
+
cpath = qualified_write_cpath(node.target, nesting)
|
|
41
|
+
yield :write, node, cpath, singleton, in_def if cpath
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The fully-qualified path a class/module definition creates, or nil when
|
|
47
|
+
# the written path is not static.
|
|
48
|
+
def class_definition_cpath(node, nesting)
|
|
49
|
+
qualified_write_cpath(node.constant_path, nesting)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The path a qualified assignment target names, or nil when it is not
|
|
53
|
+
# static (`self::X`, `expr::X`). A plain path write that repeats the
|
|
54
|
+
# lexical nesting (`Foo::Bar::X = 1` inside module Foo; module Bar) names
|
|
55
|
+
# the same constant the bare write would, so the doubled prefix is dropped
|
|
56
|
+
# — but only for that form: compound and multiple-target writes
|
|
57
|
+
# concatenate as written, mirroring the previous implementation.
|
|
58
|
+
def qualified_write_cpath(target, nesting, strip_doubled_nesting: false)
|
|
59
|
+
segments, absolute = Nesting.path_segments(target)
|
|
60
|
+
return nil unless segments
|
|
61
|
+
return segments if absolute
|
|
62
|
+
return segments if strip_doubled_nesting && segments.take(nesting.size) == nesting
|
|
63
|
+
|
|
64
|
+
nesting + segments
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The constant path as written, without any resolution — partial when the
|
|
68
|
+
# chain hangs off a dynamic root (`expr::CONST` gives just [:CONST]).
|
|
69
|
+
def syntactic_const_segments(node)
|
|
70
|
+
case node
|
|
71
|
+
when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
|
|
72
|
+
segments = [node.name]
|
|
73
|
+
current = node.parent
|
|
74
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
75
|
+
segments.unshift(current.name)
|
|
76
|
+
current = current.parent
|
|
77
|
+
end
|
|
78
|
+
segments.unshift(current.name) if current.is_a?(Prism::ConstantReadNode)
|
|
79
|
+
segments
|
|
80
|
+
else
|
|
81
|
+
# Reached only for bare named nodes (ConstantRead and the compound
|
|
82
|
+
# constant writes), which all carry #name; the parameter stays
|
|
83
|
+
# Prism::Node for the shared walk.
|
|
84
|
+
[node.name] # steep:ignore NoMethod
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `X = Struct.new(...)` / `X = Data.define(...)` assigns a class, so the
|
|
89
|
+
# constant registers as a class definition: it renames exactly when class
|
|
90
|
+
# renaming is on, and reopenings resolve through the same machinery.
|
|
91
|
+
def struct_definition_write?(node)
|
|
92
|
+
return false unless node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
|
|
93
|
+
|
|
94
|
+
value = node.value
|
|
95
|
+
return false unless value.is_a?(Prism::CallNode)
|
|
96
|
+
|
|
97
|
+
receiver = value.receiver
|
|
98
|
+
return false unless receiver.is_a?(Prism::ConstantReadNode)
|
|
99
|
+
|
|
100
|
+
(value.name == :new && receiver.name == :Struct) ||
|
|
101
|
+
(value.name == :define && receiver.name == :Data)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def collect_constants(prism_root)
|
|
105
|
+
each_constant_event(prism_root) do |kind, node, cpath, singleton, in_def|
|
|
106
|
+
case kind
|
|
107
|
+
when :class_def
|
|
108
|
+
type = node.is_a?(Prism::ClassNode) ? :class : :module
|
|
109
|
+
@constant_mapping.add_definition_with_path(cpath, definition_type: type)
|
|
110
|
+
when :write
|
|
111
|
+
# Skip constants defined inside `class << self` — they live on the
|
|
112
|
+
# metaclass and cannot be accessed as Foo::X, so alias declarations
|
|
113
|
+
# would fail at runtime. (An assignment inside a def body there is a
|
|
114
|
+
# plain assignment and still registers.)
|
|
115
|
+
next if singleton && !in_def
|
|
116
|
+
|
|
117
|
+
type = struct_definition_write?(node) ? :class : :value
|
|
118
|
+
@constant_mapping.add_definition_with_path(cpath, definition_type: type)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def exclude_private_constants(prism_root)
|
|
124
|
+
Nesting.each(prism_root) do |node, nesting, _singleton, _in_def|
|
|
125
|
+
next unless node.is_a?(Prism::CallNode)
|
|
126
|
+
next unless %i[private_constant public_constant].include?(node.name)
|
|
127
|
+
|
|
128
|
+
AstUtils.symbol_arguments(node).each do |sym|
|
|
129
|
+
@constant_mapping.exclude_path(nesting + [sym])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def count_constant_references(prism_root)
|
|
135
|
+
each_constant_event(prism_root) do |kind, node, cpath, _singleton, _in_def|
|
|
136
|
+
case kind
|
|
137
|
+
when :class_def, :write
|
|
138
|
+
@constant_mapping.increment_usage_by_path(cpath)
|
|
139
|
+
when :read
|
|
140
|
+
increment_constant_read_usage(node)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def increment_constant_read_usage(node)
|
|
146
|
+
user_path = resolve_user_defined_cpath(node)
|
|
147
|
+
if user_path
|
|
148
|
+
@constant_mapping.increment_usage_by_path(user_path)
|
|
149
|
+
else
|
|
150
|
+
@constant_mapping.increment_usage(node.name) # steep:ignore NoMethod
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def resolve_user_defined_cpath(node)
|
|
155
|
+
[@oracle.resolve_constant_read(node), syntactic_const_segments(node)].each do |cpath|
|
|
156
|
+
return cpath if cpath && @constant_mapping.user_defined_path?(cpath)
|
|
157
|
+
end
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Where a superclass reference lands, for the alias patcher. A qualified
|
|
162
|
+
# path is taken as written; a bare name is meaningful only when it names a
|
|
163
|
+
# user-defined constant in the class's enclosing scope.
|
|
164
|
+
def resolve_superclass_path(superclass_node, class_cpath)
|
|
165
|
+
case superclass_node
|
|
166
|
+
when Prism::ConstantPathNode
|
|
167
|
+
syntactic_const_segments(superclass_node)
|
|
168
|
+
when Prism::ConstantReadNode
|
|
169
|
+
full_path = class_cpath[0...-1] + [superclass_node.name] # steep:ignore NoMethod
|
|
170
|
+
full_path if @constant_mapping&.user_defined_path?(full_path)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A closed-world walk sees each textual reference once, but type analysis
|
|
175
|
+
# can record more read sites than the text shows (reads reached through
|
|
176
|
+
# resolution rather than spelling). Take whichever count is higher, so a
|
|
177
|
+
# rename is never judged unprofitable on an undercount.
|
|
178
|
+
def augment_constant_counts_via_oracle
|
|
179
|
+
@constant_mapping.each_user_defined_path do |cpath|
|
|
180
|
+
oracle_count = @oracle.constant_read_count(cpath)
|
|
181
|
+
current_count = @constant_mapping.usage_count_for_path(cpath)
|
|
182
|
+
@constant_mapping.set_usage_count_by_path(cpath, oracle_count) if oracle_count > current_count
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Location-keyed maps the patchers consume: resolution and full path per
|
|
187
|
+
# read, write cpath per assignment, cpath and superclass per definition.
|
|
188
|
+
def precompute_constant_resolution
|
|
189
|
+
@const_resolution_map = {} #: Hash[location_key, Array[Symbol]?]
|
|
190
|
+
@const_full_path_map = {} #: Hash[location_key, Array[Symbol]]
|
|
191
|
+
@const_write_cpath_map = {} #: Hash[location_key, Array[Symbol]]
|
|
192
|
+
@class_cpath_map = {} #: Hash[location_key, Array[Symbol]]
|
|
193
|
+
@superclass_resolution_map = {} #: Hash[location_key, Array[Symbol]]
|
|
194
|
+
|
|
195
|
+
each_constant_event(@prism_root) do |kind, node, cpath, _singleton, _in_def|
|
|
196
|
+
case kind
|
|
197
|
+
when :read
|
|
198
|
+
record_constant_read(node)
|
|
199
|
+
when :write
|
|
200
|
+
@const_write_cpath_map[AstUtils.location_key(node)] = cpath
|
|
201
|
+
when :class_def
|
|
202
|
+
key = AstUtils.location_key(node)
|
|
203
|
+
@class_cpath_map[key] = cpath
|
|
204
|
+
if node.is_a?(Prism::ClassNode) && node.superclass
|
|
205
|
+
# cpath is never nil for a :class_def event.
|
|
206
|
+
resolved = resolve_superclass_path(node.superclass, cpath) # steep:ignore ArgumentTypeMismatch
|
|
207
|
+
@superclass_resolution_map[key] = resolved if resolved
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def record_constant_read(node)
|
|
214
|
+
key = AstUtils.location_key(node)
|
|
215
|
+
resolved = @oracle.resolve_constant_read(node)
|
|
216
|
+
@const_resolution_map[key] = resolved
|
|
217
|
+
@const_full_path_map[key] = resolved || syntactic_const_segments(node)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def collect_external_references(prism_root)
|
|
221
|
+
counted_prefix_ids = Set.new
|
|
222
|
+
prefix_counts = Hash.new(0)
|
|
223
|
+
|
|
224
|
+
each_constant_event(prism_root) do |kind, node, _cpath, _singleton, _in_def|
|
|
225
|
+
next unless kind == :read
|
|
226
|
+
next if counted_prefix_ids.include?(node.object_id)
|
|
227
|
+
|
|
228
|
+
mark_chain_prefixes(node, counted_prefix_ids)
|
|
229
|
+
prefix = external_prefix_for(node)
|
|
230
|
+
prefix_counts[prefix] += 1 if prefix
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
prefix_counts.each do |prefix, count|
|
|
234
|
+
@constant_mapping.add_external_prefix(prefix, usage_count: count)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Mark the reference's prefix chain so sub-paths are not counted again as
|
|
239
|
+
# references of their own.
|
|
240
|
+
def mark_chain_prefixes(node, counted_prefix_ids)
|
|
241
|
+
current = case node
|
|
242
|
+
when Prism::ConstantPathNode, Prism::ConstantPathTargetNode then node.parent
|
|
243
|
+
end
|
|
244
|
+
while current.is_a?(Prism::ConstantPathNode) || current.is_a?(Prism::ConstantReadNode)
|
|
245
|
+
counted_prefix_ids << current.object_id
|
|
246
|
+
current = current.is_a?(Prism::ConstantPathNode) ? current.parent : nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The external prefix this reference should be counted under, or nil when
|
|
251
|
+
# it has none: the reference must not touch the project's own namespace,
|
|
252
|
+
# and must either resolve through type analysis or — the fallback for gems
|
|
253
|
+
# with no RBS — be spelled in full from a root that one of the program's
|
|
254
|
+
# own top-level requires provides. The requires are re-emitted ahead of
|
|
255
|
+
# the preamble, so such a root provably exists when the alias declaration
|
|
256
|
+
# runs; without that anchor an alias would turn "NameError if this line is
|
|
257
|
+
# ever reached" into "NameError at boot", or capture the wrong constant
|
|
258
|
+
# for a reference that resolves through its nesting at runtime.
|
|
259
|
+
def external_prefix_for(node)
|
|
260
|
+
full_path = syntactic_const_segments(node)
|
|
261
|
+
resolved_cpath = @oracle.resolve_constant_read(node)
|
|
262
|
+
return nil if @constant_mapping.user_defined_path?(full_path)
|
|
263
|
+
return nil if resolved_cpath && @constant_mapping.user_defined_path?(resolved_cpath)
|
|
264
|
+
|
|
265
|
+
effective_path = resolved_cpath ||
|
|
266
|
+
(full_path if complete_const_chain?(node) && required_external_root?(full_path.first))
|
|
267
|
+
return nil unless effective_path
|
|
268
|
+
return nil if effective_path.size < 2
|
|
269
|
+
return nil if @constant_mapping.has_user_defined_prefix?(full_path)
|
|
270
|
+
|
|
271
|
+
prefix = effective_path[0...-1]
|
|
272
|
+
return nil if @constant_mapping.user_defined_path?(prefix)
|
|
273
|
+
|
|
274
|
+
prefix
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# True when the qualified chain is rooted at a plain constant (or written
|
|
278
|
+
# absolute), so its syntactic segments are its complete spelling.
|
|
279
|
+
def complete_const_chain?(node)
|
|
280
|
+
case node
|
|
281
|
+
when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
|
|
282
|
+
!Nesting.path_segments(node).nil?
|
|
283
|
+
else
|
|
284
|
+
false
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def required_external_root?(root)
|
|
289
|
+
@boot_constant_roots&.include?(root) || false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The top-level constants that exist when the minified program boots,
|
|
295
|
+
# before any of its own code runs: Ruby's core names plus whatever its
|
|
296
|
+
# top-level requires provide. Asking a fresh interpreter is what makes
|
|
297
|
+
# this exact — no naming-convention guessing, and no contamination from
|
|
298
|
+
# the constants the minifier process itself happens to have loaded.
|
|
299
|
+
# nil when the probe fails, so callers can fall back conservatively.
|
|
300
|
+
#
|
|
301
|
+
# Its own module rather than a phase method: the memo is process state
|
|
302
|
+
# (one probe per require set, because the probe boots an interpreter),
|
|
303
|
+
# and process state does not belong on a mixin of per-run phases.
|
|
304
|
+
module BootConstants
|
|
305
|
+
@cache = {} #: Hash[Array[String], Set[Symbol]?]
|
|
306
|
+
|
|
307
|
+
class << self
|
|
308
|
+
def for(stdlib_requires)
|
|
309
|
+
key = stdlib_requires.sort
|
|
310
|
+
return @cache[key] if @cache.key?(key)
|
|
311
|
+
|
|
312
|
+
@cache[key] = probe(stdlib_requires)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
# The probe must run outside the minifier's bundler context: with it, the
|
|
318
|
+
# subprocess boots bundler, bundler evaluates the analyzed project's
|
|
319
|
+
# gemspec-style Gemfile, and the program's own constants leak into the
|
|
320
|
+
# "exists at boot" answer — under self-hosting, Ryac itself.
|
|
321
|
+
def probe(stdlib_requires)
|
|
322
|
+
requires = stdlib_requires.map { |r| "begin;require #{r.dump};rescue LoadError;end" }.join(';')
|
|
323
|
+
script = "#{requires};puts Object.constants"
|
|
324
|
+
popen = -> { IO.popen([{ 'RUBYOPT' => nil }, RbConfig.ruby, '-e', script], &:read) } #: ^() -> String
|
|
325
|
+
out = defined?(Bundler) ? Bundler.with_unbundled_env(&popen) : popen.call
|
|
326
|
+
Process.last_status&.success? ? out.split.map(&:to_sym).to_set : nil
|
|
327
|
+
rescue StandardError
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Ryac
|
|
6
|
+
# Represents an external library constant prefix that can be aliased
|
|
7
|
+
ExternalPrefixInfo = Struct.new(
|
|
8
|
+
:prefix_path, # Array<Symbol> - Prefix path (e.g., [:TypeProf, :Core, :AST])
|
|
9
|
+
:prefix_string, # String - Full prefix string (e.g., "TypeProf::Core::AST")
|
|
10
|
+
:short_name, # String - Assigned short name (e.g., "Z")
|
|
11
|
+
:usage_count, # Integer - Number of references using this prefix
|
|
12
|
+
:char_savings, # Integer - Net character savings from aliasing
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def initialize(prefix_path: nil, prefix_string: nil, short_name: nil, # steep:ignore UndeclaredMethodDefinition
|
|
16
|
+
usage_count: nil, char_savings: nil)
|
|
17
|
+
# @type self: ExternalPrefixInfo
|
|
18
|
+
self.prefix_path = prefix_path || []
|
|
19
|
+
self.prefix_string = prefix_string
|
|
20
|
+
self.short_name = short_name
|
|
21
|
+
self.usage_count = usage_count || 0
|
|
22
|
+
self.char_savings = char_savings || 0
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Represents a user-defined constant found in the source file
|
|
27
|
+
ConstantInfo = Struct.new(
|
|
28
|
+
:original_name, # Symbol - Original constant name (e.g., :MyClass)
|
|
29
|
+
:full_path, # Array<Symbol> - Full namespace path (e.g., [:Foo, :Bar, :MyClass])
|
|
30
|
+
:short_name, # String - Assigned short name (e.g., "A")
|
|
31
|
+
:usage_count, # Integer - Number of references in the file
|
|
32
|
+
:definition_type, # Symbol - :class, :module, or :value
|
|
33
|
+
:scope_path, # Array<Symbol> - Module/class scope where defined
|
|
34
|
+
keyword_init: true
|
|
35
|
+
) do
|
|
36
|
+
def initialize(original_name: nil, full_path: nil, short_name: nil, # steep:ignore UndeclaredMethodDefinition
|
|
37
|
+
usage_count: nil, definition_type: nil, scope_path: nil)
|
|
38
|
+
# @type self: ConstantInfo
|
|
39
|
+
self.original_name = original_name
|
|
40
|
+
self.full_path = full_path || []
|
|
41
|
+
self.short_name = short_name
|
|
42
|
+
self.usage_count = usage_count || 0
|
|
43
|
+
self.definition_type = definition_type
|
|
44
|
+
self.scope_path = scope_path || []
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Tracks the mapping between original and short constant names
|
|
49
|
+
# Uses static_cpath (full qualified path) as key to distinguish
|
|
50
|
+
# constants with same name in different modules.
|
|
51
|
+
# Also tracks external prefix aliases (absorbed from ExternalPrefixAliaser).
|
|
52
|
+
class ConstantRenameMapping
|
|
53
|
+
attr_reader :mappings, :used_short_names
|
|
54
|
+
|
|
55
|
+
# boot_roots: the top-level constants that exist when the minified
|
|
56
|
+
# program boots (core + its own requires), used to spot reopenings of
|
|
57
|
+
# classes the program didn't create. nil falls back to probing the
|
|
58
|
+
# minifier's own process, which over-approximates: everything the
|
|
59
|
+
# minifier has loaded — including, under self-hosting, the analyzed
|
|
60
|
+
# program itself — then looks like a reopening.
|
|
61
|
+
def initialize(boot_roots: nil)
|
|
62
|
+
@boot_roots = boot_roots
|
|
63
|
+
@mappings = {} # Hash<Array<Symbol>, ConstantInfo> - key is static_cpath
|
|
64
|
+
# keyed by the last path segment
|
|
65
|
+
@by_name = {}
|
|
66
|
+
@used_short_names = Set.new
|
|
67
|
+
@external_prefixes = {}
|
|
68
|
+
@prefix_counts = Hash.new(0) # Hash<Array<Symbol>, Integer> - raw prefix reference counts
|
|
69
|
+
@state = :empty
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty?
|
|
73
|
+
@state == :empty
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def finalized?
|
|
77
|
+
@state == :frozen
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add a constant definition using TypeProf's static_cpath
|
|
81
|
+
def add_definition_with_path(static_cpath, definition_type:)
|
|
82
|
+
raise "Cannot add definitions when finalized" if finalized?
|
|
83
|
+
@state = :collecting if empty?
|
|
84
|
+
|
|
85
|
+
return if @mappings.key?(static_cpath)
|
|
86
|
+
|
|
87
|
+
name = static_cpath.last
|
|
88
|
+
scope_path = static_cpath[0...-1]
|
|
89
|
+
|
|
90
|
+
info = ConstantInfo.new(
|
|
91
|
+
original_name: name,
|
|
92
|
+
full_path: static_cpath,
|
|
93
|
+
definition_type: definition_type,
|
|
94
|
+
scope_path: scope_path
|
|
95
|
+
)
|
|
96
|
+
@mappings[static_cpath] = info
|
|
97
|
+
|
|
98
|
+
# Also index by simple name for backward compatibility
|
|
99
|
+
(@by_name[name] ||= []) << info
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Increment usage count for a constant by static_cpath
|
|
103
|
+
def increment_usage_by_path(static_cpath)
|
|
104
|
+
raise "Cannot increment usage when finalized" if finalized?
|
|
105
|
+
return unless @mappings.key?(static_cpath)
|
|
106
|
+
|
|
107
|
+
info = @mappings[static_cpath]
|
|
108
|
+
info.usage_count += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Increment usage count by simple name (finds first match)
|
|
112
|
+
def increment_usage(name)
|
|
113
|
+
raise "Cannot increment usage when finalized" if finalized?
|
|
114
|
+
return unless @by_name.key?(name)
|
|
115
|
+
|
|
116
|
+
# Increment all constants with this name
|
|
117
|
+
@by_name[name].each do |info|
|
|
118
|
+
info.usage_count += 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def exclude_path(static_cpath)
|
|
123
|
+
raise "Cannot exclude when finalized" if finalized?
|
|
124
|
+
info = @mappings.delete(static_cpath)
|
|
125
|
+
return unless info
|
|
126
|
+
name = static_cpath.last
|
|
127
|
+
@by_name[name]&.reject! { |i| i.full_path == static_cpath }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Freeze the mapping and assign short names.
|
|
131
|
+
# Unified allocation: internal constants and external prefixes are merged
|
|
132
|
+
# into a single sorted list and allocated from the same NameGenerator.
|
|
133
|
+
# This follows the src.dest two-phase model: propagation (this method)
|
|
134
|
+
# determines ALL short names before any application.
|
|
135
|
+
def assign_short_names(name_generator, skip_class_modules: false)
|
|
136
|
+
raise "Already finalized" if finalized?
|
|
137
|
+
|
|
138
|
+
@state = :frozen
|
|
139
|
+
prefix_counts = @prefix_counts
|
|
140
|
+
@prefix_counts = nil
|
|
141
|
+
|
|
142
|
+
existing_names = @mappings.each_value.with_object(Set.new) { |info, s| s << info.original_name.to_s }
|
|
143
|
+
|
|
144
|
+
# Augment prefix counts with preamble-induced parent refs:
|
|
145
|
+
# each prefix's declaration (e.g., C5=RuboCop::Cop) references its parent.
|
|
146
|
+
# Adding unconditionally avoids the chicken-and-egg problem of needing to
|
|
147
|
+
# know which children are aliased before counting parent refs.
|
|
148
|
+
prefix_counts.keys.each do |prefix|
|
|
149
|
+
next if prefix.size < 2
|
|
150
|
+
prefix_counts[prefix[0...-1]] += 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Build unified allocation list: [gross_savings_estimate, :internal/:external, object]
|
|
154
|
+
entries = [] #: Array[[Integer, Symbol, untyped]]
|
|
155
|
+
|
|
156
|
+
@mappings.each_value do |info|
|
|
157
|
+
next if skip_class_modules && info.definition_type != :value
|
|
158
|
+
next if info.definition_type != :value && external_class_root?(info.full_path)
|
|
159
|
+
entries << [info.original_name.to_s.size * (info.usage_count + 1), :internal, info]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
prefix_counts.each do |prefix, count|
|
|
163
|
+
prefix_string = prefix.map(&:to_s).join('::')
|
|
164
|
+
info = ExternalPrefixInfo.new(prefix_path: prefix, prefix_string: prefix_string, usage_count: count)
|
|
165
|
+
entries << [prefix_string.size * count, :external, info]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
entries.sort_by! { |e| -e[0] }
|
|
169
|
+
|
|
170
|
+
# candidate is assigned on the first iteration and only replaced after
|
|
171
|
+
candidate = nil #: String?
|
|
172
|
+
entries.each do |_savings, kind, info|
|
|
173
|
+
if candidate.nil?
|
|
174
|
+
candidate = name_generator.next_name
|
|
175
|
+
candidate = name_generator.next_name while existing_names.include?(candidate)
|
|
176
|
+
end
|
|
177
|
+
cand = candidate #: String
|
|
178
|
+
|
|
179
|
+
case kind
|
|
180
|
+
when :internal
|
|
181
|
+
# Kept names make re-minification a fixed point: the names this
|
|
182
|
+
# pass assigns are themselves KEPT_NAME_MAX or shorter, and a
|
|
183
|
+
# second pass must not shuffle them again.
|
|
184
|
+
next if info.original_name.to_s.size <= NameGenerator::KEPT_NAME_MAX
|
|
185
|
+
next unless info.original_name.to_s.size - cand.size > 0
|
|
186
|
+
info.short_name = cand
|
|
187
|
+
@used_short_names << cand
|
|
188
|
+
|
|
189
|
+
when :external
|
|
190
|
+
saved_per_use = info.prefix_string.size - cand.size
|
|
191
|
+
next unless saved_per_use > 0
|
|
192
|
+
declaration_cost = cand.size + 1 + info.prefix_string.size + 1
|
|
193
|
+
net_savings = (saved_per_use * info.usage_count) - declaration_cost
|
|
194
|
+
next unless net_savings > 0
|
|
195
|
+
info.short_name = cand
|
|
196
|
+
info.char_savings = net_savings
|
|
197
|
+
existing_names << cand
|
|
198
|
+
@external_prefixes[info.prefix_path] = info
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
candidate = nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Generate backward-compatible alias declarations for renamed constants.
|
|
206
|
+
# Returns array of strings like "OriginalName=ShortName" or
|
|
207
|
+
# "ShortParent::OriginalName=ShortParent::ShortName" for nested constants.
|
|
208
|
+
def generate_alias_declarations
|
|
209
|
+
renamed = @mappings.values.select(&:short_name).sort_by { |info| [info.full_path.size, info.full_path] }
|
|
210
|
+
renamed.filter_map { |info| build_alias_declaration(info) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Get short name for a constant by static_cpath. The parameter is nilable
|
|
214
|
+
# for the callers that pass sliced sub-paths; a nil key simply misses.
|
|
215
|
+
def short_name_for_path(static_cpath)
|
|
216
|
+
info = static_cpath ? @mappings[static_cpath] : nil
|
|
217
|
+
info&.short_name
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Get short name for a constant by simple name (finds first match)
|
|
221
|
+
# Used when static_cpath is not available
|
|
222
|
+
def short_name_for(name)
|
|
223
|
+
return nil unless @by_name.key?(name)
|
|
224
|
+
infos = @by_name[name]
|
|
225
|
+
return nil if infos.empty?
|
|
226
|
+
# Return first match (for backward compatibility)
|
|
227
|
+
infos.first&.short_name
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Check if a constant is user-defined by static_cpath
|
|
231
|
+
def user_defined_path?(static_cpath)
|
|
232
|
+
@mappings.key?(static_cpath)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Get usage count for a constant by static_cpath
|
|
236
|
+
def usage_count_for_path(static_cpath)
|
|
237
|
+
info = @mappings[static_cpath]
|
|
238
|
+
info ? info.usage_count : 0
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Set usage count for a constant by static_cpath
|
|
242
|
+
def set_usage_count_by_path(static_cpath, count)
|
|
243
|
+
raise "Cannot set usage when finalized" if finalized?
|
|
244
|
+
info = @mappings[static_cpath]
|
|
245
|
+
info.usage_count = count if info
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Iterate over user-defined constant paths
|
|
249
|
+
def each_user_defined_path(&block)
|
|
250
|
+
@mappings.each_key(&block)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Check if a path is a class or module definition
|
|
254
|
+
def class_or_module_path?(static_cpath)
|
|
255
|
+
return false unless static_cpath
|
|
256
|
+
info = @mappings[static_cpath]
|
|
257
|
+
info && (info.definition_type == :class || info.definition_type == :module)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Check if any sub-prefix of the path is user-defined
|
|
261
|
+
def has_user_defined_prefix?(full_path)
|
|
262
|
+
(1...full_path.size).any? { |i| user_defined_path?(full_path[0...i]) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Add an external prefix reference count (e.g., [:TypeProf, :Core, :AST] with count 20)
|
|
266
|
+
def add_external_prefix(prefix_path, usage_count:)
|
|
267
|
+
raise "Cannot add external prefix when finalized" if finalized?
|
|
268
|
+
@state = :collecting if empty?
|
|
269
|
+
@prefix_counts[prefix_path] += usage_count
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Get short name for the prefix of a full external path
|
|
273
|
+
def short_name_for_prefix(full_path)
|
|
274
|
+
return nil if full_path.nil? || full_path.size < 2
|
|
275
|
+
prefix = full_path[0...-1] #: Array[Symbol]
|
|
276
|
+
info = @external_prefixes[prefix]
|
|
277
|
+
info&.short_name
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Generate prefix declaration statements (e.g., ["Z=TypeProf::Core::AST"])
|
|
281
|
+
# Uses chained aliases when a sub-prefix is also aliased
|
|
282
|
+
def generate_prefix_declarations
|
|
283
|
+
sorted = @external_prefixes.values.sort_by { |info| [info.prefix_path.size, -info.char_savings] }
|
|
284
|
+
|
|
285
|
+
alias_map = {} #: Hash[Array[Symbol], String?]
|
|
286
|
+
sorted.map do |info|
|
|
287
|
+
decl_rhs = info.prefix_string
|
|
288
|
+
(info.prefix_path.size - 1).downto(2) do |len|
|
|
289
|
+
sub = info.prefix_path[0...len] #: Array[Symbol]
|
|
290
|
+
if alias_map.key?(sub)
|
|
291
|
+
rest = info.prefix_path[len..] #: Array[Symbol]
|
|
292
|
+
decl_rhs = "#{alias_map[sub]}::#{rest.map(&:to_s).join('::')}"
|
|
293
|
+
break
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
alias_map[info.prefix_path] = info.short_name
|
|
297
|
+
"#{info.short_name}=#{decl_rhs}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
# A class/module definition whose root the program didn't create is a
|
|
304
|
+
# reopening (e.g. `class Array`, or `module Prism` adding to the gem) and
|
|
305
|
+
# must not be renamed. With boot_roots the judgement is exact for the
|
|
306
|
+
# program's own runtime; without it, fall back to probing this process.
|
|
307
|
+
def external_class_root?(cpath)
|
|
308
|
+
return runtime_constant?(cpath) if @boot_roots.nil?
|
|
309
|
+
|
|
310
|
+
@boot_roots.include?(cpath.first)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Check if a constant path already exists in the Ruby runtime.
|
|
314
|
+
# Used to detect class/module reopenings (e.g., `class Array` adding methods
|
|
315
|
+
# to a built-in class) which must not be renamed.
|
|
316
|
+
def runtime_constant?(cpath)
|
|
317
|
+
cpath.reduce(Object) do |mod, name|
|
|
318
|
+
return false unless mod.is_a?(Module) && mod.const_defined?(name, false)
|
|
319
|
+
mod.const_get(name, false)
|
|
320
|
+
end
|
|
321
|
+
true
|
|
322
|
+
rescue
|
|
323
|
+
false
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Build backward alias declaration for renamed value constants.
|
|
327
|
+
# Class/module constants are never renamed (no short_name assigned).
|
|
328
|
+
def build_alias_declaration(info)
|
|
329
|
+
path = info.full_path
|
|
330
|
+
# LHS: short parent path + original leaf name
|
|
331
|
+
lhs = path.each_index.map { |i|
|
|
332
|
+
if i < path.size - 1
|
|
333
|
+
short_name_for_path(path[0..i]) || path[i].to_s
|
|
334
|
+
else
|
|
335
|
+
path[i].to_s
|
|
336
|
+
end
|
|
337
|
+
}.join('::')
|
|
338
|
+
# RHS: full short path
|
|
339
|
+
rhs = path.each_index.map { |i|
|
|
340
|
+
short_name_for_path(path[0..i]) || path[i].to_s
|
|
341
|
+
}.join('::')
|
|
342
|
+
lhs == rhs ? nil : "#{lhs}=#{rhs}"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
end
|