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,570 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "json"
4
+ require "open3"
5
+ require "pathname"
6
+ require "rbconfig"
7
+ require "tmpdir"
8
+ require_relative "command_output" unless defined?(Ocran::CommandOutput)
9
+ require_relative "aot_toolchain"
10
+ require_relative "npm_package"
11
+ require_relative "wasm_compatibility"
12
+
13
+ module Ocran
14
+ # Builds --wasm output: the application as a web page that runs it in the
15
+ # browser on WebAssembly, written to a directory or a zip archive.
16
+ #
17
+ # Two runtimes:
18
+ #
19
+ # ruby (the default) - ruby.wasm (https://github.com/ruby/ruby.wasm),
20
+ # CRuby with its whole standard library compiled to WebAssembly. The
21
+ # application's files are packed into the interpreter's module with
22
+ # rbwasm (gem install ruby_wasm), at /src. The interpreter is the
23
+ # prebuilt one from npm, and the gems of the application's Gemfile are
24
+ # packed in beside the sources, at /gems, with a setup file that puts
25
+ # them on the load path in place of bundler/setup; that takes seconds.
26
+ # Only a gem with a C extension needs `rbwasm build`, which compiles
27
+ # CRuby and the gems for WebAssembly once and caches it.
28
+ #
29
+ # picoruby - PicoRuby (https://github.com/picoruby/picoruby), the mruby
30
+ # based Ruby for microcontrollers, whose WebAssembly build is 2 MB
31
+ # rather than 30 and needs no tool at all: its runtime comes from npm
32
+ # and runs the script from a <script type="text/ruby"> tag. PicoRuby has
33
+ # no file system holding the application in the browser, so OCRAN
34
+ # bundles the program's own files into one script.
35
+ #
36
+ # Either way the result is a static site: index.html, the runtime and the
37
+ # application. Browsers fetch WebAssembly only over HTTP, so it has to be
38
+ # served rather than opened as a file.
39
+ class WasmBuilder
40
+ include CommandOutput
41
+
42
+ # Versions of CRuby that ruby.wasm publishes prebuilt.
43
+ RUBY_VERSIONS = %w[4.0 3.4 3.3 3.2].freeze
44
+
45
+ # Used when the ruby_wasm gem's own version cannot be determined. The
46
+ # JavaScript runtime, the prebuilt interpreter and rbwasm come from the
47
+ # same ruby.wasm release, so the gem's version picks the npm packages.
48
+ DEFAULT_RUBY_WASM_VERSION = "2.10.1"
49
+
50
+ PICORUBY_VERSION = ENV.fetch("OCRAN_PICORUBY_VERSION", "4.0.5")
51
+ PICORUBY_FILES = %w[dist/init.iife.js dist/picoruby.js dist/picoruby.wasm].freeze
52
+
53
+ MAX_OUTPUT_LINES = 60
54
+
55
+ def initialize(option)
56
+ @option = option
57
+ @script = option.script
58
+ @name = @script.basename(".*").to_s
59
+ @output = option.wasm_output
60
+ end
61
+
62
+ def build
63
+ Dir.mktmpdir("ocran-wasm") do |work|
64
+ site = File.join(work, "site")
65
+ FileUtils.mkdir_p(site)
66
+
67
+ if @option.wasm_runtime == :picoruby
68
+ build_picoruby(site)
69
+ else
70
+ build_ruby_wasm(work, site)
71
+ end
72
+
73
+ if @option.wasm_zip?
74
+ write_zip(site)
75
+ else
76
+ write_directory(site)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ # --- ruby.wasm ------------------------------------------------------
84
+
85
+ def build_ruby_wasm(work, site)
86
+ gemfile = @option.application_gemfile
87
+ bundle = gemfile ? resolve_bundle(gemfile) : []
88
+ analysis = RubyWasmCompatibility.new(@script, bundle: gemfile && bundle.map { |gem| bundle_spec(gem) },
89
+ project_dirs: project_dirs).analyze
90
+
91
+ rbwasm = find_rbwasm
92
+ unless rbwasm
93
+ error "--wasm needs rbwasm, the ruby.wasm packager from the ruby_wasm gem, which was not found."
94
+ STDERR.puts
95
+ STDERR.puts "Install it with:"
96
+ STDERR.puts
97
+ STDERR.puts " gem install ruby_wasm"
98
+ STDERR.puts
99
+ STDERR.puts "(or add `gem \"ruby_wasm\"` to the Gemfile you run OCRAN under). It needs no compiler for"
100
+ STDERR.puts "applications without gems. --wasm=picoruby needs nothing installed at all."
101
+ STDERR.puts
102
+ print_report(analysis, "Meanwhile, a static check of whether #{display(@script)} is likely to run on ruby.wasm:")
103
+ raise "rbwasm is not installed"
104
+ end
105
+
106
+ warn_about(analysis)
107
+ ruby_version = wasm_ruby_version
108
+ npm_version = ruby_wasm_version
109
+ verbose "Using rbwasm #{npm_version} at #{rbwasm.last}, Ruby #{ruby_version}"
110
+
111
+ native = bundle.select { |gem| gem["native"] }
112
+ dirs = []
113
+ if native.empty?
114
+ say "Using the prebuilt ruby.wasm (Ruby #{ruby_version} with its standard library)"
115
+ base = NpmPackage.fetch("@ruby/#{ruby_version}-wasm-wasi", npm_version, ["dist/ruby+stdlib.wasm"]) { |s| say s }
116
+ .fetch("dist/ruby+stdlib.wasm")
117
+ setup = nil
118
+ unless bundle.empty?
119
+ say "Packing the gems of #{display(gemfile)} into the module (#{bundle.map { |g| g["name"] }.join(", ")})"
120
+ gems = File.join(work, "gems")
121
+ stage_gems(gems, bundle)
122
+ dirs.push("--dir", "#{gems}::#{GEMS_MOUNT}")
123
+ setup = GEMS_SETUP
124
+ end
125
+ else
126
+ base = build_with_gems(rbwasm, gemfile, ruby_version, work, analysis, native.map { |g| g["name"] })
127
+ setup = "/bundle/setup"
128
+ end
129
+
130
+ src = File.join(work, "src")
131
+ main = stage_sources(src, analysis)
132
+ say "Packing #{display(@script)} into the WebAssembly module"
133
+ run_or_fail([*rbwasm, "pack", base, "--dir", "#{src}::/src", *dirs, "-o", File.join(site, "app.wasm")],
134
+ "rbwasm could not pack the application", analysis)
135
+
136
+ runtime = NpmPackage.fetch("@ruby/wasm-wasi", npm_version, ["dist/browser.umd.js"]) { |s| say s }
137
+ FileUtils.cp(runtime.fetch("dist/browser.umd.js"), File.join(site, "browser.umd.js"))
138
+ File.write(File.join(site, "index.html"), ruby_wasm_html("/src/#{main}", setup: setup))
139
+ end
140
+
141
+ # Where the gems are packed inside the module, and the file that puts
142
+ # them on the load path. It is required in place of bundler/setup,
143
+ # which has no Gemfile to read in the browser; a shim makes the
144
+ # application's own `require "bundler/setup"` a no-op as well.
145
+ GEMS_MOUNT = "/gems"
146
+ GEMS_SETUP = "/gems/setup.rb"
147
+
148
+ # Directories of a gem that an application never loads.
149
+ GEM_SKIP = %w[test spec features benchmark benchmarks .git .github].freeze
150
+
151
+ # Ruby run in a child process to list the gems of BUNDLE_GEMFILE as
152
+ # Bundler resolves them: every gem of the lock, with where it is
153
+ # installed. Bundler is asked in a child so that it never loads here.
154
+ BUNDLE_QUERY = <<~RUBY
155
+ require "bundler"
156
+ require "json"
157
+ specs = Bundler.definition.specs.reject { |s| s.name == "bundler" }
158
+ puts JSON.generate(specs.map { |s|
159
+ default = s.respond_to?(:default_gem?) && s.default_gem?
160
+ { "name" => s.name, "version" => s.version.to_s, "path" => s.full_gem_path,
161
+ "require_paths" => s.full_require_paths, "default" => default,
162
+ "native" => !s.extensions.empty? || s.platform.to_s != "ruby" }
163
+ })
164
+ RUBY
165
+ private_constant :BUNDLE_QUERY
166
+
167
+ # The gems the application's Gemfile.lock resolves to, as hashes (see
168
+ # BUNDLE_QUERY), without the default gems: ruby.wasm's standard library
169
+ # has those already.
170
+ def resolve_bundle(gemfile)
171
+ lockfile = Pathname("#{gemfile}.lock")
172
+ lockfile = gemfile.dirname + "gems.locked" if gemfile.basename.to_s == "gems.rb"
173
+ return [] unless lockfile.file?
174
+
175
+ env = { "BUNDLE_GEMFILE" => gemfile.to_s, "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "BUNDLE_LOCKFILE" => nil }
176
+ output, status = Open3.capture2e(env, RbConfig.ruby, "-e", BUNDLE_QUERY, chdir: File.dirname(gemfile))
177
+ unless status.success?
178
+ STDERR.puts "Bundler could not resolve the gems of #{display(gemfile)}:"
179
+ STDERR.puts output.lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
180
+ STDERR.puts
181
+ STDERR.puts "The gems have to be installed for this Ruby: run `bundle install` in #{display(File.dirname(gemfile))}."
182
+ raise "the application's gems could not be resolved"
183
+ end
184
+ JSON.parse(output.lines.last).reject { |gem| gem["default"] }
185
+ end
186
+
187
+ # Copies the gems into dir, each under name-version, and writes the
188
+ # setup file that puts their require paths on the load path.
189
+ def stage_gems(dir, bundle)
190
+ load_paths = bundle.flat_map do |gem|
191
+ home = File.join(dir, "#{gem["name"]}-#{gem["version"]}")
192
+ copy_gem(gem["path"], home)
193
+ gem["require_paths"].map do |path|
194
+ rel = Pathname(path).relative_path_from(Pathname(gem["path"])).to_s
195
+ File.join(GEMS_MOUNT, File.basename(home), rel == "." ? "" : rel).chomp("/")
196
+ end
197
+ end
198
+
199
+ shim = File.join(dir, ".shims", "bundler")
200
+ FileUtils.mkdir_p(shim)
201
+ File.write(File.join(shim, "setup.rb"), "# The gems are on the load path already; see #{GEMS_SETUP}.\n")
202
+ File.write(File.join(dir, "setup.rb"), <<~RUBY)
203
+ # Written by OCRAN: the application's gems, packed at #{GEMS_MOUNT}, take the
204
+ # place of the bundle. Required before the program instead of bundler/setup.
205
+ $LOAD_PATH.unshift(#{(load_paths + [File.join(GEMS_MOUNT, ".shims")]).map(&:inspect).join(", ")})
206
+ RUBY
207
+ end
208
+
209
+ def copy_gem(from, to)
210
+ FileUtils.mkdir_p(to)
211
+ Dir.children(from).each do |entry|
212
+ next if GEM_SKIP.include?(entry)
213
+
214
+ FileUtils.cp_r(File.join(from, entry), File.join(to, entry))
215
+ end
216
+ end
217
+
218
+ # `rbwasm build` compiles CRuby for WebAssembly together with the gems
219
+ # the Gemfile lists, under /bundle. Its downloads and build trees are
220
+ # kept in OCRAN's cache, so only the first build is slow.
221
+ def build_with_gems(rbwasm, gemfile, ruby_version, work, analysis, native)
222
+ say "Building ruby.wasm with the gems of #{display(gemfile)}: #{native.join(", ")} " \
223
+ "#{native.size == 1 ? "has" : "have"} a C extension, which the prebuilt interpreter cannot load"
224
+ say "The first build compiles CRuby for WebAssembly and takes several minutes; later builds reuse it"
225
+
226
+ cache = File.join(cache_dir, "ruby_wasm")
227
+ FileUtils.mkdir_p(cache)
228
+ output = File.join(work, "ruby.wasm")
229
+ env = { "RUBY_WASM_ROOT" => cache, "BUNDLE_GEMFILE" => gemfile.to_s,
230
+ "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "BUNDLE_LOCKFILE" => nil }
231
+ # rbwasm reads the bundle through Bundler only when Bundler is loaded,
232
+ # which `bundle exec` would do - but that needs ruby_wasm in the
233
+ # application's own Gemfile.
234
+ command = [*rbwasm_with_bundler(rbwasm), "build", "--ruby-version", ruby_version, *@option.wasm_options, "-o", output]
235
+ run_or_fail(command, "rbwasm could not build ruby.wasm with the application's gems", analysis,
236
+ env: env, chdir: File.dirname(gemfile)) do
237
+ STDERR.puts " - the gems have to be installed for this Ruby: run `bundle install` in #{display(File.dirname(gemfile))}"
238
+ native = analysis.gems.values.select(&:native).map(&:name)
239
+ unless native.empty?
240
+ STDERR.puts " - gems with C extensions are compiled for WASI and often fail there: #{native.join(", ")}"
241
+ end
242
+ end
243
+ output
244
+ end
245
+
246
+ # A specification standing for a gem of the bundle, for the scan: the
247
+ # gem's name, version, require paths and whether it has an extension.
248
+ def bundle_spec(gem)
249
+ home = Pathname(gem["path"])
250
+ Gem::Specification.new do |s|
251
+ s.name = gem["name"]
252
+ s.version = gem["version"]
253
+ s.require_paths = gem["require_paths"].map { |path| Pathname(path).relative_path_from(home).to_s }
254
+ s.extensions = ["ext/extconf.rb"] if gem["native"]
255
+ end.tap { |s| s.full_gem_path = gem["path"] }
256
+ end
257
+
258
+ # The rbwasm command: the RBWASM environment variable, else through this
259
+ # Ruby when the ruby_wasm gem is installed for it, else from PATH.
260
+ def find_rbwasm
261
+ if (explicit = ENV["RBWASM"]) && !explicit.empty?
262
+ raise "RBWASM=#{explicit} is not an executable" unless AotToolchain.executable?(explicit)
263
+
264
+ return [explicit]
265
+ end
266
+ [RbConfig.ruby, Gem.bin_path("ruby_wasm", "rbwasm")]
267
+ rescue Gem::LoadError, Gem::Exception
268
+ path = AotToolchain.search_path("rbwasm")
269
+ path && [path]
270
+ end
271
+
272
+ def rbwasm_with_bundler(rbwasm)
273
+ rbwasm.size == 2 ? [rbwasm[0], "-rbundler", rbwasm[1]] : rbwasm
274
+ end
275
+
276
+ # Gem::MissingSpecError is a LoadError, not a Gem::Exception.
277
+ def ruby_wasm_version
278
+ Gem::Specification.find_by_name("ruby_wasm").version.to_s
279
+ rescue Gem::LoadError, Gem::Exception
280
+ DEFAULT_RUBY_WASM_VERSION
281
+ end
282
+
283
+ # The CRuby version closest to the one OCRAN runs on, so the program
284
+ # meets the Ruby it was written for.
285
+ def wasm_ruby_version
286
+ if (wanted = @option.wasm_ruby)
287
+ return wanted if RUBY_VERSIONS.include?(wanted)
288
+
289
+ raise "ruby.wasm publishes Ruby #{RUBY_VERSIONS.join(", ")}, not #{wanted}"
290
+ end
291
+
292
+ host = RUBY_VERSION.split(".").first(2).join(".")
293
+ RUBY_VERSIONS.include?(host) ? host : RUBY_VERSIONS.first
294
+ end
295
+
296
+ # Copies the program's files into dir, laid out relative to their
297
+ # common directory, and returns the script's path there: the files
298
+ # given on the command line, and the ones the program requires, which
299
+ # the scan found since the program is not run to find them.
300
+ def stage_sources(dir, analysis)
301
+ root = source_root(analysis.project_files)
302
+ (@option.source_files | analysis.project_files).each do |file|
303
+ rel = file.relative_path_from(root)
304
+ dest = File.join(dir, rel.to_s)
305
+ FileUtils.mkdir_p(File.dirname(dest))
306
+ FileUtils.cp(file, dest)
307
+ end
308
+ @script.relative_path_from(root).to_s
309
+ end
310
+
311
+ def source_root(extra = [])
312
+ dirs = (@option.source_files | extra).map(&:dirname)
313
+ dirs.inject do |common, dir|
314
+ common = common.parent until dir.to_s == common.to_s || dir.to_s.start_with?("#{common.to_s.chomp("/")}/")
315
+ common
316
+ end
317
+ end
318
+
319
+ # --- PicoRuby -------------------------------------------------------
320
+
321
+ def build_picoruby(site)
322
+ analysis = PicoRubyCompatibility.new(@script, project_dirs: project_dirs).analyze
323
+ warn_about(analysis)
324
+
325
+ say "Bundling #{display(@script)} for PicoRuby"
326
+ File.write(File.join(site, script_name), bundle_sources(analysis))
327
+
328
+ # Files given besides the code (data, images) are served next to the
329
+ # page, where the program can fetch them through the js library.
330
+ bundled = analysis.files.map(&:to_s)
331
+ root = source_root
332
+ @option.source_files.each do |file|
333
+ next if bundled.include?(file.to_s) || file.extname == ".rb"
334
+
335
+ dest = File.join(site, file.relative_path_from(root).to_s)
336
+ FileUtils.mkdir_p(File.dirname(dest))
337
+ FileUtils.cp(file, dest)
338
+ end
339
+
340
+ runtime = NpmPackage.fetch("@picoruby/wasm-wasi", PICORUBY_VERSION, PICORUBY_FILES) { |s| say s }
341
+ runtime.each_value { |path| FileUtils.cp(path, File.join(site, File.basename(path))) }
342
+ File.write(File.join(site, "index.html"), picoruby_html)
343
+ end
344
+
345
+ # One script made of the program's own files, each placed before the
346
+ # first file that requires it, the way Ruby would have loaded them, with
347
+ # the requires themselves replaced by nil. Line breaks inside a replaced
348
+ # require are kept, so each file's lines stay where they were.
349
+ def bundle_sources(analysis)
350
+ requires = analysis.local_requires.group_by { |r| r.from.to_s }
351
+ order = []
352
+ visit = lambda do |file, active|
353
+ next if order.include?(file) || active.include?(file)
354
+
355
+ (requires[file] || []).each { |r| visit.call(r.target.to_s, active + [file]) }
356
+ order << file
357
+ end
358
+ visit.call(@script.to_s, [])
359
+
360
+ root = source_root
361
+ order.map do |file|
362
+ source = File.binread(file)
363
+ (requires[file] || []).sort_by(&:start_offset).reverse_each do |r|
364
+ original = source.byteslice(r.start_offset...r.end_offset)
365
+ source = source.byteslice(0, r.start_offset) + "nil" + ("\n" * original.count("\n")) +
366
+ source.byteslice(r.end_offset..)
367
+ end
368
+ source = source.force_encoding(Encoding::UTF_8)
369
+ next source if order.size == 1
370
+
371
+ rel = Pathname(file).relative_path_from(root)
372
+ "# ---- #{rel} ----\n#{source.chomp}\n"
373
+ end.join("\n")
374
+ end
375
+
376
+ # The bundled program is served under the script's own name, which is
377
+ # the file name PicoRuby's error messages give.
378
+ def script_name = "#{@name}.rb"
379
+
380
+ # --- Output ---------------------------------------------------------
381
+
382
+ def write_directory(site)
383
+ @output.mkpath
384
+ Dir.children(site).each do |entry|
385
+ FileUtils.rm_rf(@output + entry)
386
+ FileUtils.cp_r(File.join(site, entry), @output + entry)
387
+ end
388
+ finished(@output)
389
+ end
390
+
391
+ def write_zip(site)
392
+ require_relative "zip_writer"
393
+
394
+ @output.dirname.mkpath
395
+ # An empty archive is just its end-of-central-directory record, which
396
+ # ZipWriter appends the site to.
397
+ File.binwrite(@output, "PK\x05\x06".b + ("\0" * 18).b)
398
+ entries = Dir.glob("**/*", base: site).sort.reject { |rel| File.directory?(File.join(site, rel)) }.map do |rel|
399
+ ZipWriter::Entry.new(name: rel, source: File.join(site, rel), mode: 0o644)
400
+ end
401
+ ZipWriter.append(@output.to_s, entries)
402
+ finished(@output)
403
+ end
404
+
405
+ def finished(path)
406
+ say "Finished building #{display(path)}"
407
+ dir = @option.wasm_zip? ? "<unpacked folder>" : display(path)
408
+ say "Browsers load WebAssembly only over HTTP: serve the folder and open index.html, e.g."
409
+ say " ruby -run -e httpd #{dir} -p 8000 (needs the webrick gem)"
410
+ say " python3 -m http.server -d #{dir} 8000"
411
+ end
412
+
413
+ # --- Diagnostics ----------------------------------------------------
414
+
415
+ # Nothing is known to fail until the browser runs it, so what the scan
416
+ # found is shown as a warning and the build goes on.
417
+ def warn_about(analysis)
418
+ return if analysis.findings.empty? || !@option.warning?
419
+
420
+ runtime = analysis.target
421
+ if analysis.errors.empty?
422
+ warning "#{display(@script)} uses #{analysis.findings.size} thing(s) that may not work on #{runtime}" \
423
+ "#{@option.verbose? ? ":" : " (--verbose lists them)"}"
424
+ verbose analysis.report.gsub(/^(?=.)/, " ")
425
+ else
426
+ warning "#{display(@script)} is unlikely to run on #{runtime} as it is; building anyway:"
427
+ STDERR.puts analysis.report.gsub(/^(?=.)/, " ")
428
+ end
429
+ end
430
+
431
+ def print_report(analysis, heading)
432
+ STDERR.puts heading
433
+ STDERR.puts
434
+ STDERR.puts analysis.report.gsub(/^(?=.)/, " ")
435
+ end
436
+
437
+ def run_or_fail(command, failure, analysis, env: {}, chdir: Dir.pwd)
438
+ verbose command.join(" ")
439
+ output, status = Open3.capture2e(env, *command, chdir: chdir)
440
+ return if status.success?
441
+
442
+ lines = output.lines
443
+ STDERR.puts "#{failure} (exit status #{status.exitstatus.inspect}):"
444
+ STDERR.puts " ... (#{lines.size - MAX_OUTPUT_LINES} earlier lines omitted)" if lines.size > MAX_OUTPUT_LINES
445
+ STDERR.puts lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
446
+ STDERR.puts
447
+ print_report(analysis, "What may stand in the way:") unless analysis.findings.empty?
448
+ STDERR.puts
449
+ STDERR.puts "Things to try:"
450
+ yield if block_given?
451
+ STDERR.puts " - --wasm=picoruby runs smaller programs without any of the toolchain"
452
+ raise failure
453
+ end
454
+
455
+ def project_dirs
456
+ [@script.dirname, *@option.source_files.map(&:dirname)].uniq
457
+ end
458
+
459
+ def cache_dir
460
+ base = ENV["XDG_CACHE_HOME"]
461
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
462
+ File.join(base, "ocran")
463
+ rescue ArgumentError
464
+ File.join(Dir.tmpdir, "ocran-cache")
465
+ end
466
+
467
+ # Relative to the working directory when inside it, else absolute.
468
+ def display(path)
469
+ rel = Pathname(path).relative_path_from(Pathname.pwd).to_s
470
+ rel.start_with?("..") ? path.to_s : rel
471
+ rescue ArgumentError
472
+ path.to_s
473
+ end
474
+
475
+ # --- Pages ----------------------------------------------------------
476
+
477
+ # setup: a feature to require before the program, which puts its gems
478
+ # on the load path (GEMS_SETUP, or /bundle/setup after rbwasm build).
479
+ def ruby_wasm_html(main, setup:)
480
+ setup = setup ? %(require #{setup.inspect}\n) : ""
481
+ ruby = <<~RUBY
482
+ #{setup}$0 = #{main.inspect}
483
+ Dir.chdir(File.dirname($0))
484
+ load $0
485
+ RUBY
486
+ page(<<~JS, newline: false)
487
+ <script src="browser.umd.js"></script>
488
+ <script>
489
+ (async () => {
490
+ try {
491
+ const { DefaultRubyVM } = window["ruby-wasm-wasi"];
492
+ const module = await WebAssembly.compileStreaming(fetch("app.wasm"));
493
+ const { vm } = await DefaultRubyVM(module);
494
+ ocranStarted();
495
+ await vm.evalAsync(#{ruby.to_json});
496
+ } catch (error) {
497
+ ocranFailed(error);
498
+ }
499
+ })();
500
+ </script>
501
+ JS
502
+ end
503
+
504
+ def picoruby_html
505
+ page(<<~JS, newline: true)
506
+ <script type="text/ruby" src="#{script_name}"></script>
507
+ <script src="init.iife.js"></script>
508
+ <script>
509
+ const ocranWait = setInterval(() => {
510
+ if (window.picorubyModule) { clearInterval(ocranWait); ocranStarted(); }
511
+ }, 50);
512
+ </script>
513
+ JS
514
+ end
515
+
516
+ # The page around a runtime: a status line while it loads, and the
517
+ # program's standard output and errors mirrored from the console onto
518
+ # the page, where a terminal program's output is expected. newline:
519
+ # whether each console call is a line (PicoRuby) rather than a chunk of
520
+ # output that brings its own line breaks (ruby.wasm).
521
+ def page(runtime, newline:)
522
+ title = @name.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
523
+ <<~HTML
524
+ <!DOCTYPE html>
525
+ <html lang="en">
526
+ <head>
527
+ <meta charset="utf-8">
528
+ <meta name="viewport" content="width=device-width, initial-scale=1">
529
+ <title>#{title}</title>
530
+ <style>
531
+ :root { color-scheme: light dark; }
532
+ body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; }
533
+ #ocran-status { opacity: 0.7; }
534
+ #ocran-output { margin: 0; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace; }
535
+ #ocran-output .stderr { color: #d33; }
536
+ </style>
537
+ </head>
538
+ <body>
539
+ <div id="ocran-status">Loading #{title}...</div>
540
+ <pre id="ocran-output"></pre>
541
+ <script>
542
+ const ocranOutput = document.getElementById("ocran-output");
543
+ const ocranAppend = (text, stream) => {
544
+ const span = document.createElement("span");
545
+ if (stream) span.className = stream;
546
+ span.textContent = #{newline ? 'text + "\\n"' : "text"};
547
+ ocranOutput.appendChild(span);
548
+ };
549
+ const ocranConsole = {};
550
+ const ocranStarted = () => document.getElementById("ocran-status")?.remove();
551
+ const ocranFailed = (error) => {
552
+ ocranStarted();
553
+ ocranAppend(String(error && error.message || error) + #{newline ? '""' : '"\\n"'}, "stderr");
554
+ ocranConsole.error(error);
555
+ };
556
+ for (const [name, stream] of [["log", null], ["info", null], ["warn", "stderr"], ["error", "stderr"]]) {
557
+ const original = ocranConsole[name] = console[name].bind(console);
558
+ console[name] = (...args) => {
559
+ original(...args);
560
+ ocranAppend(args.map(String).join(" "), stream);
561
+ };
562
+ }
563
+ </script>
564
+ #{runtime.gsub(/^/, " ").rstrip}
565
+ </body>
566
+ </html>
567
+ HTML
568
+ end
569
+ end
570
+ end