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
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem-level tests for the `rudy4` command: the scaffolder, the doctor, and the
4
+ # vendor refresh. Plain Ruby — no test framework, run it with:
5
+ #
6
+ # ruby -Ilib test/rudy4_test.rb # scaffolding + CLI (fast)
7
+ # RUDY4_FULL=1 ruby -Ilib test/rudy4_test.rb # + real GDC build (slow)
8
+ #
9
+ # The full build test is what CI runs: it scaffolds a project from the gem and
10
+ # drives `rake test` inside it, which is exactly the path a new user takes.
11
+
12
+ require "fileutils"
13
+ require "open3"
14
+ require "rbconfig"
15
+ require "stringio"
16
+ require "tmpdir"
17
+
18
+ require "rudy4"
19
+
20
+ $checks = 0
21
+ $failures = []
22
+
23
+ def assert(cond, msg)
24
+ $checks += 1
25
+ return if cond
26
+
27
+ $failures << msg
28
+ puts " FAIL: #{msg}"
29
+ end
30
+
31
+ def assert_equal(expected, actual, msg)
32
+ assert(expected == actual, "#{msg} (expected #{expected.inspect}, got #{actual.inspect})")
33
+ end
34
+
35
+ def section(name)
36
+ puts "\n== #{name}"
37
+ end
38
+
39
+ def run_cli(*argv)
40
+ out = StringIO.new
41
+ err = StringIO.new
42
+ status = Rudy4::CLI.new(argv, out: out, err: err).run
43
+ [status, out.string, err.string]
44
+ end
45
+
46
+ RUBY = RbConfig.ruby
47
+
48
+ # ---------------------------------------------------------------------------
49
+ section "CLI: version and help"
50
+
51
+ status, out, = run_cli("--version")
52
+ assert_equal 0, status, "--version exit status"
53
+ assert_equal Rudy4::VERSION, out.strip, "--version prints the version"
54
+
55
+ status, out, = run_cli("help")
56
+ assert_equal 0, status, "help exit status"
57
+ assert(out.include?("rudy4 init NAME"), "help documents init")
58
+ assert(out.include?("rudy4 doctor"), "help documents doctor")
59
+ assert(out.include?("rake compile"), "help shows the getting-started loop")
60
+
61
+ status, out, = run_cli(nil)
62
+ assert_equal 0, status, "bare invocation prints usage and exits 0"
63
+ assert(out.include?("Usage:"), "bare invocation prints usage")
64
+
65
+ status, _out, err = run_cli("frobnicate")
66
+ assert_equal Rudy4::CLI::EXIT_USAGE, status, "unknown command is a usage error"
67
+ assert(err.include?("unknown command"), "unknown command is reported")
68
+
69
+ status, _out, err = run_cli("init", "whatever", "--quick")
70
+ assert_equal Rudy4::CLI::EXIT_USAGE, status, "--quick is rejected for init"
71
+ assert(err.include?("not valid"), "invalid option is reported")
72
+
73
+ # ---------------------------------------------------------------------------
74
+ section "init: scaffold contents"
75
+
76
+ Dir.mktmpdir("rudy4-gem-test-") do |tmp|
77
+ status, out, err = run_cli("init", "fast_math", "-h")
78
+ assert_equal 0, status, "init -h prints usage"
79
+
80
+ status, out, err = run_cli("init", "fast_math")
81
+ assert_equal 0, status, "init exits 0 (err: #{err})"
82
+
83
+ root = File.join(tmp, "fast_math")
84
+
85
+ # The generator ran in Dir.pwd, so move it into the tmpdir to keep $PWD clean.
86
+ # (CLI uses Dir.pwd by design, so we exercise it there and clean up after.)
87
+ expected = [
88
+ "fast_math.gemspec",
89
+ "Rakefile",
90
+ "README.md",
91
+ ".gitignore",
92
+ "lib/fast_math.rb",
93
+ "lib/fast_math/version.rb",
94
+ "ext/fast_math/fast_math.d",
95
+ "ext/fast_math/extconf.rb",
96
+ "ext/fast_math/rudy/defs.d",
97
+ "ext/fast_math/rudy/bind/value.d",
98
+ "ext/fast_math/rudy/convert.d",
99
+ "ext/fast_math/rudy/args.d",
100
+ "ext/fast_math/rudy/objproto.d",
101
+ "ext/fast_math/rudy/typeddata.d",
102
+ "test/fast_math_test.rb",
103
+ ]
104
+ expected.each do |rel|
105
+ assert(File.file?(File.join("fast_math", rel)), "generated #{rel}")
106
+ end
107
+
108
+ assert(out.include?("Created fast_math/"), "init reports what it created")
109
+ assert(out.include?("rake compile"), "init prints the next steps")
110
+
111
+ # No leftover template placeholders anywhere.
112
+ Dir.glob("fast_math/**/*").select { |f| File.file?(f) }.each do |file|
113
+ body = File.read(file)
114
+ %w[PROJECT_NAME PROJECT_CONST RUBY_REQ AUTHOR EMAIL].each do |token|
115
+ assert(!body.include?(token), "#{file} has no unresolved #{token} placeholder")
116
+ end
117
+ end
118
+
119
+ # The extension name must be spliced consistently: Init_<name>_ext, module,
120
+ # gem name, and the artifact name are all the same project.
121
+ d_source = File.read("fast_math/ext/fast_math/fast_math.d")
122
+ assert(d_source.include?('pragma(mangle, "Init_fast_math_ext")'), "Init_ entry point named after the project")
123
+ assert(d_source.include?('module fast_math;'), "D module named after the project")
124
+ assert(d_source.include?('rb_define_module("FastMath")'), "Ruby module is the camelized name")
125
+ assert(d_source.include?('def!("FastMath", add)'), "def! targets the Ruby module")
126
+
127
+ extconf = File.read("fast_math/ext/fast_math/extconf.rb")
128
+ assert(extconf.include?("TARGET = fast_math_ext."), "artifact is <name>_ext, not <name>")
129
+ assert(extconf.include?('RUDY_SRC = File.join(HERE, "rudy")'),
130
+ "extconf uses the vendored rudy sources, not a checkout path")
131
+ assert(!extconf.include?("RUDY4_ROOT"), "extconf does not need RUDY4_ROOT")
132
+
133
+ gemspec = File.read("fast_math/fast_math.gemspec")
134
+ assert(gemspec.include?('spec.extensions = ["ext/fast_math/extconf.rb"]'),
135
+ "gemspec declares the D extension")
136
+
137
+ lib_rb = File.read("fast_math/lib/fast_math.rb")
138
+ assert(lib_rb.include?('require "fast_math_ext"'), "lib requires the _ext artifact")
139
+
140
+ FileUtils.rm_rf("fast_math")
141
+
142
+ # -------------------------------------------------------------------------
143
+ section "init: argument validation"
144
+
145
+ status, _out, err = run_cli("init")
146
+ assert_equal Rudy4::CLI::EXIT_USAGE, status, "init without a name is a usage error"
147
+ assert(err.include?("usage: rudy4 init NAME"), "missing name shows usage")
148
+
149
+ status, _out, err = run_cli("init", "fast-math")
150
+ assert_equal Rudy4::CLI::EXIT_ERROR, status, "hyphenated name is rejected"
151
+ assert(err.include?("snake_case"), "hyphenated name explains the rule")
152
+
153
+ status, _out, err = run_cli("init", "FastMath")
154
+ assert_equal Rudy4::CLI::EXIT_ERROR, status, "capitalized name is rejected"
155
+
156
+ status, _out, err = run_cli("init", "ruby")
157
+ assert_equal Rudy4::CLI::EXIT_ERROR, status, "reserved name is rejected"
158
+ assert(err.include?("reserved"), "reserved name explains why")
159
+
160
+ status, _out, err = run_cli("init", "fast_math", "extra")
161
+ assert_equal Rudy4::CLI::EXIT_USAGE, status, "extra arguments are rejected"
162
+
163
+ # -------------------------------------------------------------------------
164
+ section "init: existing directory handling"
165
+
166
+ Dir.mktmpdir("rudy4-existing-") do |dir|
167
+ FileUtils.mkdir_p(File.join(dir, "taken"))
168
+ File.write(File.join(dir, "taken", "precious.txt"), "do not clobber")
169
+
170
+ generator = Rudy4::Generator.new("taken", parent_dir: dir, out: StringIO.new)
171
+ begin
172
+ generator.call
173
+ assert(false, "refuses to overwrite a non-empty directory")
174
+ rescue Rudy4::Error => e
175
+ assert(e.message.include?("already exists"), "existing directory is reported")
176
+ assert(e.message.include?("--force"), "the error suggests --force")
177
+ end
178
+
179
+ assert_equal "do not clobber", File.read(File.join(dir, "taken", "precious.txt")),
180
+ "existing files are untouched"
181
+
182
+ forced = Rudy4::Generator.new("taken", parent_dir: dir, force: true, out: StringIO.new)
183
+ forced.call
184
+ assert(File.file?(File.join(dir, "taken", "taken.gemspec")), "--force writes into the directory")
185
+ assert(File.file?(File.join(dir, "taken", "precious.txt")), "--force does not delete existing files")
186
+ end
187
+
188
+ # -------------------------------------------------------------------------
189
+ section "vendor: refresh an existing project"
190
+
191
+ status, _out, err = run_cli("init", "vendored_app")
192
+ assert_equal 0, status, "scaffold a project for the vendor test (err: #{err})"
193
+
194
+ ext_dir = File.join("vendored_app", "ext", "vendored_app")
195
+ sentinel = File.join(ext_dir, "rudy", "defs.d")
196
+ File.write(sentinel, "// stale, hand-mangled copy")
197
+ File.write(File.join(ext_dir, "vendored_app.d"), "// user code — must not be touched")
198
+
199
+ Dir.chdir(ext_dir) do
200
+ status, out, err = run_cli("vendor")
201
+ assert_equal 0, status, "vendor exits 0 (err: #{err})"
202
+ assert(out.include?("Refreshed"), "vendor reports what it refreshed")
203
+ end
204
+
205
+ assert(!File.read(sentinel).include?("stale"), "vendor overwrites the stale vendored source")
206
+ assert_equal Rudy4::Vendor.d_sources.length,
207
+ Dir.glob(File.join(ext_dir, "rudy", "**", "*.d")).length,
208
+ "vendor restores every vendored source"
209
+ assert_equal "// user code — must not be touched",
210
+ File.read(File.join(ext_dir, "vendored_app.d")),
211
+ "vendor leaves user code alone"
212
+
213
+ FileUtils.rm_rf("vendored_app")
214
+
215
+ # -------------------------------------------------------------------------
216
+ section "doctor: quick checks"
217
+
218
+ Dir.mktmpdir("rudy4-doctor-") do |dir|
219
+ Dir.chdir(dir) do
220
+ out = StringIO.new
221
+ status = Rudy4::Doctor.new(quick: true, out: out).call
222
+ text = out.string
223
+
224
+ assert(text.include?("rudy4 doctor"), "doctor prints a header")
225
+ assert(text.include?("ruby headers"), "doctor checks ruby headers")
226
+ assert(text.include?("shared libruby"), "doctor checks the shared library")
227
+ assert(text.include?("gdc (D compiler)"), "doctor checks GDC")
228
+ assert(text.include?("vendored rudy4 sources"), "doctor checks the vendored sources")
229
+ assert(!text.include?("build smoke test"), "--quick skips the build probe")
230
+
231
+ gdc_ok = !text.include?("[fail] gdc")
232
+ headers_ok = !text.include?("[fail] ruby headers")
233
+ if gdc_ok && headers_ok
234
+ assert_equal 0, status, "doctor exits 0 on a healthy toolchain"
235
+ else
236
+ assert_equal 1, status, "doctor exits 1 when the toolchain is missing"
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ # ---------------------------------------------------------------------------
243
+ if ENV["RUDY4_FULL"] == "1"
244
+ section "end-to-end: scaffold, GDC build, generated test suite (slow)"
245
+
246
+ Dir.mktmpdir("rudy4-e2e-") do |dir|
247
+ Dir.chdir(dir) do
248
+ status, _out, err = run_cli("init", "fast_math")
249
+ assert_equal 0, status, "init for the e2e build (err: #{err})"
250
+
251
+ # The exact path a new user runs.
252
+ out, build_status = Open3.capture2e({ "GDC" => ENV["GDC"] || "gdc-14" },
253
+ "rake", "test",
254
+ chdir: File.join(dir, "fast_math"))
255
+ puts out
256
+ assert(build_status.success?, "rake test succeeds in the generated project")
257
+
258
+ lib = File.join(dir, "fast_math", "lib", "fast_math_ext.#{RbConfig::CONFIG['DLEXT']}")
259
+ assert(File.file?(lib), "rake compile installs the artifact into lib/")
260
+
261
+ probe, probe_status = Open3.capture2e(
262
+ RUBY, "-I#{File.dirname(lib)}", "-e",
263
+ 'require "fast_math"; print FastMath.squares(3).inspect',
264
+ )
265
+ assert_equal "[0.0, 1.0, 4.0]", probe.strip, "the built extension is loadable and correct"\
266
+ if probe_status.success?
267
+ assert(probe_status.success?, "loading the built extension succeeds")
268
+
269
+ # `rake` + no build residue tracked by the gem's own .gitignore.
270
+ gitignore = File.read(File.join(dir, "fast_math", ".gitignore"))
271
+ assert(gitignore.include?("/lib/*.so"), ".gitignore covers built artifacts")
272
+
273
+ # And the doctor, end to end, inside the generated project.
274
+ out = StringIO.new
275
+ status = Rudy4::Doctor.new(out: out).call
276
+ puts out.string
277
+ assert_equal 0, status, "doctor reports a healthy setup inside a generated project"
278
+ end
279
+ end
280
+ end
281
+
282
+ # ---------------------------------------------------------------------------
283
+ puts
284
+ if $failures.empty?
285
+ puts "rudy4 gem tests: #{$checks} assertions OK"
286
+ exit 0
287
+ else
288
+ puts "rudy4 gem tests: #{$checks} assertions, #{$failures.length} FAILED"
289
+ $failures.each { |f| puts " - #{f}" }
290
+ exit 1
291
+ end
data/test/run.rb ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # Acceptance suite for rudy-d's dextest extension (dexter revival, 2010 -> 2026).
3
+
4
+ require_relative "dextest_loader"
5
+
6
+ $failures = 0
7
+ $count = 0
8
+
9
+ def assert(cond, msg)
10
+ $count += 1
11
+ if cond
12
+ puts "ok #{$count} - #{msg}"
13
+ else
14
+ $failures += 1
15
+ puts "FAIL #{$count} - #{msg}"
16
+ end
17
+ end
18
+
19
+ def assert_equal(expected, actual, msg = nil)
20
+ assert(expected == actual, "#{msg || 'equal'}: expected #{expected.inspect}, got #{actual.inspect}")
21
+ end
22
+
23
+ def assert_raises(exp)
24
+ yield
25
+ assert(false, "expected #{exp} to be raised")
26
+ rescue exp
27
+ assert(true, "raised #{exp}")
28
+ rescue => e
29
+ assert(false, "expected #{exp}, got #{e.class}")
30
+ end
31
+
32
+ # ---------------------------------------------------------------- raw C API class
33
+ d = DexterClass.new
34
+ assert_equal(Class, DexterClass.class, "DexterClass is a class")
35
+ assert_equal(Object, DexterClass.superclass, "DexterClass < Object")
36
+ assert_equal(10, d.return_ten, "return_ten")
37
+ assert_equal([], d.arr, "arr starts empty")
38
+ d.add(1); d.add(2)
39
+ assert_equal([1, 2], d.arr, "add pushes raw VALUEs")
40
+ d.native_add(22)
41
+ assert_equal([1, 2, 23], d.arr, "native_add converts via num2long/ll2inum")
42
+ d.add_strings_first_letter("showtime!")
43
+ assert_equal([1, 2, 23, "s"], d.arr, "add_strings_first_letter")
44
+
45
+ # ---------------------------------------------------------------- def! globals
46
+ assert_equal(15, the_new_fifteen, "def! global function")
47
+ assert_equal(33, convert_integer_two_ways(33), "def! integer round trip")
48
+ assert_equal(45.67, convert_float_two_ways(45.67), "def! float round trip")
49
+ assert_equal("somestring", convert_string_two_ways("somestring"), "def! string round trip")
50
+ assert_equal(true, convert_bool_two_ways(true), "def! bool true round trip")
51
+ assert_equal(false, convert_bool_two_ways(false), "def! bool false round trip")
52
+ assert_equal(17, add_five(12), "def! computed int")
53
+ assert_equal("hi tomash 42", DexterModule.greet("tomash", 42), "def! module function, 2 args, mixed types")
54
+
55
+ # ---------------------------------------------------------------- issue #1 regression: ulong params decode, Raw!VALUE passes bits
56
+ assert_equal(10, identity_ulong(10), "def! ulong param decodes (not tagged)")
57
+ assert_equal(0, identity_ulong(0), "def! ulong zero decodes")
58
+ assert_equal(20, double_it_ulong(10), "def! ulong param arithmetic")
59
+ assert_equal(10, raw_value_id(10), "def! Raw!VALUE passthrough identity")
60
+ assert_equal("abc", raw_value_id("abc"), "def! Raw!VALUE passthrough string object")
61
+
62
+ # ---------------------------------------------------------------- arity / errors
63
+ assert_raises(ArgumentError) { convert_integer_two_ways } # wrong arity
64
+ assert_raises(TypeError) { convert_integer_two_ways("nope") } # bad conversion
65
+
66
+ # ---------------------------------------------------------------- results
67
+ puts
68
+ if $failures.zero?
69
+ puts "PASS: all #{$count} assertions green"
70
+ exit 0
71
+ else
72
+ puts "FAIL: #{$failures}/#{$count} assertions failed"
73
+ exit 1
74
+ end
data/test/typedtest.rb ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+ # Acceptance suite for the typed-data extension (rudy/typeddata.d).
3
+ # Exercises: wrap/unwrap via TypedData API (rb_data_typed_object_wrap /
4
+ # rb_check_typeddata), alloc func, det, GC stress, TypeError on bad unwrap.
5
+
6
+ require "pathname"
7
+ EXT = Pathname.new(__dir__).parent + ".build" + "dtypetest.so"
8
+ raise "extension not built: run `make` first" unless EXT.exist?
9
+ require EXT.to_s
10
+
11
+ $failures = 0
12
+ $count = 0
13
+
14
+ def assert(cond, msg)
15
+ $count += 1
16
+ if cond then puts "ok #{$count} - #{msg}"
17
+ else $failures += 1; puts "FAIL #{$count} - #{msg}" end
18
+ end
19
+
20
+ def assert_equal(expected, actual, msg = nil)
21
+ assert(expected == actual, "#{msg || 'equal'}: expected #{expected.inspect}, got #{actual.inspect}")
22
+ end
23
+
24
+ def assert_raises(exp)
25
+ yield
26
+ assert(false, "expected #{exp} to be raised")
27
+ rescue exp
28
+ assert(true, "raised #{exp}")
29
+ rescue => e
30
+ assert(false, "expected #{exp}, got #{e.class}: #{e.message}")
31
+ end
32
+
33
+ # ---------------------------------------------------------------- basics
34
+ m = DTypeMatrix.new(3, 3)
35
+ assert_equal(9, DTypeMatrix.allocate.unwrap_other(m), "typed wrap/unwrap round trip")
36
+ [[2, 0, 1], [0, 3, 0], [1, 0, 2]].each_with_index do |row, r|
37
+ row.each_with_index { |v, c| m[r, c] = v }
38
+ end
39
+ assert_equal([[2, 0, 1], [0, 3, 0], [1, 0, 2]], m.to_a, "to_a returns matrix contents")
40
+ assert((m.det - 9.0).abs < 1e-9, "3x3 det == 9.0 (Laplace, recursion)")
41
+ assert_equal(24, DTypeTest::SIZEOF, "sizeof Matrix struct exposed (2*8 + 8 == 24 on LP64)")
42
+
43
+ # 10x10 identity perturbation — deep det recursion
44
+ big = DTypeMatrix.new(10, 10)
45
+ 10.times { |r| 10.times { |c| big[r, c] = (r == c) ? 1.0 : 0.0 } }
46
+ big[3, 3] = 3.5
47
+ big[7, 0] = 2.0
48
+ assert((big.det - 3.5).abs < 1e-9, "10x10 identity det with [3,3]=3.5")
49
+
50
+ # ---------------------------------------------------------------- TypeError
51
+ assert_raises(TypeError) { DTypeMatrix.allocate.unwrap_other("not a matrix") }
52
+ assert_raises(TypeError) { DTypeMatrix.allocate.unwrap_other(42) }
53
+ assert_raises(TypeError) { DTypeMatrix.allocate.unwrap_other(DTypeMatrix) }
54
+
55
+ # ---------------------------------------------------------------- GC stress
56
+ before = `ps -o rss= -p #{Process.pid}`.to_i
57
+ m2 = DTypeMatrix.new(4, 4)
58
+ m2.stress(100_000)
59
+ after = `ps -o rss= -p #{Process.pid}`.to_i
60
+ assert(after - before < 50_000, "100k typed wrap/free cycles: RSS delta #{after - before} KB < 50 MB")
61
+
62
+ puts
63
+ if $failures.zero?
64
+ puts "PASS: all #{$count} assertions green"
65
+ exit 0
66
+ else
67
+ puts "FAIL: #{$failures}/#{$count} assertions failed"
68
+ exit 1
69
+ end
@@ -0,0 +1,253 @@
1
+ /// Parameter wrappers for `def!` (rudy/defs.d) that reach past the
2
+ /// plain positional-argument model. The 2010 RuDy had none of these;
3
+ /// a `def!`'d function could neither receive the caller's block nor
4
+ /// take rest/keyword arguments.
5
+ ///
6
+ /// void with_block(RubyBlock blk) { blk.call(42); } // receives the
7
+ /// // caller's block
8
+ /// long rest_sum(long first, Rest rest) { ... } // 1 + *rest
9
+ /// D str_kw(KwArgs kw) { kw.get!string("sep", "-") } // **kwargs
10
+ ///
11
+ /// All wrappers are VALUE-sized (passable as fixed-arity C args); the
12
+ /// interesting VALUE lives inside (block proc, rest array, kwargs
13
+ /// hash). See defs.d for how the trampolines populate them.
14
+
15
+ module rudy.args;
16
+
17
+ import rudy.bind.value;
18
+ import rudy.convert;
19
+ import std.string : toStringz;
20
+
21
+ // lazily-interned method names (rb_intern on every call is a symbol-table
22
+ // hash probe; the VM is alive inside any method call, so lazy init is safe)
23
+ private __gshared ID rudy_id_call;
24
+ private __gshared ID rudy_id_arity;
25
+ private __gshared ID rudy_id_length;
26
+ private __gshared ID rudy_id_to_a;
27
+ private __gshared ID rudy_id_to_s;
28
+
29
+ private ID id_call() { if (!rudy_id_call) rudy_id_call = rb_intern("call"); return rudy_id_call; }
30
+ private ID id_arity() { if (!rudy_id_arity) rudy_id_arity = rb_intern("arity"); return rudy_id_arity; }
31
+ private ID id_length() { if (!rudy_id_length) rudy_id_length = rb_intern("length"); return rudy_id_length; }
32
+ private ID id_to_a() { if (!rudy_id_to_a) rudy_id_to_a = rb_intern("to_a"); return rudy_id_to_a; }
33
+ private ID id_to_s() { if (!rudy_id_to_s) rudy_id_to_s = rb_intern("to_s"); return rudy_id_to_s; }
34
+
35
+ // ---------------------------------------------------------------- RubyBlock
36
+
37
+ /// A caller's block, materialized as a Proc. `def!` fills it in via
38
+ /// rb_scan_args' "&" slot inside the trampoline (Qnil when no block was
39
+ /// passed), before the D function runs.
40
+ ///
41
+ /// blk.call(args...) — invoke the block
42
+ /// blk.arity — the Proc's arity
43
+ /// blk.given — was a block actually passed?
44
+ ///
45
+ /// A block-less method raises LocalJumpError from Ruby the moment
46
+ /// call() runs — same failure mode as bare `yield` in Ruby. Check
47
+ /// `given` first when that must not happen.
48
+ struct RubyBlock
49
+ {
50
+ VALUE proc = Qnil_;
51
+
52
+ /// Was a block actually passed to this method?
53
+ bool given() const @safe pure nothrow @nogc { return proc != Qnil_; }
54
+
55
+ /// The wrapped Proc, as raw VALUE (for API-passing scenarios).
56
+ Raw!VALUE raw() const @safe pure nothrow @nogc { return Raw!VALUE(proc); }
57
+
58
+ /// Number of arguments the block expects (Proc#arity).
59
+ long arity() { return d_type!long(rb_funcallv(proc, id_arity(), 0, null)); }
60
+
61
+ /// Call the block with args; returns whatever the block returned.
62
+ /// LocalJumpError (from Ruby) if called with no block given; any
63
+ /// exception the block raises propagates straight through the D frame.
64
+ VALUE call() { return lje_or_call(proc, 0, null); }
65
+
66
+ VALUE call(VALUE a1)
67
+ {
68
+ VALUE[1] av = [a1];
69
+ return lje_or_call(proc, 1, av.ptr);
70
+ }
71
+
72
+ VALUE call(VALUE a1, VALUE a2)
73
+ {
74
+ VALUE[2] av = [a1, a2];
75
+ return lje_or_call(proc, 2, av.ptr);
76
+ }
77
+
78
+ VALUE call(VALUE a1, VALUE a2, VALUE a3)
79
+ {
80
+ VALUE[3] av = [a1, a2, a3];
81
+ return lje_or_call(proc, 3, av.ptr);
82
+ }
83
+
84
+ /// Call with a pre-built argv (n args).
85
+ VALUE callv(int n, const(VALUE)* argv)
86
+ {
87
+ return lje_or_call(proc, n, cast(VALUE*) argv);
88
+ }
89
+ }
90
+
91
+ /// RubyBlock#call contract helper: a block-less call is the same error
92
+ /// a bare `yield` with no block would give (rb_funcallv on Qnil would
93
+ /// give a misleading NoMethodError instead).
94
+ private VALUE lje_or_call(VALUE proc, int n, VALUE* argv)
95
+ {
96
+ if (proc == Qnil_)
97
+ rb_raise(rb_eLocalJumpError, "no block given (yield)");
98
+ return rb_funcallv(proc, id_call(), n, argv);
99
+ }
100
+
101
+ // ---------------------------------------------------------------- Self
102
+
103
+ /// The method's receiver (self), passed through raw. First-parameter
104
+ /// convention; lets def!'d functions inspect the object they were
105
+ /// called on (class, ivars, singleton-ness) without dropping to a
106
+ /// hand-written trampoline.
107
+ ///
108
+ /// long klass_name(Self self) { ... rb_obj_class(self.v) ... }
109
+ struct Self
110
+ {
111
+ VALUE v = Qnil_;
112
+
113
+ /// The receiver as raw VALUE.
114
+ Raw!VALUE raw() const @safe pure nothrow @nogc { return Raw!VALUE(v); }
115
+
116
+ /// implicit conversion so `self.v` and passing Self around stays terse
117
+ VALUE value() const @safe pure nothrow @nogc { return v; }
118
+ }
119
+
120
+ // ---------------------------------------------------------------- Rest
121
+
122
+ /// Captured `*rest` arguments, as a Ruby Array. The trampoline owns the
123
+ /// VALUE (GC-rooted for the call's duration); the D function sees this
124
+ /// slice-style wrapper.
125
+ struct Rest
126
+ {
127
+ VALUE ary = Qnil_;
128
+
129
+ /// Number of captured rest arguments (0 for an unused slot).
130
+ @property size_t length() const
131
+ {
132
+ if (ary == Qnil_)
133
+ return 0;
134
+ return cast(size_t) d_type!long(rb_funcallv(ary, id_length(), 0, null));
135
+ }
136
+
137
+ /// True when nothing was captured. NB: rb_scan_args' "*" slot fills
138
+ /// an EMPTY ARRAY (not Qnil) when the caller passes zero rest args —
139
+ /// this must agree with Ruby-side `rest.empty?`, so it checks the
140
+ /// length, not just the slot's Qnil-ness.
141
+ bool empty() { return length == 0; }
142
+
143
+ /// i-th captured argument (0-based).
144
+ VALUE opIndex(size_t i)
145
+ {
146
+ return rb_ary_entry(ary, cast(long) i);
147
+ }
148
+
149
+ /// Iterate the captured arguments (foreach over a Rest works).
150
+ int opApply(scope int delegate(ref VALUE) dg)
151
+ {
152
+ long n = cast(long) length;
153
+ foreach (i; 0 .. n)
154
+ {
155
+ VALUE v = rb_ary_entry(ary, i);
156
+ int r = dg(v);
157
+ if (r)
158
+ return r;
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ /// The raw Ruby Array, as raw VALUE.
164
+ Raw!VALUE raw() const @safe pure nothrow @nogc { return Raw!VALUE(ary); }
165
+
166
+ /// Convert every captured arg to D type T (allocates a D array).
167
+ T[] to(T)()
168
+ {
169
+ long n = cast(long) length;
170
+ T[] outv;
171
+ outv.length = cast(size_t) n;
172
+ foreach (i; 0 .. n)
173
+ outv[cast(size_t) i] = d_type!T(rb_ary_entry(ary, i));
174
+ return outv;
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------- KwArgs
179
+
180
+ /// Captured keyword arguments, as a Ruby Hash. `def!` fills it via
181
+ /// rb_scan_args' `:` slot (Qnil_ when no keywords were passed — see
182
+ /// `given`).
183
+ ///
184
+ /// kw.get!long("sep", 1) — decoded with a D default when absent
185
+ /// kw["sep"] — raw VALUE (Qnil_ when absent)
186
+ /// kw.to!(long, string)() — whole hash as a D AA
187
+ struct KwArgs
188
+ {
189
+ VALUE hash = Qnil_;
190
+
191
+ /// Were any keywords passed?
192
+ bool given() const @safe pure nothrow @nogc { return hash != Qnil_; }
193
+
194
+ /// Raw value of keyword `key` (Qnil_ when absent). Ruby keyword keys
195
+ /// are Symbols (`m(sep: x)`), but string keys are legal too
196
+ /// (`m("sep": x)` / string-keyed **hash) — probe both.
197
+ VALUE opIndex(string key)
198
+ {
199
+ if (!given)
200
+ return Qnil_;
201
+ ID id = rb_intern(toStringz(key));
202
+ VALUE v = rb_hash_lookup2(hash, rb_id2sym(id), Qnil_);
203
+ if (v != Qnil_)
204
+ return v;
205
+ VALUE[1] av = [rb_str_new(key.ptr, cast(C_long) key.length)];
206
+ return rb_hash_lookup2(hash, av[0], Qnil_);
207
+ }
208
+
209
+ /// Keyword decoded to D type T; `dflt` when the keyword was absent.
210
+ T get(T)(string key, T dflt)
211
+ {
212
+ VALUE v = opIndex(key);
213
+ if (v == Qnil_ || v == Qundef_)
214
+ return dflt;
215
+ return d_type!T(v);
216
+ }
217
+
218
+ /// All keywords as a D associative array (empty when none passed).
219
+ V[K] to(V, K)()
220
+ {
221
+ V[K] outv;
222
+ if (!given)
223
+ return outv;
224
+ // Hash#to_a -> [[k, v], ...]: plain exported-API walking, no
225
+ // callbacks (rb_block_call's return value can't accumulate).
226
+ VALUE pairs = rb_funcallv(hash, id_to_a(), 0, null);
227
+ long n = d_type!long(rb_funcallv(pairs, id_length(), 0, null));
228
+ foreach (i; 0 .. n)
229
+ {
230
+ VALUE pair = rb_ary_entry(pairs, i);
231
+ VALUE k = rb_ary_entry(pair, 0);
232
+ static if (is(K == string))
233
+ {
234
+ // kwargs keys are normally Symbols; d_type!string of a
235
+ // Symbol raises TypeError. Normalize through to_s
236
+ // (Symbol#to_s -> name String, String#to_s -> self) so
237
+ // string-keyed D AAs see every key. Non-string K keys
238
+ // go through d_type directly.
239
+ k = rb_funcallv(k, id_to_s(), 0, null);
240
+ }
241
+ static if (is(V == Raw!VALUE))
242
+ {
243
+ // raw VALUE escape hatch: bits unchanged, no decode
244
+ outv[d_type!K(k)] = Raw!VALUE(rb_ary_entry(pair, 1));
245
+ }
246
+ else
247
+ {
248
+ outv[d_type!K(k)] = d_type!V(rb_ary_entry(pair, 1));
249
+ }
250
+ }
251
+ return outv;
252
+ }
253
+ }