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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +68 -0
  3. data/README.md +295 -10
  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 +190 -30
  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 +425 -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 +43 -11
  25. data/lib/ocran/runtime_sdk.rb +252 -0
  26. data/lib/ocran/spinel_builder.rb +183 -0
  27. data/lib/ocran/spinel_compatibility.rb +149 -0
  28. data/lib/ocran/static_runtime/boot.rb +443 -0
  29. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  30. data/lib/ocran/static_runtime_builder.rb +814 -0
  31. data/lib/ocran/stub_builder.rb +70 -25
  32. data/lib/ocran/version.rb +1 -1
  33. data/lib/ocran/wasm_builder.rb +570 -0
  34. data/lib/ocran/wasm_compatibility.rb +198 -0
  35. data/lib/ocran/windows_command_escaping.rb +21 -0
  36. data/lib/ocran/zip_payload_builder.rb +18 -9
  37. data/lib/ocran/zip_writer.rb +48 -9
  38. data/src/Makefile +18 -2
  39. data/src/Makefile.mswin +65 -0
  40. data/src/inst_dir.c +34 -6
  41. data/src/path_utils.c +144 -0
  42. data/src/script_info.c +5 -0
  43. data/src/stub.c +77 -19
  44. data/src/stub.rc +1 -1
  45. data/src/system_utils.c +276 -155
  46. data/src/system_utils.h +46 -3
  47. data/src/system_utils_posix.c +243 -148
  48. data/src/unpack.c +9 -0
  49. 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