ocran 1.4.6 → 1.5.1
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 +82 -0
- data/README.md +519 -15
- data/ext/extconf.rb +5 -1
- data/lib/ocran/aot_toolchain.rb +154 -0
- data/lib/ocran/build_helper.rb +7 -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 +292 -44
- data/lib/ocran/ed_resource.rb +113 -0
- data/lib/ocran/ed_resource_builder.rb +199 -0
- data/lib/ocran/env_hook.rb +98 -0
- data/lib/ocran/file_path_set.rb +9 -5
- data/lib/ocran/gem_spec_queryable.rb +13 -5
- 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 +465 -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 +52 -11
- data/lib/ocran/runtime_sdk.rb +259 -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 +25 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "json"
|
|
8
|
+
require_relative "pe_imports"
|
|
9
|
+
|
|
10
|
+
module Ocran
|
|
11
|
+
# The SDK part of a static runtime (<runtime>/sdk, written by
|
|
12
|
+
# runtime/make-sdk.rb): what is needed to link the runtime again with more
|
|
13
|
+
# C extensions statically linked in - its link inputs and link command
|
|
14
|
+
# (link.args), Ruby's headers, the runtime's rbconfig.rb with the
|
|
15
|
+
# <arch>-fake.rb Ruby's cross builds use to describe the target, and the
|
|
16
|
+
# gem extensions already linked in (exts/*.line).
|
|
17
|
+
#
|
|
18
|
+
# Linking needs a compiler for the runtime's target on the build host. For
|
|
19
|
+
# a MinGW runtime (toolchain "gcc" in sdk.json): the one link.args names
|
|
20
|
+
# (e.g. x86_64-w64-mingw32ucrt-gcc on Linux), or OCRAN_RUNTIME_CC (e.g. gcc
|
|
21
|
+
# from RubyInstaller's MSYS2 on Windows). For an mswin runtime ("msvc"):
|
|
22
|
+
# cl and nmake of Visual Studio (Build Tools), from a developer prompt.
|
|
23
|
+
class RuntimeSdk
|
|
24
|
+
BUILD_STATIC_EXT = File.expand_path("static_runtime/build_static_ext.rb", __dir__)
|
|
25
|
+
|
|
26
|
+
# One statically built extension: the feature it provides ("bcrypt_ext"),
|
|
27
|
+
# its Init function, the static library and the libraries it links with.
|
|
28
|
+
Extension = Struct.new(:feature, :init, :archive, :libs) do
|
|
29
|
+
def self.parse(line, base)
|
|
30
|
+
feature, init, archive, libs = line.strip.split(" ", 4)
|
|
31
|
+
raise "bad extension line #{line.inspect}" unless feature&.match?(%r{\A[\w/.-]+\z}) && init&.match?(/\A\w+\z/)
|
|
32
|
+
|
|
33
|
+
new(feature, init, File.expand_path(archive.sub("@SDK@", base.to_s)), libs.to_s.split(","))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :dir, :arch, :toolchain
|
|
38
|
+
|
|
39
|
+
def initialize(dir, arch:)
|
|
40
|
+
@dir = Pathname(dir)
|
|
41
|
+
@arch = arch
|
|
42
|
+
raise "#{@dir} is not a static runtime SDK (no link.args)" unless (@dir / "link.args").file?
|
|
43
|
+
|
|
44
|
+
@info = (@dir / "sdk.json").file? ? JSON.parse((@dir / "sdk.json").read) : {}
|
|
45
|
+
@toolchain = @info.fetch("toolchain", "gcc")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Libraries available as DLLs instead of linked in (--runtime-dll):
|
|
49
|
+
# name ("sybdb") => { "dll" => ..., "import" => ... }, relative to the SDK.
|
|
50
|
+
def dlls = @info.fetch("dlls", {})
|
|
51
|
+
|
|
52
|
+
# extconf.rb options for gems whose C library the SDK provides, so that
|
|
53
|
+
# extconf.rb uses it instead of downloading and building its own.
|
|
54
|
+
GEM_EXTCONF_ARGS = {
|
|
55
|
+
"nokogiri" => ["--use-system-libraries"],
|
|
56
|
+
"tiny_tds" => ["--with-freetds-dir=@SDK_DEPS@"],
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def extconf_args(gem_name)
|
|
60
|
+
(GEM_EXTCONF_ARGS[gem_name] || []).map { |a| a.gsub("@SDK_DEPS@", (@dir / "deps").to_s) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def msvc? = @toolchain == "msvc"
|
|
64
|
+
|
|
65
|
+
# True when the application archive cannot be appended to the
|
|
66
|
+
# executable but is linked in as the section __OCRAN,__zip (macOS, where
|
|
67
|
+
# appended data would break the code signature).
|
|
68
|
+
def section_payload? = @info["payload"] == "section"
|
|
69
|
+
|
|
70
|
+
def link_args = (@dir / "link.args").read.lines(chomp: true).map { |a| a.gsub("@SDK@", @dir.to_s) }
|
|
71
|
+
|
|
72
|
+
# The SDK's header directories, ahead of whatever an extconf.rb adds:
|
|
73
|
+
# the SDK's libraries are the ones linked, so their headers must win
|
|
74
|
+
# over a system copy (nokogiri adds the libxml2 headers of Apple's SDK,
|
|
75
|
+
# an older libxml2, on macOS).
|
|
76
|
+
def sdk_include_flags
|
|
77
|
+
deps = @dir / "deps" / "include"
|
|
78
|
+
[deps, deps / "libxml2"].select(&:directory?).map { |dir| "-I#{dir}" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The C compiler driver of the target.
|
|
82
|
+
def cc
|
|
83
|
+
return "cl" if msvc?
|
|
84
|
+
|
|
85
|
+
env = ENV["OCRAN_RUNTIME_CC"]
|
|
86
|
+
env && !env.empty? ? env : link_args.first
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The gem extensions already linked into the runtime.
|
|
90
|
+
def base_extensions
|
|
91
|
+
Dir[(@dir / "exts/*.line").to_s].sort.map { |f| Extension.parse(File.read(f), @dir) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Fails early, with a hint, when the build host has no compiler for the
|
|
95
|
+
# runtime's target.
|
|
96
|
+
def check_toolchain!
|
|
97
|
+
return check_msvc! if msvc?
|
|
98
|
+
|
|
99
|
+
out, status = Open3.capture2e(cc, "-dumpmachine")
|
|
100
|
+
expected = @info["dumpmachine"]
|
|
101
|
+
return if status.success? && (expected.nil? || out.include?(expected))
|
|
102
|
+
|
|
103
|
+
raise "#{cc} #{status.success? ? "does not target #{expected} (#{out.strip})" : "did not run"}; " \
|
|
104
|
+
"set OCRAN_RUNTIME_CC to #{toolchain_hint}"
|
|
105
|
+
rescue SystemCallError
|
|
106
|
+
raise "#{cc} not found; set OCRAN_RUNTIME_CC to #{toolchain_hint}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def toolchain_hint
|
|
110
|
+
target_os = @info.fetch("target_os", "mingw32")
|
|
111
|
+
if target_os.start_with?("darwin")
|
|
112
|
+
return "clang from Xcode or the Command Line Tools (xcode-select --install)"
|
|
113
|
+
end
|
|
114
|
+
if target_os.start_with?("mingw")
|
|
115
|
+
"a MinGW-w64 UCRT C compiler (on Linux: gcc-mingw-w64-ucrt64, on Windows: RubyInstaller's " \
|
|
116
|
+
"MSYS2, e.g. ucrt64/bin/gcc.exe)"
|
|
117
|
+
else
|
|
118
|
+
"a musl C compiler (musl-gcc from musl-tools)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_msvc!
|
|
123
|
+
%w[cl nmake].each do |tool|
|
|
124
|
+
Open3.capture2e(tool, "-?")
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
raise "#{tool} not found: building C extensions for an mswin runtime needs Visual Studio " \
|
|
127
|
+
"(Build Tools); run ocran from a \"x64 Native Tools\" developer prompt"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Compiles the C extension in +ext_dir+ (a gem's ext/<name>/, with its
|
|
132
|
+
# extconf.rb) to a static library under +work_dir+. +gem_path+: more
|
|
133
|
+
# gem directories for the gems extconf.rb itself uses.
|
|
134
|
+
def build_extension(ext_dir, work_dir, *extconf_args, gem_path: [])
|
|
135
|
+
pkgconfig = File.join(work_dir.to_s + ".pkgconfig")
|
|
136
|
+
FileUtils.mkdir_p(pkgconfig)
|
|
137
|
+
Dir[(@dir / "deps/lib/pkgconfig/*.pc").to_s].each do |pc|
|
|
138
|
+
File.write(File.join(pkgconfig, File.basename(pc)), File.read(pc).gsub("@SDK_DEPS@", (@dir / "deps").to_s))
|
|
139
|
+
end
|
|
140
|
+
# The SDK's rbconfig.rb is found first on the load path; a MinGW SDK
|
|
141
|
+
# also has Ruby's <arch>-fake.rb, which makes any Ruby pose as the
|
|
142
|
+
# runtime's (an mswin build host runs the runtime's own revision).
|
|
143
|
+
fake = Dir[(@dir / "*-fake.rb").to_s].first
|
|
144
|
+
env = { "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, "BUNDLER_SETUP" => nil,
|
|
145
|
+
"OCRAN_RUNTIME_CC" => msvc? ? nil : ENV["OCRAN_RUNTIME_CC"] || (cc == link_args.first ? nil : cc),
|
|
146
|
+
"OCRAN_RUNTIME_LIBS" => @info.fetch("libs", []).join(" "),
|
|
147
|
+
"OCRAN_RUNTIME_CPPFLAGS" => (sdk_include_flags + @info.fetch("cppflags", [])).join(" "),
|
|
148
|
+
"PKG_CONFIG_LIBDIR" => pkgconfig, "PKG_CONFIG_PATH" => "",
|
|
149
|
+
# Build-time gems extconf.rb uses (nokogiri: mini_portile2):
|
|
150
|
+
# the build host's, which the target's RbConfig would hide.
|
|
151
|
+
"GEM_HOME" => Gem.dir, "GEM_PATH" => (gem_path + Gem.path).join(File::PATH_SEPARATOR) }
|
|
152
|
+
cmd = [RbConfig.ruby, "--disable=gems", "-I", @dir.to_s, *(fake ? ["-r", fake] : []), BUILD_STATIC_EXT,
|
|
153
|
+
(@dir / "include").to_s, (@dir / "include" / @arch).to_s, ext_dir.to_s, work_dir.to_s, *extconf_args]
|
|
154
|
+
out, status = Open3.capture2e(env, *cmd)
|
|
155
|
+
unless status.success?
|
|
156
|
+
raise "Building the C extension in #{ext_dir} for the static runtime failed:\n#{out.lines.last(25).join}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Extension.parse(out.lines.last, @dir)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Links the runtime with the given extensions registered in addition to
|
|
163
|
+
# those it already has, into +output+. Libraries named in +dlls+ are
|
|
164
|
+
# linked against their DLL instead of statically (see #dll_files).
|
|
165
|
+
# macOS: the application archive is linked in as a section (see
|
|
166
|
+
# #section_payload?); +archive+ is that file.
|
|
167
|
+
def link(extensions, output, dlls: [], archive: nil)
|
|
168
|
+
unknown = dlls - self.dlls.keys
|
|
169
|
+
unless unknown.empty?
|
|
170
|
+
raise "No DLL build of #{unknown.join(", ")} in #{@dir} (available: #{self.dlls.keys.join(", ")})"
|
|
171
|
+
end
|
|
172
|
+
as_dll = dlls.to_h { |name| ["-l#{name}", (@dir / self.dlls[name]["import"]).to_s] }
|
|
173
|
+
all = base_extensions + extensions
|
|
174
|
+
Dir.mktmpdir do |tmp|
|
|
175
|
+
src = File.join(tmp, "ocran_exts.c")
|
|
176
|
+
File.write(src, exts_source(all))
|
|
177
|
+
obj = File.join(tmp, msvc? ? "ocran_exts.obj" : "ocran_exts.o")
|
|
178
|
+
if msvc?
|
|
179
|
+
run("cl", "-nologo", "-MT", "-O2", "-c", src, "-Fo#{obj}")
|
|
180
|
+
else
|
|
181
|
+
run(cc, "-O2", "-c", src, "-o", obj)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
extra = [obj] + all.flat_map { |ext| [ext.archive, *ext.libs] } + @info.fetch("libs", [])
|
|
185
|
+
args = link_args
|
|
186
|
+
args[0] = cc
|
|
187
|
+
cmd = args.flat_map { |a| a == "@EXTRA@" ? extra : [a.gsub("@OUTPUT@", output.to_s)] }
|
|
188
|
+
cmd = cmd.map { |a| as_dll.fetch(a, a) }
|
|
189
|
+
cmd += ["-o", output.to_s] unless args.any? { |a| a.include?("@OUTPUT@") }
|
|
190
|
+
cmd += ["-Wl,-sectcreate,__OCRAN,__zip,#{archive}"] if archive
|
|
191
|
+
# macOS: no debug map (the objects' paths, here in a temporary
|
|
192
|
+
# directory, would differ every time), and nothing for strip to do,
|
|
193
|
+
# which would break the signature the linker writes.
|
|
194
|
+
cmd += ["-Wl,-S"] if @info["target_os"].to_s.start_with?("darwin")
|
|
195
|
+
run(*cmd)
|
|
196
|
+
end
|
|
197
|
+
strip = strip_tool
|
|
198
|
+
run(strip, output.to_s) if strip
|
|
199
|
+
output
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The strip of the compiler in use: sdk.json names the SDK build host's
|
|
203
|
+
# (nil on macOS, where stripping would break the signature), which
|
|
204
|
+
# another compiler named with OCRAN_RUNTIME_CC may not come with (MSYS2
|
|
205
|
+
# has gcc and strip, not x86_64-w64-mingw32ucrt-strip).
|
|
206
|
+
def strip_tool
|
|
207
|
+
return nil if msvc? || (@info.key?("strip") && @info["strip"].nil?)
|
|
208
|
+
|
|
209
|
+
derived = cc.sub(/gcc(\.exe)?\z/) { "strip#{$1}" } if cc.match?(/gcc(\.exe)?\z/)
|
|
210
|
+
return derived if derived && cc != link_args.first
|
|
211
|
+
|
|
212
|
+
@info["strip"] || derived
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The DLL files to ship next to the executable for +dlls+: each named
|
|
216
|
+
# library's DLL and the SDK DLLs it imports in turn (libsybdb-5.dll
|
|
217
|
+
# needs libiconv-2.dll).
|
|
218
|
+
def dll_files(dlls)
|
|
219
|
+
by_file = self.dlls.values.to_h { |d| [File.basename(d["dll"]).downcase, @dir / d["dll"]] }
|
|
220
|
+
queue = dlls.map { |name| @dir / self.dlls.fetch(name)["dll"] }
|
|
221
|
+
found = {}
|
|
222
|
+
until queue.empty?
|
|
223
|
+
dll = queue.shift
|
|
224
|
+
next if found[dll.basename.to_s.downcase]
|
|
225
|
+
|
|
226
|
+
found[dll.basename.to_s.downcase] = dll
|
|
227
|
+
PEImports.dll_names(dll.to_s).each { |name| queue << by_file[name.downcase] if by_file[name.downcase] }
|
|
228
|
+
end
|
|
229
|
+
found.values
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def exts_source(extensions)
|
|
235
|
+
<<~C
|
|
236
|
+
/* generated by ocran (Ocran::RuntimeSdk) */
|
|
237
|
+
void ruby_init_ext(const char *name, void (*init)(void));
|
|
238
|
+
#{extensions.map { |e| "extern void #{e.init}(void);" }.join("\n")}
|
|
239
|
+
void ocran_init_extra_exts(void)
|
|
240
|
+
{
|
|
241
|
+
#{extensions.map { |e| " ruby_init_ext(\"#{e.feature}.so\", #{e.init});" }.join("\n")}
|
|
242
|
+
}
|
|
243
|
+
C
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Compilers, linkers and strip run with SOURCE_DATE_EPOCH set: binutils
|
|
247
|
+
# then writes that, not the current time, into a PE header it rewrites
|
|
248
|
+
# (strip does), so that the same application links to the same bytes.
|
|
249
|
+
# ZERO_AR_DATE does the same for Apple's ld, which otherwise writes the
|
|
250
|
+
# objects' modification times into the executable's debug map.
|
|
251
|
+
def run(*cmd)
|
|
252
|
+
env = { "SOURCE_DATE_EPOCH" => ENV["SOURCE_DATE_EPOCH"] || "1767225600", "ZERO_AR_DATE" => "1" }
|
|
253
|
+
out, status = Open3.capture2e(env, *cmd)
|
|
254
|
+
raise "#{cmd.first} failed:\n#{out.lines.last(25).join}" unless status.success?
|
|
255
|
+
|
|
256
|
+
out
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -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
|