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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +114 -0
  3. data/LICENSE +21 -0
  4. data/Makefile +137 -0
  5. data/README.md +400 -0
  6. data/bin/rudy4 +6 -0
  7. data/examples/hello_gem/README.md +91 -0
  8. data/examples/hello_gem/ext/hello_gem/extconf.rb +75 -0
  9. data/examples/hello_gem/ext/hello_gem/hello_gem.d +122 -0
  10. data/examples/hello_gem/hello_gem.gemspec +35 -0
  11. data/examples/hello_gem/lib/hello_gem/version.rb +5 -0
  12. data/examples/hello_gem/lib/hello_gem.rb +32 -0
  13. data/examples/hello_gem/test/smoke.sh +81 -0
  14. data/experiments/exp_compact.d +178 -0
  15. data/experiments/exp_convert.d +93 -0
  16. data/experiments/exp_cross.d +144 -0
  17. data/experiments/exp_defargs.d +301 -0
  18. data/experiments/exp_exc.d +265 -0
  19. data/experiments/exp_gcstorm.d +169 -0
  20. data/experiments/exp_http.d +254 -0
  21. data/experiments/exp_nonregression.d +209 -0
  22. data/experiments/exp_objproto.d +123 -0
  23. data/experiments/exp_perf.d +81 -0
  24. data/experiments/exp_wrap.d +186 -0
  25. data/lib/rudy4/cli.rb +204 -0
  26. data/lib/rudy4/doctor.rb +295 -0
  27. data/lib/rudy4/error.rb +10 -0
  28. data/lib/rudy4/generator.rb +172 -0
  29. data/lib/rudy4/templates.rb +484 -0
  30. data/lib/rudy4/vendor.rb +36 -0
  31. data/lib/rudy4/version.rb +5 -0
  32. data/lib/rudy4.rb +16 -0
  33. data/scripts/abi_audit.sh +64 -0
  34. data/scripts/memcheck.sh +77 -0
  35. data/scripts/vendor_check.sh +33 -0
  36. data/scripts/vendor_sync.sh +25 -0
  37. data/src/dexitest.d +278 -0
  38. data/src/dextest.d +119 -0
  39. data/src/dtypetest.d +159 -0
  40. data/src/rudy/args.d +253 -0
  41. data/src/rudy/bind/value.d +306 -0
  42. data/src/rudy/convert.d +221 -0
  43. data/src/rudy/defs.d +440 -0
  44. data/src/rudy/objproto.d +282 -0
  45. data/src/rudy/typeddata.d +143 -0
  46. data/test/dextest_loader.rb +6 -0
  47. data/test/exitest.rb +113 -0
  48. data/test/rudy4_test.rb +291 -0
  49. data/test/run.rb +74 -0
  50. data/test/typedtest.rb +69 -0
  51. data/vendor/rudy/args.d +253 -0
  52. data/vendor/rudy/bind/value.d +306 -0
  53. data/vendor/rudy/convert.d +221 -0
  54. data/vendor/rudy/defs.d +440 -0
  55. data/vendor/rudy/objproto.d +282 -0
  56. data/vendor/rudy/typeddata.d +143 -0
  57. 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
@@ -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
@@ -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