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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The error hierarchy lives in its own required file, not in ryac.rb:
4
+ # superclass references execute at load, and the concatenated self-host
5
+ # artifact places dependencies first and the root file's own body last —
6
+ # a class defined in the root body can never be a parent for a class
7
+ # defined in a required file.
8
+ module Ryac
9
+ class Error < StandardError; end
10
+
11
+ # The program being minified is at fault — fix the input.
12
+ class InputError < Error; end
13
+
14
+ # ryac itself is at fault — please report it.
15
+ class InternalError < Error; end
16
+
17
+ # The input uses a construct minification cannot handle.
18
+ class MinifyError < InputError; end
19
+
20
+ # The input does not parse. Carries the coordinates of the first error.
21
+ class SyntaxError < InputError
22
+ attr_reader :path, :line, :column
23
+
24
+ def initialize(message, path: nil, line: nil, column: nil)
25
+ @path = path
26
+ @line = line
27
+ @column = column
28
+ message = "#{path}:#{line}:#{column}: #{message}" if path
29
+ super(message)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ class GemResolver
5
+ GemResolution = Data.define(:entry_path, :project_root, :require_paths)
6
+
7
+ # The one spelling of "minify these installed gems": folds each gem's
8
+ # resolution into the Minifier#call keyword arguments, so the CLI and
9
+ # anything replicating it assemble the same invocation.
10
+ def self.minifier_args(gem_names)
11
+ resolver = new
12
+ resolutions = gem_names.map { |name| resolver.call(name) }
13
+ entry = resolutions.map(&:entry_path)
14
+ entry = entry[0] if entry.size == 1
15
+ {
16
+ entry: entry,
17
+ project_root: resolutions.map(&:project_root),
18
+ gem_require_paths: resolutions.flat_map(&:require_paths),
19
+ }
20
+ end
21
+
22
+ def call(gem_name)
23
+ spec = Gem::Specification.find_by_name(gem_name)
24
+ entry_path = find_entry_file(spec, gem_name)
25
+ raise Pipeline::GemNotFoundError.new(gem_name) unless entry_path
26
+
27
+ GemResolution.new(
28
+ entry_path: entry_path,
29
+ project_root: spec.gem_dir,
30
+ require_paths: spec.full_require_paths
31
+ )
32
+ rescue Gem::MissingSpecError
33
+ raise Pipeline::GemNotFoundError.new(gem_name)
34
+ end
35
+
36
+ private
37
+
38
+ def find_entry_file(spec, gem_name)
39
+ candidates = [
40
+ gem_name, # json
41
+ gem_name.tr('-', '_'), # concurrent-ruby -> concurrent_ruby
42
+ gem_name.tr('-', '/'), # unicode-display_width -> unicode/display_width
43
+ ].uniq
44
+
45
+ spec.full_require_paths.each do |dir|
46
+ candidates.each do |name|
47
+ path = File.join(dir, "#{name}.rb")
48
+ return path if File.exist?(path)
49
+ end
50
+ end
51
+
52
+ nil
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pipeline/stage'
4
+ require_relative 'pipeline/data_types'
5
+ require_relative 'pipeline/errors'
6
+ require_relative 'pipeline/file_collector'
7
+ require_relative 'pipeline/concatenator'
8
+ require_relative 'pipeline/analyzer'
9
+ require_relative 'pipeline/source_patcher'
10
+ require_relative 'pipeline/compactor'
11
+ require_relative 'pipeline/boolean_shorten'
12
+ require_relative 'pipeline/char_shorten'
13
+ require_relative 'pipeline/spelling_shorten'
14
+ require_relative 'pipeline/constant_fold'
15
+ require_relative 'pipeline/control_flow_simplify'
16
+ require_relative 'pipeline/endless_method'
17
+ require_relative 'pipeline/paren_optimizer'
18
+ require_relative 'pipeline/rename_patcher'
19
+ require_relative 'pipeline/stage_runner'
20
+ require_relative 'pipeline/constant_aliaser'
21
+ require_relative 'pipeline/attr_decl_shorten'
22
+ require_relative 'pipeline/variable_renamer'
23
+ require_relative 'pipeline/method_renamer'
24
+
25
+ module Ryac
26
+ class Minifier
27
+ # Stage lists are ordered — phase is list position. The optimizers that
28
+ # run before the rename batch:
29
+ OPTIMIZE_PRE = [
30
+ # ControlFlowSimplify must precede EndlessMethod: a def body has to
31
+ # collapse to a single statement before the def can become endless.
32
+ [Pipeline::ControlFlowSimplify],
33
+ [Pipeline::EndlessMethod],
34
+ [Pipeline::ConstantFold],
35
+ [Pipeline::BooleanShorten],
36
+ [Pipeline::CharShorten],
37
+ [Pipeline::SpellingShorten],
38
+ ].freeze
39
+
40
+ # Paren removal reasons about the final spelling of every call, so it
41
+ # runs after all renames.
42
+ OPTIMIZE_POST = [
43
+ [Pipeline::ParenOptimizer],
44
+ ].freeze
45
+
46
+ DEFAULT_LEVEL = :stable
47
+
48
+ def self.resolve_level(value)
49
+ level = value.to_sym
50
+ return level if STAGES.key?(level)
51
+
52
+ raise ArgumentError, "Invalid compress level: #{value} (valid: #{STAGES.keys.join(', ')})"
53
+ end
54
+
55
+ ALL_VAR_FEATURES = { features: { keywords: true, ivars: true, cvars: true, gvars: true } }.freeze
56
+ ALL_VAR_WITH_ATTR = { features: { keywords: true, ivars: true, cvars: true, gvars: true, attr_ivars: true } }.freeze
57
+
58
+ # :unstable is derived from :stable, never hand-copied — the superset
59
+ # law lives here as code. "attrs renamed" is one fact spelled by three
60
+ # co-moving switches: AttrDeclShorten rename_attrs (declarations),
61
+ # attr_ivars (backing ivars) and MethodRenamer (call sites). This
62
+ # transform flips all three together; a hand-composed list must too.
63
+ def self.derive_unstable(stable)
64
+ stable.flat_map do |entry|
65
+ if entry[0].equal?(Pipeline::AttrDeclShorten)
66
+ [[Pipeline::AttrDeclShorten, { rename_attrs: true }]] #: Array[stage_entry]
67
+ elsif entry[0].equal?(Pipeline::VariableRenamer)
68
+ [[Pipeline::VariableRenamer, ALL_VAR_WITH_ATTR], [Pipeline::MethodRenamer]] #: Array[stage_entry]
69
+ else
70
+ [entry]
71
+ end
72
+ end.freeze
73
+ end
74
+
75
+ # The two levels, named for their promise rather than a number.
76
+ #
77
+ # :stable is the boundary the optcarrot test certifies frame-for-frame on
78
+ # a real program: everything up to class, constant and variable renaming,
79
+ # which closed-world analysis can keep sound.
80
+ #
81
+ # :unstable adds method renaming, which a program can defeat by
82
+ # construction — names survive inside strings, eval'd source and computed
83
+ # send targets, out of reach of any static analysis. It is certified by
84
+ # self-hosting and works only when the program plays along.
85
+ #
86
+ # Finer configurations are not levels: individual steps stay composable
87
+ # by passing an explicit stage list in place of a level name.
88
+ STABLE_STAGES = [
89
+ *OPTIMIZE_PRE,
90
+ [Pipeline::ConstantAliaser, { rename_classes: true }],
91
+ [Pipeline::AttrDeclShorten],
92
+ [Pipeline::VariableRenamer, ALL_VAR_FEATURES],
93
+ *OPTIMIZE_POST,
94
+ ].freeze
95
+
96
+ STAGES = {
97
+ stable: STABLE_STAGES,
98
+ unstable: derive_unstable(STABLE_STAGES),
99
+ }.freeze
100
+
101
+ # code is raw (uncompacted) text — compaction is the runner's fixed
102
+ # first step, so every stage list starts from the same dialect.
103
+ def self.run_stages(code, stages, stdlib_requires: [], rbs_files: {})
104
+ Pipeline::StageRunner.new(stdlib_requires: stdlib_requires, rbs_files: rbs_files)
105
+ .call(code, stages)
106
+ end
107
+
108
+ include Pipeline::SourcePatcher
109
+
110
+ attr_reader :result
111
+
112
+ def initialize
113
+ @file_collector = Pipeline::FileCollector.new
114
+ @concatenator = Pipeline::Concatenator.new
115
+ end
116
+
117
+ def call(entry_path, level: DEFAULT_LEVEL, project_root: nil, gem_names: [], gem_require_paths: [])
118
+ graph = @file_collector.call(entry_path, project_root: project_root, gem_names: gem_names, gem_require_paths: gem_require_paths)
119
+ source = @concatenator.call(graph)
120
+ # Captured while the boundaries still describe the text; from
121
+ # compaction on they are input provenance, not current positions.
122
+ file_count = source.file_boundaries.size
123
+
124
+ result = run_pipeline(source, level)
125
+ build_result(result, source, file_count)
126
+ end
127
+
128
+ private
129
+
130
+ # target_level is a preset name, or — for callers composing their own
131
+ # pipeline out of steps — an explicit stage list.
132
+ def run_pipeline(source, target_level)
133
+ stages = target_level.is_a?(Array) ? target_level : STAGES.fetch(self.class.resolve_level(target_level))
134
+ self.class.run_stages(source.content, stages,
135
+ stdlib_requires: source.stdlib_requires,
136
+ rbs_files: source.rbs_files
137
+ )
138
+ end
139
+
140
+ def build_result(rename_result, source, file_count)
141
+ content = build_output(rename_result.code, source.stdlib_requires, rename_result.preamble)
142
+ # code and aliases were verified at the runner's exit; the assembled
143
+ # content is the one string the runner never sees.
144
+ verify_parses(content, 'content')
145
+ size = content.bytesize
146
+
147
+ stats = Pipeline::CompressionStats.new(
148
+ original_size: source.original_size,
149
+ minified_size: size,
150
+ compression_ratio: size.to_f / source.original_size,
151
+ file_count: file_count
152
+ )
153
+
154
+ @result = Pipeline::MinifiedResult.new(
155
+ content: content,
156
+ aliases: rename_result.aliases,
157
+ preamble: rename_result.preamble,
158
+ stats: stats
159
+ )
160
+ end
161
+
162
+ def build_output(code, stdlib_requires, preamble = '')
163
+ parts = stdlib_requires.map { |lib| "require \"#{lib}\"" }
164
+ parts << preamble unless preamble.empty?
165
+ parts << code
166
+ parts.join(';')
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ # Generates sequential short names for variable mangling
5
+ # a, b, ..., z, a0, a1, ..., z9, a0a, a0b, ..., z9z, ...
6
+ class NameGenerator
7
+ LETTERS = ('a'..'z').to_a.freeze
8
+ DIGITS = ('0'..'9').to_a.freeze
9
+
10
+ # A one- or two-character name is as short as this generator's own
11
+ # output; the rename families keep such names untouched, so a second
12
+ # minification pass reassigns nothing and the output is a fixed point.
13
+ KEPT_NAME_MAX = 2
14
+
15
+ # additional_excluded holds FINAL names — prefix and case included —
16
+ # which is what every caller has in hand (kept originals, reserved
17
+ # names). Comparing before prefixing would make seeding useless to the
18
+ # prefixed families and force each to re-check by hand.
19
+ def initialize(additional_excluded = [], prefix: "", upcase: false)
20
+ @index = 0
21
+ @prefix = prefix
22
+ @upcase = upcase
23
+ @excluded = Set.new(additional_excluded)
24
+ end
25
+
26
+ def next_name
27
+ loop do
28
+ name = index_to_name(@index)
29
+ @index += 1
30
+ name = name.upcase if @upcase
31
+ final = "#{@prefix}#{name}"
32
+ next if @excluded.include?(final)
33
+ return final
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Convert index to name using letter-digit alternating scheme:
40
+ # a-z, a0-z9, a0a-z9z, a0a0-z9z9, ...
41
+ def index_to_name(index)
42
+ return LETTERS[index] if index < 26
43
+
44
+ index -= 26
45
+ length = 2
46
+ capacity = 26 * 10
47
+
48
+ while index >= capacity
49
+ index -= capacity
50
+ length += 1
51
+ capacity *= (length.odd? ? 26 : 10)
52
+ end
53
+
54
+ result = ""
55
+ (length - 1).downto(0) do |pos|
56
+ if pos.even?
57
+ result = LETTERS[index % 26] + result
58
+ index /= 26
59
+ else
60
+ result = DIGITS[index % 10] + result
61
+ index /= 10
62
+ end
63
+ end
64
+ result
65
+ end
66
+
67
+ end
68
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Ryac
6
+ module Pipeline
7
+ # Stage 3: Analysis
8
+ # Parses source with TypeProf, builds scope mappings,
9
+ # collects constants and their references, and freezes mappings.
10
+ class Analyzer
11
+ include AnalysisPhases
12
+
13
+ def self.prism_only(source)
14
+ prism_only_from_string(source.content, source: source)
15
+ end
16
+
17
+ def self.prism_only_from_string(content, source: nil)
18
+ prism_result = Prism.parse(content)
19
+
20
+ analyzer = new
21
+ syntax_data = analyzer.syntax_data_for(prism_result.value)
22
+
23
+ source ||= ConcatenatedSource.new(content: content)
24
+
25
+ AnalysisResult.new(
26
+ prism_ast: prism_result.value,
27
+ scope_mappings: {},
28
+ constant_mapping: nil,
29
+ rename_map: {},
30
+ method_alias_map: {},
31
+ method_transform_map: {},
32
+ source: source,
33
+ attr_rename_map: {},
34
+ block_param_names_map: {},
35
+ syntax_data: syntax_data,
36
+ const_resolution_map: {},
37
+ const_full_path_map: {},
38
+ const_write_cpath_map: {},
39
+ class_cpath_map: {},
40
+ superclass_resolution_map: {},
41
+ meta_node_map: {}
42
+ )
43
+ end
44
+
45
+ def call(source)
46
+ prism_result, @oracle = without_stdout_pollution { setup_typeprof(source) }
47
+ @prism_root = prism_result.value
48
+ @boot_constant_roots = BootConstants.for(source.stdlib_requires)
49
+ @syntax_data = collect_syntax_data(@prism_root)
50
+
51
+ analyze_keywords_and_scopes
52
+ analyze_methods_phase
53
+ method_alias_map, method_transform_map = resolve_method_aliases_and_transforms(@prism_root)
54
+ analyze_variables_phase
55
+
56
+ rename_map = @method_rename_mapping.node_mapping.dup
57
+ attr_ivar_entries = {} #: Hash[location_key, String]
58
+ attr_rename_map = coordinate_attr_renames(@prism_root, rename_map, attr_ivar_entries)
59
+
60
+ analyze_constants_phase
61
+ local_rename_entries = precompute_rename_entries
62
+ precompute_constant_resolution
63
+ precompute_meta_nodes
64
+
65
+ build_analysis_result(
66
+ prism_result, source, rename_map, method_alias_map, method_transform_map,
67
+ attr_rename_map, attr_ivar_entries, local_rename_entries
68
+ )
69
+ end
70
+
71
+ def syntax_data_for(prism_ast)
72
+ collect_syntax_data(prism_ast)
73
+ end
74
+
75
+ private
76
+
77
+ # TypeProf prints diagnostics for node types it does not recognize
78
+ # straight to stdout, which is also where the minified program is
79
+ # written. Divert anything it emits to stderr so it can never end up
80
+ # inside the output.
81
+ def without_stdout_pollution
82
+ original = $stdout
83
+ capture = StringIO.new
84
+ $stdout = capture
85
+ begin
86
+ yield
87
+ ensure
88
+ $stdout = original
89
+ noise = capture.string
90
+ $stderr.print(noise) unless noise.empty?
91
+ end
92
+ end
93
+
94
+ def setup_typeprof(source)
95
+ prism_result = Prism.parse(source.content)
96
+ # Every input file was parse-checked at collection, so a failure
97
+ # here means an upstream stage broke the text.
98
+ unless prism_result.errors.none?
99
+ raise InvalidOutputError.new('pre-rename source', prism_result.errors)
100
+ end
101
+
102
+ [prism_result, TypeOracle.boot(source.content, source.rbs_files)]
103
+ end
104
+
105
+ def analyze_keywords_and_scopes
106
+ @local_scopes = LocalScopes.new(@prism_root)
107
+
108
+ @keyword_rename_mapping = KeywordRenameMapping.new
109
+ collect_keyword_info(@prism_root)
110
+ @keyword_rename_mapping.assign_short_names
111
+
112
+ # Inline rather than through locals: a local passed as the same-named
113
+ # keyword gets hint-aligned and collapses to shorthand, and the
114
+ # re-minified form of that shorthand no longer carries the hint — the
115
+ # names then oscillate between passes instead of reaching a fixed
116
+ # point under self-hosting.
117
+ @local_scopes.allocate(
118
+ kw_def_map: @keyword_rename_mapping.def_node_mapping(@keyword_def_node_registry || {}),
119
+ var_hints: @keyword_rename_mapping.build_variable_hints { |val_node| @local_scopes.scope_id_of(val_node) }
120
+ )
121
+ @local_scopes.resolve
122
+ @scope_mappings = @local_scopes.scope_mappings
123
+ end
124
+
125
+ def analyze_methods_phase
126
+ @method_rename_mapping = MethodRenameMapping.new
127
+ collect_method_definitions(@prism_root)
128
+ resolve_method_calls
129
+ collect_alias_undef_methods(@prism_root)
130
+ scan_dynamic_method_references(@prism_root)
131
+ collect_visibility_modifier_methods(@prism_root)
132
+ collect_attr_write_exclusions(@prism_root)
133
+ collect_shorthand_pun_methods(@prism_root)
134
+ @method_rename_mapping.assign_short_names(@scope_mappings, @oracle)
135
+ end
136
+
137
+ def analyze_variables_phase
138
+ @ivar_rename_mapping = SiteBucketMapping.new(prefix: '@')
139
+ attr_backed = collect_attr_backed_ivars(@prism_root)
140
+ collect_ivar_definitions(@prism_root, attr_backed)
141
+ scan_dynamic_ivar_access(@prism_root)
142
+ merge_inherited_ivars
143
+ reserve_attr_ivar_names(@prism_root)
144
+ @ivar_rename_mapping.assign_short_names
145
+
146
+ @cvar_rename_mapping = SiteBucketMapping.new(prefix: '@@')
147
+ collect_cvar_definitions(@prism_root)
148
+ scan_dynamic_cvar_access(@prism_root)
149
+ merge_inherited_cvars
150
+ @cvar_rename_mapping.assign_short_names
151
+
152
+ @gvar_rename_mapping = GvarRenameMapping.new
153
+ collect_gvar_definitions(@prism_root)
154
+ scan_alias_globals(@prism_root)
155
+ @gvar_rename_mapping.assign_short_names
156
+ end
157
+
158
+ def analyze_constants_phase
159
+ @constant_mapping = ConstantRenameMapping.new(boot_roots: @boot_constant_roots)
160
+ collect_constants(@prism_root)
161
+ exclude_private_constants(@prism_root)
162
+ count_constant_references(@prism_root)
163
+ augment_constant_counts_via_oracle
164
+ collect_external_references(@prism_root)
165
+ end
166
+
167
+ def precompute_rename_entries
168
+ @local_scopes.def_param_names.each do |key, names|
169
+ (@syntax_data[key] ||= {})[:param_names] = names
170
+ end
171
+ @local_scopes.for_index_names.each do |key, name|
172
+ data = @syntax_data[key]
173
+ data[:for_index_mangled] = name if data&.[](:index_name)
174
+ end
175
+ @block_param_names_map = @local_scopes.block_param_names
176
+ @local_scopes.rename_entries
177
+ end
178
+
179
+ def build_analysis_result(prism_result, source, rename_map, method_alias_map,
180
+ method_transform_map, attr_rename_map, attr_ivar_entries,
181
+ local_rename_entries)
182
+ AnalysisResult.new(
183
+ prism_ast: prism_result.value,
184
+ scope_mappings: @scope_mappings,
185
+ constant_mapping: @constant_mapping,
186
+ rename_map: rename_map,
187
+ method_alias_map: method_alias_map,
188
+ method_transform_map: method_transform_map,
189
+ source: source,
190
+ attr_rename_map: attr_rename_map,
191
+ block_param_names_map: @block_param_names_map,
192
+ syntax_data: @syntax_data,
193
+ const_resolution_map: @const_resolution_map,
194
+ const_full_path_map: @const_full_path_map,
195
+ const_write_cpath_map: @const_write_cpath_map,
196
+ class_cpath_map: @class_cpath_map,
197
+ superclass_resolution_map: @superclass_resolution_map,
198
+ meta_node_map: @meta_node_map,
199
+ local_rename_entries: local_rename_entries,
200
+ keyword_rename_entries: @keyword_rename_mapping.node_mapping,
201
+ ivar_rename_entries: @ivar_rename_mapping.node_mapping,
202
+ attr_ivar_entries: attr_ivar_entries,
203
+ cvar_rename_entries: @cvar_rename_mapping.node_mapping,
204
+ gvar_rename_entries: @gvar_rename_mapping.node_mapping
205
+ )
206
+ end
207
+
208
+ META_CALL_METHODS = %i[include attr_reader attr_accessor].freeze
209
+
210
+ # Calls the renamer rewrites structurally instead of as plain calls.
211
+ def precompute_meta_nodes
212
+ @meta_node_map = {}
213
+ Nesting.each_meta_call(@prism_root, META_CALL_METHODS) do |call_node, _cpath, _singleton|
214
+ case call_node.name
215
+ when :attr_reader, :attr_accessor
216
+ next unless call_node.arguments
217
+
218
+ @meta_node_map[AstUtils.location_key(call_node)] =
219
+ { type: call_node.name, args: AstUtils.symbol_arguments(call_node) }
220
+ when :include
221
+ @meta_node_map[AstUtils.location_key(call_node)] = { type: :include }
222
+ end
223
+ end
224
+ end
225
+
226
+ def collect_syntax_data(prism_ast)
227
+ data = {} #: syntax_data_map
228
+ traverse_prism(prism_ast, data)
229
+ data
230
+ end
231
+
232
+ def traverse_prism(node, data)
233
+ key = AstUtils.line_col_key(node)
234
+ case node
235
+ when Prism::DefNode
236
+ data[key] = { self_receiver: node.receiver.is_a?(Prism::SelfNode) }
237
+ when Prism::ArrayNode
238
+ data[key] = { opening: node.opening }
239
+ when Prism::RangeNode
240
+ data[key] = { exclude_end: node.exclude_end? }
241
+ when Prism::RegularExpressionNode
242
+ data[key] = { content: node.content, flags: node.closing.delete("/") }
243
+ when Prism::InterpolatedRegularExpressionNode
244
+ data[key] = { flags: node.closing.delete("/") }
245
+ when Prism::ForNode
246
+ idx = node.index
247
+ if idx.is_a?(Prism::LocalVariableTargetNode)
248
+ data[key] = { index_name: idx.name }
249
+ else
250
+ data[key] = { index_slice: idx.slice }
251
+ end
252
+ when Prism::DefinedNode
253
+ data[key] = { value_slice: node.value.slice }
254
+ when Prism::RationalNode, Prism::ImaginaryNode, Prism::LambdaNode,
255
+ Prism::MatchLastLineNode, Prism::InterpolatedMatchLastLineNode,
256
+ Prism::FlipFlopNode, Prism::AliasGlobalVariableNode,
257
+ Prism::BackReferenceReadNode
258
+ data[key] = { slice: node.slice }
259
+ end
260
+ node.compact_child_nodes.each { |child| traverse_prism(child, data) }
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ # `attr_reader :a` → `attr :a` (the same method, four bytes shorter) and
6
+ # single-name attr_accessor → the two-argument attr form, for declarations
7
+ # type analysis recognized as meta calls. This stage owns the attr rewrite
8
+ # at every level: with rename_attrs the declared symbols are additionally
9
+ # mapped through the analysis' attr rename map, keeping declarations in
10
+ # sync with call sites the method renamer renames.
11
+ class AttrDeclShorten < Stage
12
+ include RenamePatcher
13
+
14
+ def needs_analysis? = true
15
+
16
+ def initialize(rename_attrs: false)
17
+ @rename_attrs = rename_attrs
18
+ end
19
+
20
+ def collect(ctx, patches)
21
+ analysis = analysis(ctx)
22
+ attr_rename_map = analysis.attr_rename_map if @rename_attrs
23
+ walk_prism(ctx.ast) do |subnode|
24
+ next unless subnode.is_a?(Prism::CallNode)
25
+
26
+ key = prism_location_key(subnode)
27
+ meta = analysis.meta_node_map[key]
28
+ next unless meta
29
+
30
+ args = meta[:args] || []
31
+ next unless args.any?
32
+
33
+ names = args.map { |sym| attr_rename_map&.dig(key, sym) || sym.to_s }
34
+ replacement = render_attr_declaration(meta[:type], names)
35
+ next unless replacement && replacement != subnode.slice
36
+
37
+ loc = subnode.location
38
+ patches << { start: loc.start_offset, end: loc.end_offset, replacement: replacement }
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # The compact spelling of an attr declaration: attr_reader collapses to
45
+ # attr, a single-name attr_accessor takes the two-argument attr form, a
46
+ # multi-name accessor has no shorter spelling.
47
+ def render_attr_declaration(type, names)
48
+ case type
49
+ when :attr_reader
50
+ "attr #{names.map { |n| ":#{n}" }.join(',')}"
51
+ when :attr_accessor
52
+ if names.size == 1
53
+ "attr :#{names[0]},!!1"
54
+ else
55
+ "attr_accessor #{names.map { |n| ":#{n}" }.join(',')}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ class BooleanShorten < Stage
6
+ def collect(ctx, patches)
7
+ walk(ctx.ast, patches)
8
+ end
9
+
10
+ private
11
+
12
+ def walk(node, patches, inside_block_params: false)
13
+ case node
14
+ when Prism::BlockParametersNode
15
+ node.compact_child_nodes.each { |child| walk(child, patches, inside_block_params: true) }
16
+ return
17
+ when Prism::TrueNode
18
+ patches << mk(node, '!!1') unless inside_block_params
19
+ when Prism::FalseNode
20
+ patches << mk(node, '!1') unless inside_block_params
21
+ end
22
+ node.compact_child_nodes.each { |child| walk(child, patches, inside_block_params: inside_block_params) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ryac
4
+ module Pipeline
5
+ class CharShorten < Stage
6
+ def collect(ctx, patches)
7
+ walk(ctx.ast, patches)
8
+ end
9
+
10
+ private
11
+
12
+ def walk(node, patches)
13
+ if node.is_a?(Prism::StringNode) && node.opening_loc && node.content.match?(/\A[a-zA-Z0-9_]\z/)
14
+ patches << mk(node, "?#{node.content}")
15
+ end
16
+ node.compact_child_nodes.each { |child| walk(child, patches) }
17
+ end
18
+ end
19
+ end
20
+ end