ocran 1.4.6 → 1.5.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 +4 -4
- data/CHANGELOG.txt +68 -0
- data/README.md +295 -10
- data/ext/extconf.rb +5 -1
- data/lib/ocran/aot_toolchain.rb +154 -0
- data/lib/ocran/command_output.rb +8 -1
- data/lib/ocran/compatibility_scan.rb +414 -0
- data/lib/ocran/cosmo_toolchain.rb +29 -7
- data/lib/ocran/dir_builder.rb +88 -38
- data/lib/ocran/direction.rb +190 -30
- data/lib/ocran/ed_resource.rb +113 -0
- data/lib/ocran/ed_resource_builder.rb +199 -0
- data/lib/ocran/file_path_set.rb +9 -5
- data/lib/ocran/gem_spec_queryable.rb +7 -3
- data/lib/ocran/host_config_helper.rb +11 -1
- data/lib/ocran/inno_setup_script_builder.rb +24 -14
- data/lib/ocran/launcher_batch_builder.rb +8 -11
- data/lib/ocran/npm_package.rb +106 -0
- data/lib/ocran/option.rb +425 -35
- data/lib/ocran/pe_imports.rb +96 -0
- data/lib/ocran/pe_resources.rb +226 -0
- data/lib/ocran/refine_pathname.rb +29 -32
- data/lib/ocran/roundhouse_builder.rb +316 -0
- data/lib/ocran/runner.rb +43 -11
- data/lib/ocran/runtime_sdk.rb +252 -0
- data/lib/ocran/spinel_builder.rb +183 -0
- data/lib/ocran/spinel_compatibility.rb +149 -0
- data/lib/ocran/static_runtime/boot.rb +443 -0
- data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
- data/lib/ocran/static_runtime_builder.rb +814 -0
- data/lib/ocran/stub_builder.rb +70 -25
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/wasm_builder.rb +570 -0
- data/lib/ocran/wasm_compatibility.rb +198 -0
- data/lib/ocran/windows_command_escaping.rb +21 -0
- data/lib/ocran/zip_payload_builder.rb +18 -9
- data/lib/ocran/zip_writer.rb +48 -9
- data/src/Makefile +18 -2
- data/src/Makefile.mswin +65 -0
- data/src/inst_dir.c +34 -6
- data/src/path_utils.c +144 -0
- data/src/script_info.c +5 -0
- data/src/stub.c +77 -19
- data/src/stub.rc +1 -1
- data/src/system_utils.c +276 -155
- data/src/system_utils.h +46 -3
- data/src/system_utils_posix.c +243 -148
- data/src/unpack.c +9 -0
- metadata +22 -4
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "command_output" unless defined?(Ocran::CommandOutput)
|
|
5
|
+
require_relative "aot_toolchain"
|
|
6
|
+
require_relative "spinel_compatibility"
|
|
7
|
+
|
|
8
|
+
module Ocran
|
|
9
|
+
# Builds --spinel output: the script compiled ahead of time by Spinel
|
|
10
|
+
# (https://github.com/matz/spinel) into a native executable, instead of
|
|
11
|
+
# being packaged together with a Ruby interpreter.
|
|
12
|
+
#
|
|
13
|
+
# Spinel compiles a subset of Ruby, so whether this works depends on the
|
|
14
|
+
# program. When it does, the result is one small binary that needs
|
|
15
|
+
# nothing but libc. When it does not - or when Spinel is not installed -
|
|
16
|
+
# the user gets a report of what in their code and in which gems stands
|
|
17
|
+
# in the way, from SpinelCompatibility.
|
|
18
|
+
class SpinelBuilder
|
|
19
|
+
include CommandOutput
|
|
20
|
+
|
|
21
|
+
# How much of the compiler's output is shown when it fails. The tail is
|
|
22
|
+
# what names the error; a C compiler failure can run to thousands of
|
|
23
|
+
# lines before it.
|
|
24
|
+
MAX_OUTPUT_LINES = 60
|
|
25
|
+
|
|
26
|
+
def initialize(option)
|
|
27
|
+
@option = option
|
|
28
|
+
@script = option.script
|
|
29
|
+
@output = option.output_executable
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build
|
|
33
|
+
spinel = AotToolchain.find(:spinel)
|
|
34
|
+
analysis = SpinelCompatibility.new(@script, packages_dir: packages_dir(spinel),
|
|
35
|
+
project_dirs: project_dirs).analyze
|
|
36
|
+
|
|
37
|
+
unless spinel
|
|
38
|
+
error "--spinel needs the Spinel compiler, which was not found."
|
|
39
|
+
STDERR.puts
|
|
40
|
+
STDERR.puts AotToolchain.install_instructions([:spinel])
|
|
41
|
+
STDERR.puts
|
|
42
|
+
print_report(analysis, "Meanwhile, a static check of whether #{display(@script)} is likely to compile with Spinel:")
|
|
43
|
+
raise "Spinel is not installed"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
verbose "Using Spinel at #{spinel} (#{AotToolchain.version(spinel) || "version unknown"})"
|
|
47
|
+
warn_about_uncompiled_files(analysis)
|
|
48
|
+
|
|
49
|
+
flags = analysis.include_dirs.flat_map { |dir| ["-I", dir] }
|
|
50
|
+
flags << "-g" if @option.enable_debug_mode?
|
|
51
|
+
flags.concat(@option.spinel_options)
|
|
52
|
+
command = [spinel, *flags, @script.to_s, "-o", @output.to_s]
|
|
53
|
+
|
|
54
|
+
say "Compiling #{display(@script)} with Spinel"
|
|
55
|
+
verbose command.join(" ")
|
|
56
|
+
output, status = Open3.capture2e(*command)
|
|
57
|
+
|
|
58
|
+
if status.success?
|
|
59
|
+
STDERR.print output if !output.strip.empty? && @option.warning?
|
|
60
|
+
report_success(analysis)
|
|
61
|
+
else
|
|
62
|
+
report_failure(spinel, flags, output, status, analysis)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def report_success(analysis)
|
|
69
|
+
unless analysis.findings.empty?
|
|
70
|
+
warning "Spinel compiled the program, but the static check flagged #{analysis.findings.size} " \
|
|
71
|
+
"construct(s) that may behave differently than under CRuby#{@option.verbose? ? ":" : " (--verbose lists them)"}"
|
|
72
|
+
verbose analysis.report
|
|
73
|
+
end
|
|
74
|
+
say "Finished building #{@output} (#{File.size(@output)} bytes)"
|
|
75
|
+
say "This is a native executable for this platform; it does not need Ruby to run."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def report_failure(spinel, flags, output, status, analysis)
|
|
79
|
+
lines = output.lines
|
|
80
|
+
STDERR.puts "Spinel could not compile #{display(@script)} (exit status #{status.exitstatus.inspect}):"
|
|
81
|
+
STDERR.puts " ... (#{lines.size - MAX_OUTPUT_LINES} earlier lines omitted)" if lines.size > MAX_OUTPUT_LINES
|
|
82
|
+
STDERR.puts lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
|
|
83
|
+
STDERR.puts
|
|
84
|
+
|
|
85
|
+
if analysis.clean?
|
|
86
|
+
STDERR.puts "The static check found nothing Spinel is known to reject in #{analysis.files.size} file(s),"
|
|
87
|
+
STDERR.puts "so the cause is likely a limit of Spinel's type inference rather than a missing feature."
|
|
88
|
+
else
|
|
89
|
+
print_report(analysis, "What may stand in the way:")
|
|
90
|
+
end
|
|
91
|
+
print_doctor(spinel, flags)
|
|
92
|
+
|
|
93
|
+
STDERR.puts
|
|
94
|
+
STDERR.puts "Things to try:"
|
|
95
|
+
STDERR.puts " - Spinel's supported subset: #{AotToolchain::SPINEL_URL}/blob/master/docs/limitations.md"
|
|
96
|
+
STDERR.puts " - --spinel-opt --defer-refusals builds anyway; refused methods then raise NotImplementedError when called"
|
|
97
|
+
STDERR.puts " - --spinel-opt --warn-widen shows where inference gave up on a type"
|
|
98
|
+
unless analysis.problem_gems.empty?
|
|
99
|
+
names = analysis.problem_gems.map(&:name).join(", ")
|
|
100
|
+
STDERR.puts " - replace or drop the gems Spinel cannot compile (#{names}), or leave out --spinel to package them with Ruby"
|
|
101
|
+
end
|
|
102
|
+
raise "Spinel could not compile #{display(@script)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# spinel-doctor, installed beside the compiler, adds what the compile
|
|
106
|
+
# error alone does not say: calls that degrade to nil, methods whose
|
|
107
|
+
# types inference gave up on, requires it treats as no-ops. Its build
|
|
108
|
+
# leg repeats the compiler output shown already, and its behavior leg
|
|
109
|
+
# runs the program under CRuby, so both are left out.
|
|
110
|
+
def print_doctor(spinel, flags)
|
|
111
|
+
doctor = doctor_path(spinel) or return
|
|
112
|
+
|
|
113
|
+
verbose "Running #{doctor}"
|
|
114
|
+
env = { "SPINEL" => spinel }
|
|
115
|
+
command = [doctor, "--skip", "build,behavior", @script.to_s]
|
|
116
|
+
command.concat(["--", *flags]) unless flags.empty?
|
|
117
|
+
output, = Open3.capture2e(env, *command)
|
|
118
|
+
return if output.strip.empty?
|
|
119
|
+
|
|
120
|
+
STDERR.puts
|
|
121
|
+
STDERR.puts "spinel-doctor's report:"
|
|
122
|
+
STDERR.puts output.lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
|
|
123
|
+
rescue SystemCallError => e
|
|
124
|
+
verbose "spinel-doctor could not be run: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def doctor_path(spinel)
|
|
128
|
+
candidates = [File.dirname(spinel)]
|
|
129
|
+
real = File.realpath(spinel)
|
|
130
|
+
candidates += [File.dirname(real), File.join(File.dirname(File.dirname(real)), "bin")]
|
|
131
|
+
candidates.uniq.map { |dir| File.join(dir, "spinel-doctor") }.find { |path| AotToolchain.executable?(path) }
|
|
132
|
+
rescue SystemCallError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def print_report(analysis, heading)
|
|
137
|
+
STDERR.puts heading
|
|
138
|
+
STDERR.puts
|
|
139
|
+
STDERR.puts analysis.report.gsub(/^(?=.)/, " ")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Files given on the command line besides the script, which Spinel
|
|
143
|
+
# compiles in only when the program requires them. Anything else -
|
|
144
|
+
# data files, assets - is not part of the binary.
|
|
145
|
+
def warn_about_uncompiled_files(analysis)
|
|
146
|
+
compiled = analysis.files.map(&:to_s)
|
|
147
|
+
extra = @option.source_files.drop(1).reject { |f| compiled.include?(f.to_s) }
|
|
148
|
+
return if extra.empty?
|
|
149
|
+
|
|
150
|
+
shown = extra.first(5).map { |f| display(f) }.join(", ")
|
|
151
|
+
shown += ", ..." if extra.size > 5
|
|
152
|
+
warning "Spinel compiles code only; #{extra.size} file(s) given on the command line are not " \
|
|
153
|
+
"part of the executable and have to be shipped beside it: #{shown}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Directories whose files are the program's own rather than a gem's.
|
|
157
|
+
def project_dirs
|
|
158
|
+
[@script.dirname, *@option.source_files.map(&:dirname)].uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Spinel's packages/ directory, which lists the standard library it
|
|
162
|
+
# provides: beside the compiler when installed, at the root of a source
|
|
163
|
+
# checkout otherwise (where `spinel` links to build/spinel).
|
|
164
|
+
def packages_dir(spinel)
|
|
165
|
+
return nil unless spinel
|
|
166
|
+
|
|
167
|
+
real = File.realpath(spinel)
|
|
168
|
+
[File.dirname(real), File.dirname(File.dirname(real))]
|
|
169
|
+
.map { |dir| File.join(dir, "packages") }
|
|
170
|
+
.find { |dir| File.directory?(dir) }
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Relative to the working directory when inside it, else absolute.
|
|
176
|
+
def display(path)
|
|
177
|
+
rel = Pathname(path).relative_path_from(Pathname.pwd).to_s
|
|
178
|
+
rel.start_with?("..") ? path.to_s : rel
|
|
179
|
+
rescue ArgumentError
|
|
180
|
+
path.to_s
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "compatibility_scan"
|
|
3
|
+
|
|
4
|
+
module Ocran
|
|
5
|
+
# Whether a script is likely to compile with Spinel, the ahead-of-time
|
|
6
|
+
# compiler behind --spinel.
|
|
7
|
+
#
|
|
8
|
+
# Spinel compiles a subset of Ruby: no eval of strings, no method_missing,
|
|
9
|
+
# no runtime class graph changes, and only the standard library it ships
|
|
10
|
+
# itself (https://github.com/matz/spinel/blob/master/docs/limitations.md).
|
|
11
|
+
#
|
|
12
|
+
# The walk also yields the -I roots that let Spinel find the pure-Ruby
|
|
13
|
+
# gems and the project's own lib/ directory, since Spinel resolves
|
|
14
|
+
# `require` at compile time against the roots it is given rather than
|
|
15
|
+
# against the gems installed for CRuby.
|
|
16
|
+
class SpinelCompatibility < CompatibilityScan
|
|
17
|
+
# Features Spinel provides when its own packages/ directory cannot be
|
|
18
|
+
# found to read the list from (it is installed beside the compiler).
|
|
19
|
+
# Mirrors packages/ plus the requires the compiler answers natively
|
|
20
|
+
# (src/spinel_parse.c: sp_lib_is_native, sp_require_tolerated,
|
|
21
|
+
# sp_require_preloaded) as of Spinel 2026.09.
|
|
22
|
+
FALLBACK_FEATURES = %w[
|
|
23
|
+
base64 benchmark bigdecimal cgi cgi/escape cgi/util csv digest digest/md5
|
|
24
|
+
digest/sha1 digest/sha2 erb ffi fiddle fiddle/import fileutils forwardable
|
|
25
|
+
io/buffer json logger net/http open3 openssl optparse pathname
|
|
26
|
+
securerandom set stringio strscan tempfile tmpdir uri zlib
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
NATIVE_FEATURES = %w[
|
|
30
|
+
io/console monitor time socket ostruct
|
|
31
|
+
thread enumerator fiber rational complex
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# packages_dir: Spinel's packages/ directory, if known, to read the
|
|
35
|
+
# features it provides from
|
|
36
|
+
def initialize(script, packages_dir: nil, project_dirs: nil)
|
|
37
|
+
super(script, project_dirs: project_dirs)
|
|
38
|
+
@features = spinel_features(packages_dir)
|
|
39
|
+
@allowed = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def target = "Spinel"
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def provided_feature?(feature) = @features.include?(feature)
|
|
47
|
+
|
|
48
|
+
def gem_problem(gem)
|
|
49
|
+
if gem.native
|
|
50
|
+
[:error, "has a C extension, which Spinel cannot compile"]
|
|
51
|
+
elsif gem.stdlib
|
|
52
|
+
# Spinel has its own versions of the parts of the standard library
|
|
53
|
+
# it supports (matched by name before a gem is ever looked up), so
|
|
54
|
+
# any other default gem is standard library it does not have.
|
|
55
|
+
[:error, "Ruby standard library that Spinel does not provide"]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def gem_status(gem)
|
|
60
|
+
return super if gem_problem(gem) || @findings.any? { |f| f.severity == :error && f.origin == gem.name }
|
|
61
|
+
|
|
62
|
+
"pure Ruby, nothing unsupported found (compiled from #{gem.spec.full_gem_path})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def dynamic_require_message(name)
|
|
66
|
+
"#{name} with a computed name: Spinel resolves requires at compile time, so the name must be a string literal"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def spinel_features(packages_dir)
|
|
70
|
+
features = NATIVE_FEATURES.dup
|
|
71
|
+
if packages_dir && File.directory?(packages_dir)
|
|
72
|
+
Dir.glob(File.join(packages_dir, "*")).each do |pkg|
|
|
73
|
+
Dir.glob("**/*.rb", base: pkg).each do |rel|
|
|
74
|
+
next if rel.start_with?("test/", "build/") || rel.include?("/test/")
|
|
75
|
+
|
|
76
|
+
features << rel.delete_suffix(".rb")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
else
|
|
80
|
+
features.concat(FALLBACK_FEATURES)
|
|
81
|
+
end
|
|
82
|
+
features.uniq
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_def(node)
|
|
86
|
+
if node.name == :method_missing
|
|
87
|
+
flag(:warning, node, "method_missing is never dispatched by Spinel; calls to undefined methods fail to compile")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_global(node)
|
|
92
|
+
if %i[$LOAD_PATH $:].include?(node.name)
|
|
93
|
+
flag(:warning, node, "#{node.name} has no effect: Spinel resolves every require at compile time")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check_constant(node)
|
|
98
|
+
flag(:error, node, "TracePoint is not supported (it needs an interpreter loop)") if node.name == :TracePoint
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_call(node, name, args, receiver, kernel)
|
|
102
|
+
return if @allowed[node.object_id]
|
|
103
|
+
|
|
104
|
+
case name
|
|
105
|
+
when :load
|
|
106
|
+
flag(:error, node, "load: a compiled program cannot load Ruby files at run time") if kernel && !args.empty?
|
|
107
|
+
when :eval
|
|
108
|
+
flag(:error, node, "eval of a string is not supported (no parser at run time)") if kernel
|
|
109
|
+
when :instance_eval, :class_eval, :module_eval, :class_exec
|
|
110
|
+
if node.block.nil? && args.any? && !args.first.is_a?(Prism::BlockArgumentNode)
|
|
111
|
+
flag(:error, node, "#{name} with a string is not supported (the block form works)")
|
|
112
|
+
end
|
|
113
|
+
when :define_method
|
|
114
|
+
unless literal?(args.first)
|
|
115
|
+
flag(:warning, node, "define_method with a computed name works only where Spinel can enumerate the names at compile time")
|
|
116
|
+
end
|
|
117
|
+
when :instance_variable_get, :instance_variable_set
|
|
118
|
+
unless literal?(args.first)
|
|
119
|
+
flag(:error, node, "#{name} with a computed name is not supported (only literal names like :@x)")
|
|
120
|
+
end
|
|
121
|
+
when :send, :public_send, :__send__
|
|
122
|
+
if args.first && !literal?(args.first)
|
|
123
|
+
flag(:warning, node, "#{name} with a computed method name only dispatches to methods named literally somewhere in the program")
|
|
124
|
+
end
|
|
125
|
+
when :binding
|
|
126
|
+
if kernel && args.empty?
|
|
127
|
+
flag(:error, node, "binding as an object is not supported (only binding.local_variable_get(:literal))")
|
|
128
|
+
end
|
|
129
|
+
when :local_variable_get
|
|
130
|
+
# binding.local_variable_get(:x) with a literal name is supported.
|
|
131
|
+
if receiver.is_a?(Prism::CallNode) && receiver.name == :binding && literal?(args.first)
|
|
132
|
+
@allowed[receiver.object_id] = true
|
|
133
|
+
end
|
|
134
|
+
when :each_object, :count_objects
|
|
135
|
+
flag(:error, node, "ObjectSpace.#{name} is not supported") if constant?(receiver, :ObjectSpace)
|
|
136
|
+
when :set_trace_func
|
|
137
|
+
flag(:error, node, "set_trace_func is not supported") if kernel
|
|
138
|
+
when :callcc
|
|
139
|
+
flag(:error, node, "callcc / Continuation is not supported") if kernel
|
|
140
|
+
when :refine, :using
|
|
141
|
+
if receiver.nil? && args.size == 1 && (node.block || name == :using)
|
|
142
|
+
flag(:error, node, "refinements (refine / using) are not supported")
|
|
143
|
+
end
|
|
144
|
+
when :new
|
|
145
|
+
flag(:error, node, "Class.new builds a class at run time, which is not supported") if constant?(receiver, :Class)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|