ocran 1.4.5 → 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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +74 -0
  3. data/README.md +296 -9
  4. data/ext/extconf.rb +5 -1
  5. data/lib/ocran/aot_toolchain.rb +154 -0
  6. data/lib/ocran/command_output.rb +8 -1
  7. data/lib/ocran/compatibility_scan.rb +414 -0
  8. data/lib/ocran/cosmo_toolchain.rb +29 -7
  9. data/lib/ocran/dir_builder.rb +88 -38
  10. data/lib/ocran/direction.rb +232 -33
  11. data/lib/ocran/ed_resource.rb +113 -0
  12. data/lib/ocran/ed_resource_builder.rb +199 -0
  13. data/lib/ocran/file_path_set.rb +9 -5
  14. data/lib/ocran/gem_spec_queryable.rb +7 -3
  15. data/lib/ocran/host_config_helper.rb +11 -1
  16. data/lib/ocran/inno_setup_script_builder.rb +24 -14
  17. data/lib/ocran/launcher_batch_builder.rb +8 -11
  18. data/lib/ocran/npm_package.rb +106 -0
  19. data/lib/ocran/option.rb +433 -35
  20. data/lib/ocran/pe_imports.rb +96 -0
  21. data/lib/ocran/pe_resources.rb +226 -0
  22. data/lib/ocran/refine_pathname.rb +29 -32
  23. data/lib/ocran/roundhouse_builder.rb +316 -0
  24. data/lib/ocran/runner.rb +96 -11
  25. data/lib/ocran/runtime_environment.rb +14 -6
  26. data/lib/ocran/runtime_sdk.rb +252 -0
  27. data/lib/ocran/spinel_builder.rb +183 -0
  28. data/lib/ocran/spinel_compatibility.rb +149 -0
  29. data/lib/ocran/static_runtime/boot.rb +443 -0
  30. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  31. data/lib/ocran/static_runtime_builder.rb +814 -0
  32. data/lib/ocran/stub_builder.rb +70 -25
  33. data/lib/ocran/version.rb +1 -1
  34. data/lib/ocran/wasm_builder.rb +570 -0
  35. data/lib/ocran/wasm_compatibility.rb +198 -0
  36. data/lib/ocran/windows_command_escaping.rb +21 -0
  37. data/lib/ocran/zip_payload_builder.rb +18 -9
  38. data/lib/ocran/zip_writer.rb +48 -9
  39. data/src/Makefile +18 -2
  40. data/src/Makefile.mswin +65 -0
  41. data/src/inst_dir.c +34 -6
  42. data/src/path_utils.c +144 -0
  43. data/src/script_info.c +5 -0
  44. data/src/stub.c +77 -19
  45. data/src/stub.rc +1 -1
  46. data/src/system_utils.c +276 -155
  47. data/src/system_utils.h +46 -3
  48. data/src/system_utils_posix.c +243 -148
  49. data/src/unpack.c +9 -0
  50. metadata +23 -5
@@ -0,0 +1,252 @@
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
+ run(*cmd)
192
+ end
193
+ strip = strip_tool
194
+ run(strip, output.to_s) if strip
195
+ output
196
+ end
197
+
198
+ # The strip of the compiler in use: sdk.json names the SDK build host's
199
+ # (nil on macOS, where stripping would break the signature), which
200
+ # another compiler named with OCRAN_RUNTIME_CC may not come with (MSYS2
201
+ # has gcc and strip, not x86_64-w64-mingw32ucrt-strip).
202
+ def strip_tool
203
+ return nil if msvc? || (@info.key?("strip") && @info["strip"].nil?)
204
+
205
+ derived = cc.sub(/gcc(\.exe)?\z/) { "strip#{$1}" } if cc.match?(/gcc(\.exe)?\z/)
206
+ return derived if derived && cc != link_args.first
207
+
208
+ @info["strip"] || derived
209
+ end
210
+
211
+ # The DLL files to ship next to the executable for +dlls+: each named
212
+ # library's DLL and the SDK DLLs it imports in turn (libsybdb-5.dll
213
+ # needs libiconv-2.dll).
214
+ def dll_files(dlls)
215
+ by_file = self.dlls.values.to_h { |d| [File.basename(d["dll"]).downcase, @dir / d["dll"]] }
216
+ queue = dlls.map { |name| @dir / self.dlls.fetch(name)["dll"] }
217
+ found = {}
218
+ until queue.empty?
219
+ dll = queue.shift
220
+ next if found[dll.basename.to_s.downcase]
221
+
222
+ found[dll.basename.to_s.downcase] = dll
223
+ PEImports.dll_names(dll.to_s).each { |name| queue << by_file[name.downcase] if by_file[name.downcase] }
224
+ end
225
+ found.values
226
+ end
227
+
228
+ private
229
+
230
+ def exts_source(extensions)
231
+ <<~C
232
+ /* generated by ocran (Ocran::RuntimeSdk) */
233
+ void ruby_init_ext(const char *name, void (*init)(void));
234
+ #{extensions.map { |e| "extern void #{e.init}(void);" }.join("\n")}
235
+ void ocran_init_extra_exts(void)
236
+ {
237
+ #{extensions.map { |e| " ruby_init_ext(\"#{e.feature}.so\", #{e.init});" }.join("\n")}
238
+ }
239
+ C
240
+ end
241
+
242
+ # Compilers, linkers and strip run with SOURCE_DATE_EPOCH set: binutils
243
+ # then writes that, not the current time, into a PE header it rewrites
244
+ # (strip does), so that the same application links to the same bytes.
245
+ def run(*cmd)
246
+ out, status = Open3.capture2e({ "SOURCE_DATE_EPOCH" => ENV["SOURCE_DATE_EPOCH"] || "1767225600" }, *cmd)
247
+ raise "#{cmd.first} failed:\n#{out.lines.last(25).join}" unless status.success?
248
+
249
+ out
250
+ end
251
+ end
252
+ 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