uprb 0.1.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.
data/lib/uprb/cli.rb ADDED
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require_relative "../uprb"
6
+
7
+ module Uprb
8
+ class CLI
9
+ USAGE = <<~USAGE.chomp
10
+ Usage:
11
+ uprb pack <src.rb> <dest>
12
+ uprb gem install <gem> [--path DIR]
13
+ uprb gem pack <gem> [--path DIR]
14
+ USAGE
15
+
16
+ def self.start(argv = ARGV)
17
+ new(argv).run
18
+ end
19
+
20
+ def initialize(argv)
21
+ @argv = argv.dup
22
+ end
23
+
24
+ def run
25
+ command = @argv.shift
26
+
27
+ case command
28
+ when "pack"
29
+ pack_command
30
+ when "gem"
31
+ gem_command
32
+ when "--version", "-v"
33
+ $stdout.puts(Uprb::VERSION)
34
+ when "--help", "-h", nil
35
+ $stdout.puts(USAGE)
36
+ else
37
+ $stderr.puts(USAGE)
38
+ end
39
+ rescue Uprb::Error => e
40
+ $stderr.puts("uprb: #{e.message}")
41
+ exit 1
42
+ rescue StandardError => e
43
+ $stderr.puts("uprb: #{e.class}: #{e.message}")
44
+ exit 1
45
+ end
46
+
47
+ private
48
+
49
+ def pack_command
50
+ options, args = parse_pack_options(@argv)
51
+ src = args.shift or raise Uprb::Error, "missing <src.rb>"
52
+ dest = args.shift or raise Uprb::Error, "missing <dist>"
53
+
54
+ src_path = File.expand_path(src)
55
+ dest_path = File.expand_path(dest)
56
+
57
+ raise Uprb::Error, "source not found: #{src}" unless File.file?(src_path)
58
+
59
+ FileUtils.mkdir_p(File.dirname(dest_path))
60
+ return unless confirm_overwrite(dest_path, options)
61
+
62
+ Uprb::RequireReplacer.pack(src_path, dest_path:, requires: options[:requires], dynamic: options[:dynamic], script_argv: options[:script_argv], skip_disable_gems: options[:skip_disable_gems], skip_ruby_path_replace: options[:skip_ruby_path_replace])
63
+
64
+ $stdout.puts("Packed #{dest_path}")
65
+ end
66
+
67
+ def gem_command
68
+ subcommand = @argv.shift
69
+
70
+ case subcommand
71
+ when "install"
72
+ options, args = parse_pack_options(@argv)
73
+ gem_name = args.shift or raise Uprb::Error, "missing <gem>"
74
+ install_gem(gem_name)
75
+ pack_gem_executables(gem_name, options)
76
+ when "pack"
77
+ options, args = parse_pack_options(@argv)
78
+ gem_name = args.shift or raise Uprb::Error, "missing <gem>"
79
+ pack_gem_executables(gem_name, options)
80
+ else
81
+ $stdout.puts(USAGE)
82
+ end
83
+ end
84
+
85
+ def parse_pack_options(argv)
86
+ if (separator_index = argv.index("--"))
87
+ script_argv = argv[(separator_index + 1)..]
88
+ argv = argv[0...separator_index]
89
+ else
90
+ script_argv = []
91
+ end
92
+
93
+ options = {
94
+ path: nil,
95
+ force: false,
96
+ requires: [],
97
+ dynamic: false,
98
+ script_argv: script_argv,
99
+ skip_disable_gems: false,
100
+ skip_ruby_path_replace: false,
101
+ }
102
+ parser = OptionParser.new
103
+ parser.on("--path DIR") do |dir|
104
+ options[:path] = dir
105
+ end
106
+ parser.on("-f", "--force", "overwrite existing destination without prompting") do
107
+ options[:force] = true
108
+ end
109
+ parser.on("-r", "--require LIB", "pre-require LIB at pack time and at runtime (repeatable)") do |lib|
110
+ options[:requires] << lib
111
+ end
112
+ parser.on("--with-rubygems", "shortcut for --require rubygems") do
113
+ options[:requires] << "rubygems"
114
+ end
115
+ parser.on("--dynamic", "execute the entry script at pack time to observe runtime-only requires") do
116
+ options[:dynamic] = true
117
+ end
118
+ parser.on("--skip-disable-gems", "drop --disable-gems from the shebang (vendoring only; trades fast startup for normal Ruby startup)") do
119
+ options[:skip_disable_gems] = true
120
+ end
121
+ parser.on("--skip-ruby-path-replace", "keep the source file's shebang ruby invocation instead of rewriting it to an absolute RbConfig.ruby path (vendoring only; source must have a shebang)") do
122
+ options[:skip_ruby_path_replace] = true
123
+ end
124
+ args = parser.parse(argv)
125
+
126
+ [options, args]
127
+ rescue OptionParser::ParseError => e
128
+ raise Uprb::Error, e.message
129
+ end
130
+
131
+ def confirm_overwrite(dest_path, options)
132
+ return true if options[:force]
133
+ return true unless File.exist?(dest_path)
134
+
135
+ unless $stdin.tty?
136
+ raise Uprb::Error, "destination already exists: #{dest_path} (pass --force to overwrite)"
137
+ end
138
+
139
+ $stderr.print("uprb: #{dest_path} already exists. overwrite? [y/N]: ")
140
+ answer = $stdin.gets
141
+ return true if answer && answer.strip.match?(/\A(y|yes)\z/i)
142
+
143
+ $stdout.puts("skipped #{dest_path}")
144
+ false
145
+ end
146
+
147
+ def install_gem(gem_name)
148
+ command = [RbConfig.ruby, "-S", "gem", "install", gem_name]
149
+ system(*command) or raise Uprb::Error, "gem install failed: #{gem_name}"
150
+ end
151
+
152
+ def pack_gem_executables(gem_name, options)
153
+ spec = Gem::Specification.find_by_name(gem_name)
154
+ executables = spec.executables
155
+ raise Uprb::Error, "no executables for gem: #{gem_name}" if executables.empty?
156
+ bindir = spec.bindir
157
+
158
+ dest_dir = options[:path] ? File.expand_path(options[:path]) : Gem.bindir
159
+ FileUtils.mkdir_p(dest_dir)
160
+
161
+ executables.each do |exe|
162
+ source_path = File.join(spec.full_gem_path, bindir, exe)
163
+ raise Uprb::Error, "executable not found: #{source_path}" unless File.file?(source_path)
164
+
165
+ dest_path = File.join(dest_dir, exe)
166
+
167
+ next unless confirm_overwrite(dest_path, options)
168
+
169
+ Uprb::RequireReplacer.pack(source_path, dest_path:, requires: options[:requires], dynamic: options[:dynamic], script_argv: options[:script_argv], skip_disable_gems: options[:skip_disable_gems], skip_ruby_path_replace: options[:skip_ruby_path_replace])
170
+ $stdout.puts("Packed #{dest_path}")
171
+ end
172
+ rescue Gem::LoadError => e
173
+ raise Uprb::Error, e.message
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "tempfile"
6
+
7
+ module Uprb
8
+ module RequireReplacer
9
+ class << self
10
+ attr_reader :mapping
11
+
12
+ def pack(source_path, dest_path: nil, requires: [], dynamic: false, script_argv: [], skip_disable_gems: false, skip_ruby_path_replace: false)
13
+ source = File.read(source_path)
14
+ mapping = build_mapping(source_path, requires, dynamic, script_argv)
15
+ embedded, external = build_payload(mapping)
16
+ ruby_source = source_with_require_hook(source, requires)
17
+ main_iseq = RubyVM::InstructionSequence.compile(ruby_source, source_path, source_path)
18
+ payload = Marshal.dump({
19
+ embedded: embedded,
20
+ external: external,
21
+ main: main_iseq.to_binary
22
+ })
23
+
24
+ shebang = resolve_shebang(source, skip_ruby_path_replace: skip_ruby_path_replace, skip_disable_gems: skip_disable_gems)
25
+ body = <<~RUBY
26
+ DATA.binmode
27
+ data = Marshal.load(DATA)
28
+
29
+ EMBEDDED_ISEQ = data.fetch(:embedded)
30
+ REQUIRE_MAP = data.fetch(:external)
31
+
32
+ iseq = RubyVM::InstructionSequence.load_from_binary(data.fetch(:main))
33
+ iseq.eval
34
+ __END__
35
+ RUBY
36
+
37
+ if shebang
38
+ program = "#{shebang}\n#{body}#{payload}"
39
+ return program unless dest_path
40
+ File.write(dest_path, program)
41
+ FileUtils.chmod("+x", dest_path)
42
+ else
43
+ program = body + payload
44
+ return program unless dest_path
45
+ File.write(dest_path, program)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_shebang(source, skip_ruby_path_replace:, skip_disable_gems:)
52
+ first_line = source.lines.first&.chomp
53
+ return nil unless first_line&.start_with?("#!")
54
+
55
+ ruby_command = skip_ruby_path_replace ? first_line[2..] : RbConfig.ruby
56
+ skip_disable_gems ? "#!#{ruby_command}" : "#!#{ruby_command} --disable-gems"
57
+ end
58
+
59
+ # `--dynamic` alone would miss literal requires in branches the
60
+ # execution didn't take (rescued `LoadError` alternates, unused
61
+ # autoloads, feature-flag branches); the static walk fills those in.
62
+ def build_mapping(source_path, requires, dynamic, script_argv)
63
+ return Uprb::StaticRequireTracker.trace(source_path, requires: requires) unless dynamic
64
+
65
+ dynamic_map = execute_with_tracker(source_path, requires, script_argv)
66
+ static_map = Uprb::StaticRequireTracker::StaticWalker.new.walk(File.expand_path(source_path))
67
+ static_map.merge(dynamic_map)
68
+ end
69
+
70
+ def rewind_read_tempfile(file)
71
+ file.flush
72
+ file.rewind
73
+ file.read
74
+ end
75
+
76
+ def execute_with_tracker(path, requires = [], script_argv = [])
77
+ original_stdout, original_stderr = STDOUT.dup, STDERR.dup
78
+ original_argv = ARGV.dup
79
+ original_program_name = $PROGRAM_NAME
80
+ tmp_stdout = Tempfile.new("uprb-stdout")
81
+ tmp_stderr = Tempfile.new("uprb-stderr")
82
+ mapping = nil
83
+
84
+ begin
85
+ STDOUT.reopen(tmp_stdout)
86
+ STDERR.reopen(tmp_stderr)
87
+ ARGV.replace(script_argv)
88
+ $PROGRAM_NAME = path
89
+ Uprb::RequireTracker.start
90
+ requires.each {|lib| require lib }
91
+ load path
92
+ rescue SystemExit => e
93
+ rescue StandardError => e
94
+ stdout_content = rewind_read_tempfile(tmp_stdout)
95
+ stderr_content = rewind_read_tempfile(tmp_stderr)
96
+ message = ["execution failed: #{e.class}: #{e.message}"]
97
+ message << "stdout: #{stdout_content}" unless stdout_content.empty?
98
+ message << "stderr: #{stderr_content}" unless stderr_content.empty?
99
+ raise Uprb::Error, message.join("\n")
100
+ ensure
101
+ mapping = Uprb::RequireTracker.stop
102
+ STDOUT.reopen(original_stdout)
103
+ STDERR.reopen(original_stderr)
104
+ ARGV.replace(original_argv)
105
+ $PROGRAM_NAME = original_program_name
106
+ tmp_stdout.close!
107
+ tmp_stderr.close!
108
+ end
109
+
110
+ mapping
111
+ end
112
+
113
+ def source_with_require_hook(source, requires = [])
114
+ preload_lines = requires.map {|lib| "require #{lib.inspect}" }.join("\n")
115
+ pre_code = <<~RUBY
116
+ module FixedRequire
117
+ SUFFIXES = #{Uprb::SUFFIXES.inspect}.freeze
118
+
119
+ def require(name)
120
+ entry = EMBEDDED_ISEQ[name]
121
+ if entry
122
+ path, binary = entry
123
+ return false if $LOADED_FEATURES.include?(path) || $LOADED_FEATURES.include?(name)
124
+ $LOADED_FEATURES << path
125
+ $LOADED_FEATURES << name unless $LOADED_FEATURES.include?(name)
126
+ mark_runtime_resolved(name, path)
127
+ RubyVM::InstructionSequence.load_from_binary(binary).eval
128
+ true
129
+ elsif (path = REQUIRE_MAP[name])
130
+ result = super(path)
131
+ mark_runtime_resolved(name, path) if result
132
+ result
133
+ else
134
+ super(name)
135
+ end
136
+ end
137
+
138
+ # C extensions bypass this hook via rb_require(); pre-mark the path
139
+ # $LOAD_PATH would resolve `name` to so they see it as already loaded.
140
+ def mark_runtime_resolved(name, loaded_path)
141
+ resolved = $LOAD_PATH.lazy.flat_map {|d| SUFFIXES.map {|s| File.join(d, "\#{name}\#{s}") } }.find {|p| File.file?(p) }
142
+ return unless resolved && resolved != loaded_path && !$LOADED_FEATURES.include?(resolved)
143
+ $LOADED_FEATURES << resolved
144
+ end
145
+ end
146
+
147
+ Kernel.prepend(FixedRequire)
148
+ #{preload_lines}
149
+ RUBY
150
+ pre_code + source
151
+ end
152
+
153
+ def build_payload(mapping)
154
+ embedded = {}
155
+ external = {}
156
+
157
+ mapping.each do |name, path|
158
+ if path.is_a?(String) && File.file?(path) && File.extname(path) == ".rb"
159
+ source = File.read(path)
160
+ iseq = RubyVM::InstructionSequence.compile(source, path, path)
161
+ embedded[name] = [path, iseq.to_binary]
162
+ else
163
+ external[name] = path
164
+ end
165
+ end
166
+
167
+ [embedded, external]
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uprb
4
+ module RequireTracker
5
+ class << self
6
+ attr_reader :mapping
7
+
8
+ def start
9
+ install_require_hook
10
+ @mapping = {}
11
+ end
12
+
13
+ def stop
14
+ recorded = @mapping
15
+ @mapping = nil
16
+ return recorded
17
+ end
18
+
19
+ def record_require(name, features)
20
+ return if !@mapping || @mapping[name]
21
+
22
+ path = find_loaded_feature(name, features)
23
+ @mapping[name] = path if path
24
+ end
25
+
26
+ private
27
+
28
+ def install_require_hook
29
+ return if defined?(@require_hook_installed) && @require_hook_installed
30
+
31
+ if Kernel.private_method_defined?(:uprb_original_require)
32
+ @require_hook_installed = true
33
+ return
34
+ end
35
+
36
+ Kernel.module_eval do
37
+ alias_method :uprb_original_require, :require
38
+ alias_method :uprb_original_require_relative, :require_relative
39
+
40
+ def require(name)
41
+ before_size = $LOADED_FEATURES.size
42
+ required = uprb_original_require(name)
43
+ features = required ? $LOADED_FEATURES[before_size..] : $LOADED_FEATURES
44
+ Uprb::RequireTracker.record_require(name, features)
45
+ required
46
+ end
47
+
48
+ def require_relative(path)
49
+ caller_path = caller_locations(1, 1).first.path
50
+ absolute_path = File.expand_path(path, File.dirname(caller_path))
51
+ before_size = $LOADED_FEATURES.size
52
+ required = uprb_original_require(absolute_path)
53
+ features = required ? $LOADED_FEATURES[before_size..] : $LOADED_FEATURES
54
+ Uprb::RequireTracker.record_require(absolute_path, features)
55
+ required
56
+ end
57
+
58
+ private :require, :uprb_original_require
59
+ private :require_relative, :uprb_original_require_relative
60
+ end
61
+
62
+ @require_hook_installed = true
63
+ end
64
+
65
+ def find_loaded_feature(name, entries)
66
+ extname = File.extname(name)
67
+ if Uprb::SUFFIXES.include?(extname)
68
+ target_name = name.delete_suffix(extname)
69
+ suffixes = Uprb::DL_SUFFIXES.include?(extname) ? Uprb::DL_SUFFIXES : Uprb::SUFFIXES
70
+ else
71
+ target_name = name
72
+ suffixes = Uprb::SUFFIXES
73
+ end
74
+ suffixes.each do |suffix|
75
+ pattern = /(?:\A|#{File::SEPARATOR})#{target_name}#{suffix}\z/
76
+ # Ruby appends the parent after its nested children; search from the tail to hit it first.
77
+ entries.reverse_each do |f|
78
+ return f if pattern.match?(f)
79
+ end
80
+ end
81
+
82
+ nil
83
+ end
84
+
85
+ def absolute_path?(name)
86
+ name.start_with?(File::SEPARATOR) ||
87
+ (File::ALT_SEPARATOR && name.start_with?(File::ALT_SEPARATOR))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "set"
5
+
6
+ module Uprb
7
+ # Avoids running the entry's top-level code (CLI scripts often start
8
+ # `App.start(ARGV)` at load time) while still letting dependencies load
9
+ # normally so rubygems resolution, autoload registration, etc. happen.
10
+ module StaticRequireTracker
11
+ class << self
12
+ def trace(source_path, requires: [])
13
+ Tracer.new(requires: requires).trace(source_path)
14
+ end
15
+ end
16
+
17
+ class Tracer
18
+ def initialize(requires: [])
19
+ @requires = requires
20
+ end
21
+
22
+ def trace(source_path)
23
+ entry_path = File.expand_path(source_path)
24
+
25
+ dynamic = dynamic_phase(entry_path)
26
+ static = StaticWalker.new.walk(entry_path)
27
+
28
+ static.merge(dynamic)
29
+ end
30
+
31
+ private
32
+
33
+ def dynamic_phase(entry_path)
34
+ Uprb::RequireTracker.start
35
+ begin
36
+ @requires.each {|lib| require(lib) }
37
+ entry_dir = File.dirname(entry_path)
38
+ extract_requires(entry_path).each do |kind, name|
39
+ trigger(kind, name, entry_dir)
40
+ end
41
+ ensure
42
+ mapping = Uprb::RequireTracker.stop
43
+ end
44
+ mapping
45
+ end
46
+
47
+ def trigger(kind, name, entry_dir)
48
+ case kind
49
+ when :require
50
+ require(name)
51
+ when :require_relative
52
+ require(File.expand_path(name, entry_dir))
53
+ end
54
+ rescue LoadError
55
+ # The packed output falls through to runtime require for names we
56
+ # can't resolve here (e.g. local libs the entry would have added to
57
+ # $LOAD_PATH if it had been allowed to run).
58
+ end
59
+
60
+ def extract_requires(path)
61
+ source = File.read(path)
62
+ result = Prism.parse(source, filepath: path)
63
+ return [] if result.failure?
64
+
65
+ visitor = RequireVisitor.new
66
+ result.value.accept(visitor)
67
+ visitor.requires
68
+ end
69
+ end
70
+
71
+ class StaticWalker
72
+ def initialize
73
+ @mapping = {}
74
+ @visited = Set.new
75
+ end
76
+
77
+ def walk(entry_path)
78
+ parse_file(entry_path)
79
+ @mapping
80
+ end
81
+
82
+ private
83
+
84
+ def parse_file(path)
85
+ return unless @visited.add?(path)
86
+ return unless File.file?(path)
87
+
88
+ source = File.read(path)
89
+ result = Prism.parse(source, filepath: path)
90
+ return if result.failure?
91
+
92
+ visitor = RequireVisitor.new
93
+ result.value.accept(visitor)
94
+
95
+ visitor.requires.each do |kind, name|
96
+ case kind
97
+ when :require
98
+ record_require(name)
99
+ when :require_relative
100
+ absolute = File.expand_path(name, File.dirname(path))
101
+ resolved = resolve_file(absolute)
102
+ next unless resolved
103
+
104
+ @mapping[absolute] ||= resolved
105
+ parse_file(resolved) if resolved.end_with?(".rb")
106
+ end
107
+ end
108
+ end
109
+
110
+ def record_require(name)
111
+ resolved = resolve_in_load_path(name)
112
+ return unless resolved
113
+
114
+ @mapping[name] ||= resolved
115
+ parse_file(resolved) if resolved.end_with?(".rb")
116
+ end
117
+
118
+ # Skips rubygems' pre-activation steps (default-gem / unresolved-dep
119
+ # resolution) that run before the $LOAD_PATH search — so in environments
120
+ # with multi-version conflicts this may pick a different version than
121
+ # runtime would.
122
+ def resolve_in_load_path(name)
123
+ return resolve_file(name) if File.absolute_path?(name)
124
+
125
+ resolved = search_load_path(name)
126
+ return resolved if resolved
127
+
128
+ return nil unless defined?(Gem) && Gem.respond_to?(:try_activate)
129
+ return nil unless Gem.try_activate(name)
130
+
131
+ search_load_path(name)
132
+ end
133
+
134
+ def search_load_path(name)
135
+ $LOAD_PATH.each do |dir|
136
+ candidate = resolve_file(File.join(dir, name))
137
+ return candidate if candidate
138
+ end
139
+ nil
140
+ end
141
+
142
+ def resolve_file(path)
143
+ extname = File.extname(path)
144
+ if Uprb::SUFFIXES.include?(extname)
145
+ if Uprb::DL_SUFFIXES.include?(extname)
146
+ base = path.delete_suffix(extname)
147
+ Uprb::DL_SUFFIXES.each do |suffix|
148
+ candidate = "#{base}#{suffix}"
149
+ return candidate if File.file?(candidate)
150
+ end
151
+ nil
152
+ else
153
+ File.file?(path) ? path : nil
154
+ end
155
+ else
156
+ Uprb::SUFFIXES.each do |suffix|
157
+ candidate = "#{path}#{suffix}"
158
+ return candidate if File.file?(candidate)
159
+ end
160
+ nil
161
+ end
162
+ end
163
+ end
164
+
165
+ class RequireVisitor < Prism::Visitor
166
+ attr_reader :requires
167
+
168
+ def initialize
169
+ super
170
+ @requires = []
171
+ end
172
+
173
+ def visit_call_node(node)
174
+ case node.name
175
+ when :require, :require_relative
176
+ if node.receiver.nil?
177
+ name = extract_string_argument(node, index: 0)
178
+ @requires << [node.name, name] if name
179
+ end
180
+ when :autoload
181
+ name = extract_string_argument(node, index: 1)
182
+ @requires << [:require, name] if name
183
+ end
184
+ super
185
+ end
186
+
187
+ private
188
+
189
+ def extract_string_argument(node, index:)
190
+ args = node.arguments&.arguments
191
+ return nil unless args && args[index].is_a?(Prism::StringNode)
192
+
193
+ args[index].unescaped
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uprb
4
+ VERSION = "0.1.0"
5
+ end
data/lib/uprb.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Uprb
6
+ class Error < StandardError; end
7
+
8
+ DL_SUFFIXES = [".#{RbConfig::CONFIG['DLEXT']}", ".so", ".o"].uniq.freeze
9
+ SUFFIXES = ([".rb"] + DL_SUFFIXES).freeze
10
+ end
11
+
12
+ require_relative "uprb/version"
13
+ require_relative "uprb/require_tracker"
14
+ require_relative "uprb/static_require_tracker"
15
+ require_relative "uprb/require_replacer"
data/sig/uprb.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Uprb
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
data/tmp/.keep ADDED
File without changes