rudy4 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +114 -0
- data/LICENSE +21 -0
- data/Makefile +137 -0
- data/README.md +400 -0
- data/bin/rudy4 +6 -0
- data/examples/hello_gem/README.md +91 -0
- data/examples/hello_gem/ext/hello_gem/extconf.rb +75 -0
- data/examples/hello_gem/ext/hello_gem/hello_gem.d +122 -0
- data/examples/hello_gem/hello_gem.gemspec +35 -0
- data/examples/hello_gem/lib/hello_gem/version.rb +5 -0
- data/examples/hello_gem/lib/hello_gem.rb +32 -0
- data/examples/hello_gem/test/smoke.sh +81 -0
- data/experiments/exp_compact.d +178 -0
- data/experiments/exp_convert.d +93 -0
- data/experiments/exp_cross.d +144 -0
- data/experiments/exp_defargs.d +301 -0
- data/experiments/exp_exc.d +265 -0
- data/experiments/exp_gcstorm.d +169 -0
- data/experiments/exp_http.d +254 -0
- data/experiments/exp_nonregression.d +209 -0
- data/experiments/exp_objproto.d +123 -0
- data/experiments/exp_perf.d +81 -0
- data/experiments/exp_wrap.d +186 -0
- data/lib/rudy4/cli.rb +204 -0
- data/lib/rudy4/doctor.rb +295 -0
- data/lib/rudy4/error.rb +10 -0
- data/lib/rudy4/generator.rb +172 -0
- data/lib/rudy4/templates.rb +484 -0
- data/lib/rudy4/vendor.rb +36 -0
- data/lib/rudy4/version.rb +5 -0
- data/lib/rudy4.rb +16 -0
- data/scripts/abi_audit.sh +64 -0
- data/scripts/memcheck.sh +77 -0
- data/scripts/vendor_check.sh +33 -0
- data/scripts/vendor_sync.sh +25 -0
- data/src/dexitest.d +278 -0
- data/src/dextest.d +119 -0
- data/src/dtypetest.d +159 -0
- data/src/rudy/args.d +253 -0
- data/src/rudy/bind/value.d +306 -0
- data/src/rudy/convert.d +221 -0
- data/src/rudy/defs.d +440 -0
- data/src/rudy/objproto.d +282 -0
- data/src/rudy/typeddata.d +143 -0
- data/test/dextest_loader.rb +6 -0
- data/test/exitest.rb +113 -0
- data/test/rudy4_test.rb +291 -0
- data/test/run.rb +74 -0
- data/test/typedtest.rb +69 -0
- data/vendor/rudy/args.d +253 -0
- data/vendor/rudy/bind/value.d +306 -0
- data/vendor/rudy/convert.d +221 -0
- data/vendor/rudy/defs.d +440 -0
- data/vendor/rudy/objproto.d +282 -0
- data/vendor/rudy/typeddata.d +143 -0
- metadata +106 -0
data/lib/rudy4/cli.rb
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
require_relative "vendor"
|
|
8
|
+
require_relative "generator"
|
|
9
|
+
require_relative "doctor"
|
|
10
|
+
|
|
11
|
+
module Rudy4
|
|
12
|
+
# Command-line front end for the rudy4 gem.
|
|
13
|
+
#
|
|
14
|
+
# rudy4 init NAME [--force] scaffold a self-contained extension gem
|
|
15
|
+
# rudy4 doctor [--quick] check the Ruby + GDC toolchain
|
|
16
|
+
# rudy4 vendor refresh the vendored rudy4 sources in place
|
|
17
|
+
# rudy4 --version
|
|
18
|
+
# rudy4 help
|
|
19
|
+
class CLI
|
|
20
|
+
EXIT_OK = 0
|
|
21
|
+
EXIT_ERROR = 1
|
|
22
|
+
EXIT_USAGE = 64
|
|
23
|
+
|
|
24
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
25
|
+
@argv = argv.dup
|
|
26
|
+
@out = out
|
|
27
|
+
@err = err
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
command = @argv.shift
|
|
32
|
+
|
|
33
|
+
case command
|
|
34
|
+
when nil, "help", "-h", "--help" then usage; EXIT_OK
|
|
35
|
+
when "--version", "-v", "version" then @out.puts Rudy4::VERSION; EXIT_OK
|
|
36
|
+
when "init", "new" then cmd_init
|
|
37
|
+
when "doctor", "check" then cmd_doctor
|
|
38
|
+
when "vendor" then cmd_vendor
|
|
39
|
+
else
|
|
40
|
+
@err.puts "rudy4: unknown command #{command.inspect}"
|
|
41
|
+
@err.puts
|
|
42
|
+
usage(@err)
|
|
43
|
+
EXIT_USAGE
|
|
44
|
+
end
|
|
45
|
+
rescue UsageError => e
|
|
46
|
+
@err.puts "rudy4: #{e.message}"
|
|
47
|
+
EXIT_USAGE
|
|
48
|
+
rescue Error => e
|
|
49
|
+
@err.puts "rudy4: #{e.message}"
|
|
50
|
+
EXIT_ERROR
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Minimal hand-rolled option parsing — the gem deliberately has no runtime
|
|
56
|
+
# dependencies, so no OptionParser/Thor.
|
|
57
|
+
def parse_options(args, allowed)
|
|
58
|
+
opts = {}
|
|
59
|
+
rest = []
|
|
60
|
+
|
|
61
|
+
args.each do |arg|
|
|
62
|
+
case arg
|
|
63
|
+
when "--force" then require_flag(allowed, "force"); opts[:force] = true
|
|
64
|
+
when "--quick" then require_flag(allowed, "quick"); opts[:quick] = true
|
|
65
|
+
when "--help", "-h" then require_flag(allowed, "help"); opts[:help] = true
|
|
66
|
+
when /\A--/ then raise UsageError, "unknown option #{arg}"
|
|
67
|
+
else rest << arg
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
[opts, rest]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def require_flag(allowed, name)
|
|
75
|
+
return if allowed.include?(name)
|
|
76
|
+
|
|
77
|
+
raise UsageError, "option --#{name} is not valid for this command"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cmd_init
|
|
81
|
+
opts, rest = parse_options(@argv, %w[force help])
|
|
82
|
+
if opts[:help]
|
|
83
|
+
usage(@out)
|
|
84
|
+
return EXIT_OK
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
name = rest.shift
|
|
88
|
+
if name.nil? || name.empty?
|
|
89
|
+
raise UsageError, "usage: rudy4 init NAME\n e.g. rudy4 init fast_math"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless rest.empty?
|
|
93
|
+
raise UsageError, "rudy4 init takes a single NAME (got: #{([name] + rest).join(' ')})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
generator = Generator.new(name, force: opts[:force], out: @out)
|
|
97
|
+
files = generator.call
|
|
98
|
+
generator.print_next_steps
|
|
99
|
+
|
|
100
|
+
@out.puts
|
|
101
|
+
@out.puts " #{files.length} files written."
|
|
102
|
+
EXIT_OK
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cmd_doctor
|
|
106
|
+
opts, rest = parse_options(@argv, %w[quick help])
|
|
107
|
+
if opts[:help]
|
|
108
|
+
usage(@out)
|
|
109
|
+
return EXIT_OK
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raise UsageError, "rudy4 doctor takes no arguments" unless rest.empty?
|
|
113
|
+
|
|
114
|
+
Doctor.new(quick: opts[:quick], out: @out).call
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Refresh the vendored rudy4 sources inside an existing scaffolded project.
|
|
118
|
+
# The copied-in library is pinned to the gem version it was generated with;
|
|
119
|
+
# this re-syncs it after a rudy4 upgrade, without touching user code.
|
|
120
|
+
def cmd_vendor
|
|
121
|
+
opts, rest = parse_options(@argv, %w[help])
|
|
122
|
+
if opts[:help]
|
|
123
|
+
usage(@out)
|
|
124
|
+
return EXIT_OK
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
raise UsageError, "rudy4 vendor takes no arguments" unless rest.empty?
|
|
128
|
+
|
|
129
|
+
target = find_extension_dir
|
|
130
|
+
unless target
|
|
131
|
+
raise Error, <<~MSG
|
|
132
|
+
No D extension module found from #{Dir.pwd} upward.
|
|
133
|
+
Run this from the root of a rudy4-generated project (or one of its
|
|
134
|
+
subdirectories) — expected something like ext/NAME/NAME.d.
|
|
135
|
+
MSG
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
unless Vendor.present?
|
|
139
|
+
raise Error, "The rudy4 gem's vendored sources are missing; reinstall the gem."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
dest = File.join(target, "rudy")
|
|
143
|
+
FileUtils.mkdir_p(dest)
|
|
144
|
+
|
|
145
|
+
written = Vendor.d_sources.map do |source|
|
|
146
|
+
relative = source.delete_prefix("#{Vendor.rudy_dir}/")
|
|
147
|
+
path = File.join(dest, relative)
|
|
148
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
149
|
+
FileUtils.cp(source, path)
|
|
150
|
+
relative
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
@out.puts "Refreshed #{written.length} vendored rudy4 sources in #{dest}"
|
|
154
|
+
@out.puts "Refreshed from rudy4 gem v#{Rudy4::VERSION}. Review the rudy4 CHANGELOG for API"
|
|
155
|
+
@out.puts "changes, then run `rake compile`."
|
|
156
|
+
EXIT_OK
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Walk up from the current directory looking for an extension whose D source
|
|
160
|
+
# matches the name of its own directory (the layout `rudy4 init` produces).
|
|
161
|
+
def find_extension_dir
|
|
162
|
+
dir = File.expand_path(Dir.pwd)
|
|
163
|
+
|
|
164
|
+
loop do
|
|
165
|
+
ext = File.join(dir, "ext")
|
|
166
|
+
if File.directory?(ext)
|
|
167
|
+
Dir.children(ext).sort.each do |entry|
|
|
168
|
+
candidate = File.join(ext, entry)
|
|
169
|
+
next unless File.directory?(candidate)
|
|
170
|
+
return candidate if File.file?(File.join(candidate, "#{entry}.d"))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
parent = File.dirname(dir)
|
|
175
|
+
break if parent == dir
|
|
176
|
+
|
|
177
|
+
dir = parent
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def usage(io = @out)
|
|
184
|
+
io.puts <<~TEXT
|
|
185
|
+
rudy4 #{Rudy4::VERSION} — write Ruby native extensions in D
|
|
186
|
+
|
|
187
|
+
Usage:
|
|
188
|
+
rudy4 init NAME [--force] Scaffold a self-contained extension gem
|
|
189
|
+
rudy4 doctor [--quick] Check the Ruby + GDC toolchain
|
|
190
|
+
rudy4 vendor Refresh the vendored rudy4 sources
|
|
191
|
+
rudy4 --version
|
|
192
|
+
rudy4 help
|
|
193
|
+
|
|
194
|
+
Getting started:
|
|
195
|
+
rudy4 init fast_math
|
|
196
|
+
cd fast_math
|
|
197
|
+
rake compile # build the D extension with GDC
|
|
198
|
+
rake test # run the generated test suite
|
|
199
|
+
|
|
200
|
+
Then edit ext/fast_math/fast_math.d and repeat `rake compile && rake test`.
|
|
201
|
+
TEXT
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
data/lib/rudy4/doctor.rb
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
require_relative "error"
|
|
10
|
+
require_relative "generator"
|
|
11
|
+
require_relative "vendor"
|
|
12
|
+
require_relative "version"
|
|
13
|
+
|
|
14
|
+
module Rudy4
|
|
15
|
+
# `rudy4 doctor` — check that this machine can actually build a D-backed
|
|
16
|
+
# Ruby extension, and prove it by scaffolding and compiling a throwaway
|
|
17
|
+
# extension. Every failure carries a fix, not just a diagnosis.
|
|
18
|
+
class Doctor
|
|
19
|
+
Error = Rudy4::Error
|
|
20
|
+
# Runtime/toolchain combinations rudy4 has been tested against. Anything
|
|
21
|
+
# else gets a warning, never a hard failure — the point is to be honest
|
|
22
|
+
# about what is verified, not to refuse to try.
|
|
23
|
+
TESTED_RUBY_MAJOR = "4"
|
|
24
|
+
MIN_GDC_MAJOR = 13
|
|
25
|
+
GDC_CANDIDATES = ["gdc-14", "gdc-13", "gdc-12", "gdc"].freeze
|
|
26
|
+
|
|
27
|
+
PASS = "ok"
|
|
28
|
+
WARN = "warn"
|
|
29
|
+
FAIL = "fail"
|
|
30
|
+
|
|
31
|
+
Check = Struct.new(:status, :label, :detail, :fix)
|
|
32
|
+
|
|
33
|
+
def initialize(quick: false, out: $stdout)
|
|
34
|
+
@quick = quick
|
|
35
|
+
@out = out
|
|
36
|
+
@checks = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call
|
|
40
|
+
@out.puts "rudy4 doctor — #{Rudy4::VERSION} on ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
41
|
+
@out.puts
|
|
42
|
+
|
|
43
|
+
check_ruby
|
|
44
|
+
check_ruby_headers
|
|
45
|
+
check_shared_libruby
|
|
46
|
+
check_gdc
|
|
47
|
+
check_vendored_sources
|
|
48
|
+
check_build unless @quick
|
|
49
|
+
|
|
50
|
+
report
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def add(status, label, detail = nil, fix = nil)
|
|
56
|
+
@checks << Check.new(status, label, detail, fix)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- individual checks ---------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def check_ruby
|
|
62
|
+
major = RUBY_VERSION.split(".").first
|
|
63
|
+
if major == TESTED_RUBY_MAJOR
|
|
64
|
+
add PASS, "ruby #{RUBY_VERSION}", "matches the tested 4.x C API"
|
|
65
|
+
else
|
|
66
|
+
add WARN, "ruby #{RUBY_VERSION}",
|
|
67
|
+
"rudy4 is only tested against Ruby #{TESTED_RUBY_MAJOR}.x",
|
|
68
|
+
"Expect compile or ABI problems on Ruby #{major}.x; pin Ruby 4.x for now."
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check_ruby_headers
|
|
73
|
+
hdr = RbConfig::CONFIG["rubyhdrdir"]
|
|
74
|
+
archhdr = RbConfig::CONFIG["rubyarchhdrdir"]
|
|
75
|
+
|
|
76
|
+
if hdr && File.directory?(hdr) && File.file?(File.join(hdr, "ruby.h"))
|
|
77
|
+
add PASS, "ruby headers", hdr
|
|
78
|
+
else
|
|
79
|
+
add FAIL, "ruby headers",
|
|
80
|
+
"ruby.h not found (#{hdr.inspect})",
|
|
81
|
+
"Install the development headers: `apt-get install ruby-dev` " \
|
|
82
|
+
"(Debian/Ubuntu) or `ruby-devel` (Fedora), or use a Ruby built with headers."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if archhdr && File.directory?(archhdr)
|
|
86
|
+
add PASS, "ruby arch headers", archhdr
|
|
87
|
+
else
|
|
88
|
+
add FAIL, "ruby arch headers",
|
|
89
|
+
"rubyarchhdrdir not found (#{archhdr.inspect})",
|
|
90
|
+
"Same fix as ruby headers — they ship together (ruby-dev / ruby-devel)."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_shared_libruby
|
|
95
|
+
libarg = RbConfig::CONFIG["LIBRUBYARG_SHARED"].to_s
|
|
96
|
+
libdir = RbConfig::CONFIG["libdir"].to_s
|
|
97
|
+
|
|
98
|
+
if libarg.empty?
|
|
99
|
+
add FAIL, "shared libruby",
|
|
100
|
+
"this Ruby has no LIBRUBYARG_SHARED",
|
|
101
|
+
"The extension links against libruby.so, so Ruby must be built " \
|
|
102
|
+
"with --enable-shared. Rebuild Ruby with that flag (or install a " \
|
|
103
|
+
"distro/shared build)."
|
|
104
|
+
else
|
|
105
|
+
add PASS, "shared libruby", "#{libarg} (in #{libdir})"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_gdc
|
|
110
|
+
@gdc = detect_gdc
|
|
111
|
+
|
|
112
|
+
unless @gdc
|
|
113
|
+
add FAIL, "gdc (D compiler)",
|
|
114
|
+
"no GDC binary found (looked for #{GDC_CANDIDATES.join(', ')})",
|
|
115
|
+
"Install it — Ubuntu 24.04: `apt-get install gdc-14`; " \
|
|
116
|
+
"see https://gdcproject.org/downloads. Set $GDC to point at a specific binary."
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
version = @gdc_version = gdc_version(@gdc)
|
|
121
|
+
major = version&.split(".")&.first.to_i
|
|
122
|
+
|
|
123
|
+
if version.nil?
|
|
124
|
+
add WARN, "gdc (D compiler)", "#{@gdc} (version not parsed)"
|
|
125
|
+
elsif major >= MIN_GDC_MAJOR
|
|
126
|
+
detail = "#{@gdc} — GDC #{version}"
|
|
127
|
+
detail += ", matches the tested GDC 14" if major == 14
|
|
128
|
+
add PASS, "gdc (D compiler)", detail
|
|
129
|
+
else
|
|
130
|
+
add WARN, "gdc (D compiler)",
|
|
131
|
+
"#{@gdc} — GDC #{version} (older than the required #{MIN_GDC_MAJOR})",
|
|
132
|
+
"Upgrade to GDC 14 (`apt-get install gdc-14`); rudy4 uses D features " \
|
|
133
|
+
"older front-ends do not implement."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def check_vendored_sources
|
|
138
|
+
if Vendor.present?
|
|
139
|
+
count = Vendor.d_sources.length
|
|
140
|
+
add PASS, "vendored rudy4 sources", "#{count} .d files in #{Vendor.rudy_dir}"
|
|
141
|
+
else
|
|
142
|
+
add FAIL, "vendored rudy4 sources",
|
|
143
|
+
"missing from #{Vendor.rudy_dir}",
|
|
144
|
+
"Reinstall the gem: `gem install rudy4`."
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The real test: scaffold a throwaway extension, build it with GDC, load it.
|
|
149
|
+
def check_build
|
|
150
|
+
unless @gdc && Vendor.present?
|
|
151
|
+
add WARN, "build smoke test", "skipped (fix the failures above first)"
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Dir.mktmpdir("rudy4-doctor-") do |tmp|
|
|
156
|
+
name = "doctor_probe"
|
|
157
|
+
generator = Generator.new(name, parent_dir: tmp, out: StringIO.new)
|
|
158
|
+
generator.call
|
|
159
|
+
|
|
160
|
+
ext_dir = File.join(tmp, name, "ext", name)
|
|
161
|
+
ok, output = run_build(ext_dir)
|
|
162
|
+
|
|
163
|
+
unless ok
|
|
164
|
+
add FAIL, "build smoke test",
|
|
165
|
+
"GDC could not compile a scaffolded extension",
|
|
166
|
+
"Compiler output:\n#{indent(output)}"
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
loaded, detail = run_load(File.join(tmp, name, "lib"), name)
|
|
171
|
+
if loaded
|
|
172
|
+
add PASS, "build smoke test", "compiled and loaded a scaffolded extension (#{detail})"
|
|
173
|
+
else
|
|
174
|
+
add FAIL, "build smoke test",
|
|
175
|
+
"the extension compiled but could not be loaded",
|
|
176
|
+
detail
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
rescue Generator::Error => e
|
|
180
|
+
add FAIL, "build smoke test", e.message, nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def run_build(ext_dir)
|
|
184
|
+
env = { "GDC" => File.basename(@gdc) }
|
|
185
|
+
output = +""
|
|
186
|
+
lib_dir = File.expand_path("../../lib", ext_dir)
|
|
187
|
+
|
|
188
|
+
ok = Dir.chdir(ext_dir) do
|
|
189
|
+
FileUtils.rm_f("Makefile")
|
|
190
|
+
|
|
191
|
+
out1, err1, st1 = Open3.capture3(env, RbConfig.ruby, "extconf.rb")
|
|
192
|
+
output << out1 << err1
|
|
193
|
+
next false unless st1.success?
|
|
194
|
+
|
|
195
|
+
out2, err2, st2 = Open3.capture3(env, "make")
|
|
196
|
+
output << out2 << err2
|
|
197
|
+
next false unless st2.success?
|
|
198
|
+
|
|
199
|
+
# RubyGems runs `make install` after `make`; do the same so the .so
|
|
200
|
+
# lands in lib/ where `require` will find it.
|
|
201
|
+
out3, err3, st3 = Open3.capture3(env, "make", "install", "RUBYARCHDIR=#{lib_dir}")
|
|
202
|
+
output << out3 << err3
|
|
203
|
+
st3.success?
|
|
204
|
+
end
|
|
205
|
+
[!!ok, output.strip]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def run_load(lib_dir, name)
|
|
209
|
+
script = "require #{name.inspect}; print #{const_name(name)}.add(2, 3)"
|
|
210
|
+
out, status = Open3.capture2e(RbConfig.ruby, "-I#{lib_dir}", "-e", script)
|
|
211
|
+
|
|
212
|
+
if status.success? && out.strip == "5"
|
|
213
|
+
[true, "add(2, 3) == 5"]
|
|
214
|
+
else
|
|
215
|
+
[false, "loading the built extension failed:\n#{indent(out)}"]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def const_name(snake)
|
|
220
|
+
snake.split("_").map { |part| part[0].upcase + part[1..] }.join
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- toolchain discovery -------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def detect_gdc
|
|
226
|
+
explicit = ENV["GDC"]
|
|
227
|
+
return File.expand_path(explicit) if explicit && !explicit.empty? && which(explicit)
|
|
228
|
+
|
|
229
|
+
candidates = GDC_CANDIDATES.dup
|
|
230
|
+
candidates.unshift(File.basename(explicit)) if explicit && !explicit.empty?
|
|
231
|
+
candidates.uniq.each do |candidate|
|
|
232
|
+
path = which(candidate)
|
|
233
|
+
return path if path
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def which(binary)
|
|
240
|
+
return binary if binary.include?("/") && File.executable?(binary)
|
|
241
|
+
|
|
242
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
243
|
+
path = File.join(dir, binary)
|
|
244
|
+
return path if File.executable?(path) && !File.directory?(path)
|
|
245
|
+
end
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def gdc_version(binary)
|
|
250
|
+
out = IO.popen([binary, "--version"], err: [:child, :out], &:read)
|
|
251
|
+
# "gdc-14 (Ubuntu 14.2.0-4ubuntu2~24.04.1) 14.2.0" -> 14.2.0 (the GDC
|
|
252
|
+
# release; the Ubuntu build suffix differs by distro).
|
|
253
|
+
out[/\b(\d+\.\d+\.\d+)\b/, 1] || out[/\b(\d+\.\d+)\b/, 1]
|
|
254
|
+
rescue SystemCallError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# --- reporting -----------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def report
|
|
261
|
+
@out.puts
|
|
262
|
+
failed = @checks.any? { |c| c.status == FAIL }
|
|
263
|
+
warned = @checks.any? { |c| c.status == WARN }
|
|
264
|
+
|
|
265
|
+
@checks.each do |check|
|
|
266
|
+
@out.puts "#{marker(check.status)} #{check.label}#{": #{check.detail}" if check.detail}"
|
|
267
|
+
@out.puts " → #{check.fix}" if check.fix
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
@out.puts
|
|
271
|
+
if failed
|
|
272
|
+
@out.puts "Not ready to build. Fix the items marked #{marker(FAIL)} above."
|
|
273
|
+
1
|
|
274
|
+
elsif warned
|
|
275
|
+
@out.puts "Usable, with warnings — see #{marker(WARN)} items above."
|
|
276
|
+
0
|
|
277
|
+
else
|
|
278
|
+
@out.puts "All good. Try: rudy4 init fast_math && cd fast_math && rake test"
|
|
279
|
+
0
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def marker(status)
|
|
284
|
+
case status
|
|
285
|
+
when PASS then "[ ok ]"
|
|
286
|
+
when WARN then "[warn]"
|
|
287
|
+
else "[fail]"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def indent(text, spaces = 8)
|
|
292
|
+
text.to_s.lines.map { |line| " " * spaces + line }.join.rstrip
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
data/lib/rudy4/error.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rudy4
|
|
4
|
+
# Base class for the errors this gem reports to the user as a one-line
|
|
5
|
+
# message plus an exit status, rather than a backtrace.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# A command-line usage mistake (unknown option, too many arguments).
|
|
9
|
+
class UsageError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "templates"
|
|
8
|
+
require_relative "vendor"
|
|
9
|
+
|
|
10
|
+
module Rudy4
|
|
11
|
+
# `rudy4 init NAME` — scaffold a self-contained D-backed extension gem.
|
|
12
|
+
#
|
|
13
|
+
# "Self-contained" is the point: the generated project vendors the rudy4 D
|
|
14
|
+
# library under ext/NAME/rudy/, so it compiles with nothing but GDC + the
|
|
15
|
+
# Ruby headers — no rudy4 checkout, no RUDY4_ROOT.
|
|
16
|
+
class Generator
|
|
17
|
+
Error = Rudy4::Error
|
|
18
|
+
|
|
19
|
+
# A rubygems-legal name: lowercase, digits, underscores; must start with a
|
|
20
|
+
# letter. We reject dots/hyphens on purpose — the extension name is spliced
|
|
21
|
+
# into D identifiers and file names, where they would not survive.
|
|
22
|
+
NAME_RE = /\A[a-z][a-z0-9_]*\z/
|
|
23
|
+
|
|
24
|
+
# The names Ruby's own kernel/object namespace already owns well enough
|
|
25
|
+
# that shadowing them would confuse rather than help a first project.
|
|
26
|
+
RESERVED = %w[
|
|
27
|
+
ruby rudy rudy4 object kernel basicobject module klass class
|
|
28
|
+
string integer float array hash symbol range struct
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :name, :dir
|
|
32
|
+
|
|
33
|
+
# @param name [String] the extension name (also the gem and directory name)
|
|
34
|
+
# @param parent_dir [String] where to create `NAME/`
|
|
35
|
+
# @param force [Boolean] write into an existing non-empty directory
|
|
36
|
+
def initialize(name, parent_dir: Dir.pwd, force: false, out: $stdout)
|
|
37
|
+
@name = name.to_s
|
|
38
|
+
@dir = File.expand_path(File.join(parent_dir, @name))
|
|
39
|
+
@force = force
|
|
40
|
+
@out = out
|
|
41
|
+
|
|
42
|
+
validate_name!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
guard_target!
|
|
47
|
+
|
|
48
|
+
unless Vendor.present?
|
|
49
|
+
raise Error, <<~MSG
|
|
50
|
+
The rudy4 gem's vendored D library is missing (#{Vendor.rudy_dir}).
|
|
51
|
+
Reinstall the gem (`gem install rudy4`) — a scaffold without it
|
|
52
|
+
could never compile.
|
|
53
|
+
MSG
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
FileUtils.mkdir_p(@dir)
|
|
57
|
+
written = []
|
|
58
|
+
each_file { |relative, content| written << write_file(relative, content) }
|
|
59
|
+
written += vendor_sources
|
|
60
|
+
written.sort
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_next_steps
|
|
64
|
+
@out.puts <<~TEXT
|
|
65
|
+
|
|
66
|
+
Created #{@name}/ — a Ruby extension gem whose native half is D.
|
|
67
|
+
|
|
68
|
+
Next:
|
|
69
|
+
cd #{@name}
|
|
70
|
+
rake compile # GDC builds ext/#{@name}/#{@name}.d
|
|
71
|
+
rake test # 7 assertions against the compiled extension
|
|
72
|
+
|
|
73
|
+
Then edit ext/#{@name}/#{@name}.d and repeat `rake compile && rake test`.
|
|
74
|
+
Stuck? Run `rudy4 doctor` from inside the project.
|
|
75
|
+
TEXT
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate_name!
|
|
81
|
+
raise Error, "Extension name is required." if @name.empty?
|
|
82
|
+
|
|
83
|
+
unless @name.match?(NAME_RE)
|
|
84
|
+
raise Error, <<~MSG
|
|
85
|
+
"#{@name}" is not a usable extension name.
|
|
86
|
+
Use `snake_case` — a lowercase letter, then lowercase letters,
|
|
87
|
+
digits or underscores (e.g. `fast_math`). Dotted or hyphenated
|
|
88
|
+
names do not survive being spliced into D identifiers.
|
|
89
|
+
MSG
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return unless RESERVED.include?(@name)
|
|
93
|
+
|
|
94
|
+
raise Error, <<~MSG
|
|
95
|
+
"#{@name}" is reserved by rudy4 or by Ruby itself; pick another name.
|
|
96
|
+
MSG
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def guard_target!
|
|
100
|
+
return unless File.exist?(@dir)
|
|
101
|
+
return if @force
|
|
102
|
+
return if Dir.children(@dir).empty?
|
|
103
|
+
|
|
104
|
+
raise Error, <<~MSG
|
|
105
|
+
#{@dir} already exists and is not empty.
|
|
106
|
+
Pick another name, or re-run with --force to write into it anyway.
|
|
107
|
+
MSG
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def ruby_req
|
|
111
|
+
# Scaffold for the Ruby running the generator's major.minor. rudy4 tracks
|
|
112
|
+
# the 4.x C API; claiming a looser requirement would be a lie.
|
|
113
|
+
RbConfig::CONFIG["ruby_version"] || "4.0"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def tokens
|
|
117
|
+
{
|
|
118
|
+
"PROJECT_CONST" => camelize(@name),
|
|
119
|
+
"PROJECT_NAME" => @name,
|
|
120
|
+
"RUBY_REQ" => ruby_req,
|
|
121
|
+
"AUTHOR" => git_config("user.name") || "Your Name",
|
|
122
|
+
"EMAIL" => git_config("user.email") || "you@example.com",
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def camelize(snake)
|
|
127
|
+
snake.split("_").map { |part| part[0].upcase + part[1..] }.join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def git_config(key)
|
|
131
|
+
value = IO.popen(["git", "config", "--get", key], &:read).strip
|
|
132
|
+
value.empty? ? nil : value
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def each_file
|
|
138
|
+
yield "#{@name}.gemspec", Templates.render(Templates::GEMSPEC, tokens)
|
|
139
|
+
yield "lib/#{@name}.rb", Templates.render(Templates::LIB_RB, tokens)
|
|
140
|
+
yield "lib/#{@name}/version.rb", Templates.render(Templates::VERSION_RB, tokens)
|
|
141
|
+
yield "ext/#{@name}/#{@name}.d", Templates.render(Templates::D_SOURCE, tokens)
|
|
142
|
+
yield "ext/#{@name}/extconf.rb", Templates.render(Templates::EXTCONF_RB, tokens)
|
|
143
|
+
yield "test/#{@name}_test.rb", Templates.render(Templates::TEST_RB, tokens)
|
|
144
|
+
yield "Rakefile", Templates.render(Templates::RAKEFILE, tokens)
|
|
145
|
+
yield "README.md", Templates.render(Templates::README, tokens)
|
|
146
|
+
yield ".gitignore", Templates::GITIGNORE
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def write_file(relative, content)
|
|
150
|
+
path = File.join(@dir, relative)
|
|
151
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
152
|
+
File.write(path, content)
|
|
153
|
+
relative
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Copy the vendored rudy4 library in, preserving the `rudy/` module
|
|
157
|
+
# directory so `import rudy.bind.value;` resolves at build time.
|
|
158
|
+
def vendor_sources
|
|
159
|
+
sources = Vendor.d_sources
|
|
160
|
+
raise Error, "No vendored rudy4 D sources found in #{Vendor.rudy_dir}." if sources.empty?
|
|
161
|
+
|
|
162
|
+
sources.map do |source|
|
|
163
|
+
relative = File.join("ext", @name, "rudy",
|
|
164
|
+
source.delete_prefix("#{Vendor.rudy_dir}/"))
|
|
165
|
+
path = File.join(@dir, relative)
|
|
166
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
167
|
+
FileUtils.cp(source, path)
|
|
168
|
+
relative
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|