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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5491005c45ee1bc1515fe62dd7ed87d059a4383032f2e47b0597f739c27497d3
4
+ data.tar.gz: 478945a8c371a446853455f049162363a3e960d3a663ea4718b09e39e7b4a648
5
+ SHA512:
6
+ metadata.gz: b7f70024d16116739ed070e62c2988b7f45edeced059dd7669f085993bf4290b0d13bf894193206b6e508dcec6fc97c9eb2948f947923f030998601daadcc552
7
+ data.tar.gz: d24d6bbc27b3ff2e8ef6729474c77094cda12b04c7404cd8974c83490b3c215750b3a21fcf97a8e885fe50f77c92f9ea46435b27dea04d60f1470c75b2a91a23
data/CHANGELOG.md ADDED
@@ -0,0 +1,114 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - **The `rudy4` gem** (`rudy4.gemspec`, `lib/rudy4/`, `bin/rudy4`) — packages
14
+ rudy4 as a command, not a repo to clone. The gem ships the D library under
15
+ `vendor/rudy/` so a generated project builds with no rudy4 checkout and no
16
+ `RUDY4_ROOT`.
17
+ - `rudy4 init NAME` scaffolds a self-contained extension gem (gemspec,
18
+ `lib/`, `ext/NAME/` with the *vendored* `rudy/` sources, `Rakefile`,
19
+ `test/`) and prints the compile/test next steps.
20
+ - `rudy4 doctor` verifies the Ruby toolchain, headers, shared libruby and
21
+ GDC — then proves the setup by scaffolding and compiling a throwaway
22
+ extension, so failures name the missing piece. Untested Ruby majors and
23
+ pre-13 GDC are warnings, not refusals.
24
+ - `rudy4 vendor` re-copies the D library into an existing project after a
25
+ gem upgrade (`src/rudy/` stays the source of truth).
26
+ - `scripts/vendor_sync.sh` / `scripts/vendor_check.sh` — keep `vendor/rudy/`
27
+ in step with `src/rudy/`; CI fails on drift.
28
+ - `test/rudy4_test.rb` — the gem's suite (136 assertions: scaffold contents,
29
+ argument validation, `--force`, vendor refresh, doctor
30
+ pass/fail). `RUDY4_FULL=1` additionally scaffolds a project and drives
31
+ `rake test` inside it with GDC (143 assertions, `make gem-test-full`).
32
+ - `make gem-test` / `gem-test-full` / `vendor` / `vendor-check` / `gem-build`.
33
+
34
+ ### Fixed
35
+
36
+ - The scaffolded template no longer demonstrates array *parameters*
37
+ (`double[] xs`) — `d_type` has no array/AA decode path, so that template
38
+ would have failed to compile. The generated D file now uses the paths that
39
+ are actually supported: D default values for optional arguments, `Rest` for
40
+ Ruby `*args`, and a dynamic-array *return* (`double[] squares(long n)`),
41
+ alongside `long`/`string`/`bool`.
42
+
43
+ ## [0.1.1] - 2026-10-13
44
+
45
+ ### Added
46
+
47
+ - **`exp_nonregression`** (`experiments/exp_nonregression.d` +
48
+ `test_exp_nonregression.rb`, 20 assertions) — evidence for the three
49
+ documented non-goals: Fiber switches through D frames, Ractor
50
+ non-shareability (asserted VM refusal), and interruptibility of a long
51
+ GVL-held D call vs the GVL-released variant. Findings #25–#27 in
52
+ `experiments/NOTES.md`.
53
+ - **README "Known limitations (non-goals)"** section and a matching
54
+ `experiments/NOTES.md` section, so the boundaries are stated up front
55
+ instead of being discoverable only by reading the experiment notes.
56
+
57
+ ## [0.1.0] - 2026-10-13
58
+
59
+ First tagged release: rudy4 revival targeting Ruby 4.0 and GDC 14.
60
+
61
+ ### Added
62
+
63
+ - **`def!`** — register a D function as a Ruby method with automatic
64
+ argument conversion (`src/rudy/defs.d`, `src/rudy/convert.d`).
65
+ - Variadic `def!` with D default arguments → Ruby optional arguments.
66
+ - Blocks (`RubyBlock`), `*rest` (`Rest`), `**kwargs` (`KwArgs`),
67
+ `Self` receiver, `def_private!` / `def_protected!` /
68
+ `def_singleton!` / `def_singleton_on!`.
69
+ - **`RudyTypedData`** (`src/rudy/typeddata.d`) — wrap D structs as Ruby
70
+ objects, with GC mark + compaction callbacks (`rb_gc_mark_movable` /
71
+ `rb_gc_location`).
72
+ - **`RudyObjectProtocol`** (`src/rudy/objproto.d`) — companion mixin for
73
+ wrapped structs: `allocate`, `initialize_copy` (dup/clone, frozen
74
+ propagation), `==`/`eql?`/`hash`, `inspect`, and mutator freeze guards.
75
+ - **Hand-written Ruby 4.0 C-API binding** (`src/rudy/bind/value.d`),
76
+ ~220 lines: value/type checks, strings, arrays, hashes, symbols, blocks,
77
+ structured exceptions (`rb_rescue`/`rb_ensure`/`rb_protect`), per-object
78
+ methods, GVL release (`rb_thread_call_without_gvl`).
79
+ - **`examples/hello_gem/`** — a complete gem-shaped extension showing how to
80
+ actually ship a D-backed gem: `*.gemspec`, `ext/<name>/extconf.rb`,
81
+ `lib/`, and packaging/installation instructions.
82
+ - **Experiments** (`experiments/`) — ten torture suites / 432 assertions:
83
+ wrap, cross, perf, http, gcstorm, exc, compact, convert, defargs,
84
+ objproto. Their findings are catalogued in `experiments/NOTES.md`
85
+ (24 findings).
86
+ - **CI** (`.github/workflows/ci.yml`) — GitHub Actions on ubuntu-24.04:
87
+ gdc-14 + Ruby 4.0.7 built from source (`--enable-shared`), runs
88
+ `make test`, `make exp-test`, and the ABI audit.
89
+ - **ABI conformance audit** (`scripts/abi_audit.sh`) — `nm -D libruby` vs
90
+ the `extern(C)` declarations in `src/rudy/bind/value.d`; 137 bound
91
+ symbols, 0 misbinds.
92
+ - **Memory-safety runs** (`make memcheck`, `scripts/memcheck.sh`) —
93
+ AddressSanitizer subset of the experiment suites (`.build.asan/`, driven
94
+ by `RUDY_BUILD_DIR`). Two hard gdc-14 limitations documented
95
+ (`experiments/NOTES.md` #23/#24).
96
+ - **LICENSE** (MIT) and this changelog.
97
+
98
+ ### Known limitations
99
+
100
+ See `experiments/NOTES.md` for the full catalogue. Highlights:
101
+
102
+ - gdc-14 AddressSanitizer SEGVs on any explicit druntime `rt_init()` (#23);
103
+ only the `rt_init`-free experiments run under ASan.
104
+ - `exp_http` aborts under ASan with `Failed to munmap` (#24) — built but not
105
+ run.
106
+ - GDC 14 rejects `&fn` as a template value argument in hook positions —
107
+ pass hooks as ALIAS parameters with bare symbol names (#22).
108
+ - Forking after a D-runtime-initialized `.so` is loaded is unsafe — use a
109
+ subprocess re-exec for crash-witness tests.
110
+
111
+ [Unreleased]: https://github.com/tomash/rudy4/compare/v0.2.0...HEAD
112
+ [0.2.0]: https://github.com/tomash/rudy4/releases/tag/v0.2.0
113
+ [0.1.1]: https://github.com/tomash/rudy4/compare/v0.1.0...v0.1.1
114
+ [0.1.0]: https://github.com/tomash/rudy4/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2010, 2026 Tomasz Stachewicz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/Makefile ADDED
@@ -0,0 +1,137 @@
1
+ # RuDy — Ruby native extensions in D (2026 revival)
2
+ #
3
+ # Toolchain: gdc-14 (GCC 14.2), Ruby 4.0 via asdf on this machine.
4
+ # Override on the command line if your setup differs:
5
+ # make RUBY=~/.asdf/installs/ruby/4.0.7/bin/ruby GDC=gdc
6
+
7
+ RUBY ?= $(HOME)/.asdf/installs/ruby/4.0.7/bin/ruby
8
+ GDC ?= gdc-14
9
+
10
+ RUBYHDR := $(shell $(RUBY) -e 'print RbConfig::CONFIG["rubyhdrdir"]')
11
+ RUBYARCHHDR := $(shell $(RUBY) -e 'print RbConfig::CONFIG["rubyarchhdrdir"]')
12
+ LIBRUBYARG := $(shell $(RUBY) -e 'print RbConfig::CONFIG["LIBRUBYARG_SHARED"]')
13
+ LIBRUBY_DIR := $(shell $(RUBY) -e 'print RbConfig::CONFIG["libdir"]')
14
+
15
+ BUILD ?= .build
16
+ # ASan variant: `make ASAN=1 ...` or `make memcheck` builds into .build.asan
17
+ # with -fsanitize=address. Note (experiments/NOTES.md #23): druntime rt_init
18
+ # SEGVs under gdc-14 ASan, so only the rt_init-free experiments
19
+ # (wrap/cross/perf/exc/defargs/http) are meaningful this way.
20
+ ifeq ($(ASAN),1)
21
+ BUILD := .build.asan
22
+ DFLAGS += -fsanitize=address
23
+ endif
24
+
25
+ DFLAGS := -fPIC -Isrc -I$(RUBYHDR) -I$(RUBYARCHHDR) \
26
+ -fpreview=dip1008 \
27
+ -Wno-deprecated
28
+
29
+ DC_SRCS := $(wildcard src/rudy/*.d src/rudy/bind/*.d)
30
+
31
+ EXP_SRCS := exp_wrap exp_cross exp_perf exp_http exp_gcstorm exp_exc exp_compact exp_convert exp_defargs exp_objproto exp_nonregression
32
+
33
+ # rt_init-free subset that works under ASan (NOTES.md #23/#24).
34
+ ASAN_EXP_SRCS := exp_wrap exp_cross exp_perf exp_exc exp_defargs exp_http
35
+
36
+ .PHONY: all clean test check typed-test exp exp-test exp-asan exp-asan-test memcheck vendor vendor-check gem-test gem-test-full gem-build
37
+
38
+ all: $(BUILD)/dextest.so $(BUILD)/dexitest.so $(BUILD)/dtypetest.so
39
+
40
+ # One shared object: the rudy library modules + the extension entry point.
41
+ $(BUILD)/dextest.so: $(DC_SRCS) src/dextest.d | $(BUILD)
42
+ $(GDC) -shared -o $@ $(DFLAGS) $(DC_SRCS) src/dextest.d \
43
+ -L$(LIBRUBY_DIR) $(LIBRUBYARG) -lm
44
+
45
+ # Missing-API bindings extension: yield/rescue/ensure/singleton/scan_args/
46
+ # GVL bindings + variadic def! (D default args -> Ruby optional args).
47
+ $(BUILD)/dexitest.so: $(DC_SRCS) src/dexitest.d | $(BUILD)
48
+ $(GDC) -shared -o $@ $(DFLAGS) $(DC_SRCS) src/dexitest.d \
49
+ -L$(LIBRUBY_DIR) $(LIBRUBYARG) -lm
50
+
51
+ # TypedData extension: rudy library + typeddata helper + dtypetest entry point.
52
+ $(BUILD)/dtypetest.so: $(DC_SRCS) src/dtypetest.d | $(BUILD)
53
+ $(GDC) -shared -o $@ $(DFLAGS) $(DC_SRCS) src/dtypetest.d \
54
+ -L$(LIBRUBY_DIR) $(LIBRUBYARG) -lm
55
+
56
+ $(BUILD):
57
+ mkdir -p $(BUILD)
58
+
59
+ # Experiments: each experiment .d becomes its own .so
60
+ $(BUILD)/exp_%.so: experiments/exp_%.d $(DC_SRCS) | $(BUILD)
61
+ $(GDC) -shared -o $@ $(DFLAGS) $(DC_SRCS) $< \
62
+ -L$(LIBRUBY_DIR) $(LIBRUBYARG) -lm
63
+
64
+ exp: $(addprefix $(BUILD)/,$(EXP_SRCS:=.so))
65
+
66
+ exp-test: exp
67
+ $(RUBY) experiments/test_exp_wrap.rb
68
+ $(RUBY) experiments/test_exp_cross.rb
69
+ $(RUBY) experiments/test_exp_perf.rb
70
+ $(RUBY) experiments/test_exp_http.rb
71
+ $(RUBY) experiments/test_exp_gcstorm.rb
72
+ $(RUBY) experiments/test_exp_exc.rb
73
+ $(RUBY) experiments/test_exp_compact.rb
74
+ $(RUBY) experiments/test_exp_convert.rb
75
+ $(RUBY) experiments/test_exp_defargs.rb
76
+ $(RUBY) experiments/test_exp_objproto.rb
77
+ $(RUBY) experiments/test_exp_nonregression.rb
78
+
79
+ ASAN_PRELOAD := LD_PRELOAD=$(shell $(GDC) -print-file-name=libasan.so)
80
+ # detect_odr_violation=0: wrap/cross/perf .so's each embed a copy of the rudy
81
+ # library also present in dextest.so (loaded by the suite loader) — ASan would
82
+ # otherwise abort on the duplicate registered globals.
83
+ ASAN_OPTS := ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:allocator_may_return_null=1:detect_odr_violation=0
84
+
85
+ # ASan subset of the experiment suites (NOTES.md #23/#24). Builds lib +
86
+ # dextest.so + the rt_init-free exp .so's into .build.asan, then runs the
87
+ # corresponding test files under ruby with LD_PRELOAD=libasan.so.
88
+ # http is built but NOT run (NOTES.md #24: munmap crash, under investigation).
89
+ exp-asan-test:
90
+ $(MAKE) ASAN=1 --no-print-directory $(addprefix .build.asan/,$(ASAN_EXP_SRCS:=.so)) .build.asan/dextest.so
91
+ $(ASAN_PRELOAD) $(ASAN_OPTS) RUDY_BUILD_DIR=.build.asan $(RUBY) experiments/test_exp_wrap.rb
92
+ $(ASAN_PRELOAD) $(ASAN_OPTS) RUDY_BUILD_DIR=.build.asan $(RUBY) experiments/test_exp_cross.rb
93
+ $(ASAN_PRELOAD) $(ASAN_OPTS) RUDY_BUILD_DIR=.build.asan $(RUBY) experiments/test_exp_perf.rb
94
+ $(ASAN_PRELOAD) $(ASAN_OPTS) RUDY_BUILD_DIR=.build.asan $(RUBY) experiments/test_exp_exc.rb
95
+ $(ASAN_PRELOAD) $(ASAN_OPTS) RUDY_BUILD_DIR=.build.asan $(RUBY) experiments/test_exp_defargs.rb
96
+
97
+ exp-asan: exp-asan-test
98
+
99
+ # Memory-safety torture runs (backlog item 7). Local: ASan subset above;
100
+ # valgrind not installed locally (CI-only). Excluded locally: http (NOTES.md
101
+ # #24 munmap crash, under investigation); gcstorm/compact/convert/objproto
102
+ # (NOTES.md #23 rt_init SEGV).
103
+ memcheck: exp-asan-test
104
+ @echo "memcheck: ASan subset complete (see experiments/NOTES.md #23/#24)"
105
+
106
+ # Ruby-side acceptance suite (dexter revival + missing-API bindings)
107
+ test: all
108
+ $(RUBY) test/run.rb
109
+ $(RUBY) test/exitest.rb
110
+ $(RUBY) test/typedtest.rb
111
+
112
+ check: test
113
+
114
+ # --- the rudy4 gem ----------------------------------------------------------
115
+ #
116
+ # vendor/rudy/ is a copy of src/rudy/ shipped inside the gem; `vendor-check`
117
+ # fails if the two have drifted (CI runs it), `gem-test` covers the CLI, and
118
+ # `gem-test-full` also scaffolds + compiles a project with GDC.
119
+ $(BUILD)/vendor-synced: $(DC_SRCS)
120
+ @bash scripts/vendor_sync.sh
121
+
122
+ vendor: $(BUILD)/vendor-synced
123
+
124
+ vendor-check:
125
+ @bash scripts/vendor_check.sh
126
+
127
+ gem-test:
128
+ $(RUBY) -Ilib test/rudy4_test.rb
129
+
130
+ gem-test-full:
131
+ RUDY4_FULL=1 $(RUBY) -Ilib test/rudy4_test.rb
132
+
133
+ gem-build: vendor-check
134
+ gem build rudy4.gemspec
135
+
136
+ clean:
137
+ rm -rf .build .build.asan
data/README.md ADDED
@@ -0,0 +1,400 @@
1
+ # rudy4
2
+
3
+ **Write Ruby native extensions in D.** A from-scratch rewrite of
4
+ [rudy](https://github.com/tomash/rudy) (2010), targeting **Ruby 4.0** and
5
+ **GDC 14 (D front-end 2.10x)**.
6
+
7
+ The 2010 original bound Ruby 1.8's C API via a bcd.gen-generated `ruby.d`,
8
+ required a separate `meta/` template-metaprogramming library, and built with
9
+ the long-dead `dsss` tool. This repo keeps the one idea worth keeping —
10
+ `def!` — and drops everything else.
11
+
12
+ ## The idea: `def!`
13
+
14
+ Register a D function as a Ruby method with automatic argument conversion:
15
+
16
+ ```d
17
+ module myext;
18
+
19
+ import rudy.defs;
20
+
21
+ long add_five(long n) { return n + 5; }
22
+
23
+ pragma(mangle, "Init_myext")
24
+ extern(C) void Init_myext()
25
+ {
26
+ def!(add_five); // global function (on Object)
27
+ def!("MyClass", add_five); // instance method of MyClass
28
+ }
29
+ ```
30
+
31
+ From Ruby:
32
+
33
+ ```ruby
34
+ require "myext"
35
+ add_five(37) # => 42
36
+ ```
37
+
38
+ Compile the extension into a `myext.so` Ruby can `require` (here `myext.d`
39
+ lives next to a `src/` tree with the library):
40
+
41
+ ```sh
42
+ gdc -shared -o myext.so \
43
+ -fPIC -Isrc \
44
+ $(ruby -e 'print RbConfig::CONFIG["rubyhdrdir"]') \
45
+ $(ruby -e 'print RbConfig::CONFIG["rubyarchhdrdir"]') \
46
+ src/rudy/*.d src/rudy/bind/*.d myext.d \
47
+ $(ruby -e 'print RbConfig::CONFIG["LIBRUBYARG_SHARED"]') \
48
+ -Wl,-rpath,$(ruby -e 'print RbConfig::CONFIG["libdir"]') -lm
49
+ ```
50
+
51
+ or just `make` with this repo's Makefile, which does the same (header dirs,
52
+ `libruby` and rpath are pulled from `RbConfig`), builds `dextest.so` from
53
+ `src/dextest.d`, and `make test` runs the acceptance suite.
54
+
55
+ Arguments arrive as raw `VALUE`s and are converted to the declared D
56
+ parameter types (`long`, `double`, `string`, `bool`, ...); the return
57
+ value is converted back. No manual `rb_num2long` boilerplate — the
58
+ trampoline does it.
59
+
60
+ Supported D parameter types: `bool`, `int`/`uint`/`long`/`ulong` (`C_long`
61
+ aliases follow the platform), `double`, `string` (copied via `idup`),
62
+ static and dynamic arrays, and hashes (AAs). Return types: all of the
63
+ above plus `cdouble`/`creal` (mapped to Ruby `Complex` via `rb_funcallv`).
64
+ To receive a raw, unconverted `VALUE`, declare the parameter (or return
65
+ type) as `Raw!VALUE` — passthrough is opt-in since `VALUE` is an `ulong`
66
+ alias and a plain `ulong` param means "decode me".
67
+
68
+ Conversion semantics worth knowing (pinned by `exp_convert`):
69
+ - `bool` follows **Ruby truthiness** (`nil`/`false` → `false`, everything
70
+ else — including `0` and `""` — → `true`), matching how Ruby itself
71
+ sees the argument.
72
+ - `string` decodes the full byte content (`String#bytesize` bytes), not
73
+ the character count — multibyte and binary strings survive intact.
74
+ - `int`/`uint` decode through the range-checked converters: out-of-range
75
+ values raise `RangeError` instead of silently wrapping; unsigned D
76
+ types also reject negative inputs.
77
+
78
+ ### D gotchas
79
+
80
+ The torture experiments found **twenty-four** D-specific traps; the full catalog
81
+ with fixes lives in experiments/NOTES.md. The ones that will bite you on
82
+ day one:
83
+
84
+ - **Uninitialized `double x;` is NaN** — not 0.0 (D's `float.init` is NaN).
85
+ Always write `double x = 0.0;` for accumulators; a NaN accumulator
86
+ silently poisons every sum that touches it. This bit three separate
87
+ times in the torture tests.
88
+ - **A .so extension dlopen'd by Ruby never runs D's `rt_init()`** — the D
89
+ GC lazy-initializes so allocation appears to work, but the first
90
+ `GC.collect()` or `Thread.sleep` from D hard-aborts. Call
91
+ `import core.runtime : rt_init; rt_init();` at the top of `Init_*`.
92
+ - **Module variables are thread-local by default** — Ruby threads are OS
93
+ pthreads, so shared state needs `__gshared`.
94
+ - **D exceptions must never escape an extern(C) frame** (or a
95
+ no-GVL callback) — catch in D, raise Ruby-side after re-acquiring the GVL.
96
+ - **Uninitialized integers and pointers are fine (0/null).**
97
+ - **gdc-14 + `-fsanitize=address` crashes on any explicit `rt_init()`**
98
+ (ModuleInfo NULL SEGV, finding #23) — ASan coverage is limited to the
99
+ rt_init-free experiments, which pass clean (`make memcheck`, finding #24).
100
+
101
+ ## Wrapping D structs: `RudyTypedData`
102
+
103
+ `def!` converts *arguments*, but sometimes you want the D struct itself to
104
+ *be* a Ruby object (stateful objects, not just pure functions). The old
105
+ `Data_Wrap_Struct` path needs `rb_data_object_get`, which is an inline C
106
+ macro — uncallable from D. The TypedData API avoids that entirely:
107
+
108
+ ```d
109
+ import rudy.typeddata;
110
+
111
+ struct Matrix { size_t rows, cols; double* data; }
112
+
113
+ mixin RudyTypedData!(Matrix, "matrix");
114
+ // generates: matrix_type (rb_data_type_t descriptor),
115
+ // matrix_wrap(VALUE klass, Matrix*),
116
+ // matrix_get(VALUE obj) // raises TypeError on wrong type
117
+
118
+ extern(C) VALUE matrix_alloc(VALUE klass)
119
+ {
120
+ import core.stdc.stdlib : calloc;
121
+ return matrix_wrap(klass, cast(Matrix*) calloc(1, Matrix.sizeof));
122
+ }
123
+ ```
124
+
125
+ The mixin generates the descriptor (dfree defaults to `free()` for
126
+ malloc'd structs; pass `&rudy_typed_nofree` for D-GC-owned memory, or any
127
+ custom `extern(C) void function(void*)` for cleanup). `matrix_get` uses
128
+ `rb_check_typeddata`, so unwrapping a non-Matrix raises `TypeError` — no
129
+ D-side registry needed. Full example (alloc func, `initialize`, `[]`/
130
+ `[]=`, `det`, `to_a`, 100k-object GC stress): `src/dtypetest.d`, suite:
131
+ `test/typedtest.rb` (9 assertions).
132
+
133
+ ### Optional arguments via D default values
134
+
135
+ A `def!`'d function whose parameters declare D default values is
136
+ registered with arity `-1` and gets a variadic (argc/argv) trampoline:
137
+ missing arguments are substituted with their declared defaults, so from
138
+ Ruby the trailing parameters behave like optional arguments.
139
+
140
+ ```d
141
+ long var_sum(long a, long b = 2, long c = 30) { return a + b + c; }
142
+ def!("MyModule", var_sum);
143
+ ```
144
+
145
+ ```ruby
146
+ MyModule.var_sum(1) # => 33 (b, c defaulted)
147
+ MyModule.var_sum(1, 2) # => 33 (c defaulted)
148
+ MyModule.var_sum(1, 2, 0) # => 3
149
+ MyModule.var_sum # ArgumentError (a is required)
150
+ ```
151
+
152
+ Raw `VALUE` parameters (and `VALUE` returns via `to_ruby_value`) pass
153
+ through untouched, so D functions can mix optional and raw-VALUE args.
154
+
155
+ ### Blocks, `*rest`, `**kwargs`, `Self`, and visibility
156
+
157
+ Special parameters are filled by the trampoline itself, never decoded
158
+ from argv (at most one of `Rest`/`KwArgs`/`RubyBlock` per function;
159
+ `RubyBlock` must be the last parameter; declaring one switches the
160
+ registration to the uniform argc/argv trampoline):
161
+
162
+ ```d
163
+ import rudy.args;
164
+ import rudy.convert;
165
+
166
+ long with_block(long n, RubyBlock blk)
167
+ {
168
+ if (!blk.given) return n;
169
+ return blk.call(n); // yields n, returns block result
170
+ }
171
+
172
+ long rest_sum(long first, Rest rest) // var_sum(1, 2, 3) -> rest = [2, 3]
173
+ {
174
+ long total = first;
175
+ foreach (v; rest) total += d_type!long(v);
176
+ return total;
177
+ }
178
+
179
+ string kw_pick(KwArgs kw) // kw_pick(n: 5) / kw_pick()
180
+ {
181
+ long n = kw.get!long("n", 0); // D default when the key is absent
182
+ import std.conv : to;
183
+ return "n=" ~ to!string(n) ~ (kw.given ? " (given)" : "");
184
+ }
185
+ ```
186
+
187
+ Semantics pinned by `exp_defargs` (44 assertions):
188
+
189
+ - `RubyBlock.call` with no block given raises `LocalJumpError` —
190
+ the same contract as `yield`. `given` tells the two cases apart;
191
+ calling other `def!`'d D methods from inside a block body works
192
+ (reentrancy).
193
+ - A `Rest` of zero args is an **empty array**, not `nil` — matching
194
+ Ruby's own `*rest` semantics (`length == 0`, `empty` agrees).
195
+ - KwArgs keys arrive as Symbols; `kw.to!(V, string)` normalizes them
196
+ via `to_s`. Decode values into concrete D types
197
+ (`kw.to!(long, string)`); to walk values untouched use
198
+ `kw.to!(Raw!VALUE, string)` — a plain `VALUE`/`ulong` V would run a
199
+ numeric decode over every value and raise on non-numbers.
200
+ - Visibility and singletons: `def_private!`/`def_protected!` define
201
+ instance-level methods (probe through `Object.new.extend(Mod)` or
202
+ `send`), `def_singleton!` a class-level method, `def_singleton_on!`
203
+ attaches to one specific object (`"Mod::CONST"` paths walk the scope
204
+ chain). `Self` receives the actual receiver.
205
+ - Returning a Ruby object built in D requires a `Raw!VALUE` return
206
+ (finding #17): a plain `VALUE` return is converted as a machine word.
207
+
208
+ ## Beyond def! — direct C-API bindings
209
+
210
+ `src/rudy/bind/value.d` also binds the pieces you need to write
211
+ extension methods by hand (extern(C) bodies registered directly):
212
+
213
+ - **Blocks/iteration**: `rb_yield`, `rb_yield_values2`, `rb_yield_splat`,
214
+ `rb_block_call` (C-implemented blocks!), `rb_block_given_p`, `rb_each`
215
+ - **Structured exceptions**: `rb_rescue` (single StandardError family),
216
+ `rb_ensure`, `rb_protect`, `rb_exc_raise` (re-raise the *same* exception
217
+ VALUE), `rb_errinfo`/`rb_set_errinfo`
218
+ - **Per-object methods**: `rb_define_singleton_method`
219
+ - **GVL**: `rb_thread_call_without_gvl` / `_with_gvl` (pure-D work can
220
+ release the GVL so other Ruby threads run), `RUBY_UBF_IO`
221
+ - **Arg scanning**: `rb_scan_args` (argc/argv style)
222
+
223
+ `src/dexitest.d` + `test/exitest.rb` exercise all of these (31
224
+ assertions) alongside the fixed-arity `def!` suite; the exception and
225
+ GVL bindings are additionally tortured by `experiments/exp_exc` and
226
+ `experiments/exp_http` (see experiments/NOTES.md).
227
+
228
+ ## Layout
229
+
230
+ ```
231
+ lib/rudy4/ the rudy4 gem: CLI, generator, doctor, templates
232
+ bin/rudy4 the rudy4 command
233
+ vendor/rudy/ vendored copy of src/rudy/ (shipped in the gem)
234
+ rudy4.gemspec the gem itself
235
+ scripts/vendor_sync.sh src/rudy/ -> vendor/rudy/ (vendor_check.sh guards drift)
236
+ src/rudy/bind/value.d hand-written binding to the Ruby 4.0 C API
237
+ src/rudy/convert.d to_ruby_value!(T) / d_type!(T) conversions
238
+ src/rudy/defs.d def! templates + extern(C) trampolines
239
+ src/rudy/typeddata.d RudyTypedData mixin: wrap D structs as Ruby objects
240
+ src/rudy/objproto.d RudyObjectProtocol mixin: dup/clone/==/hash/inspect/freeze
241
+ src/dextest.d acceptance-test extension (old "dexter" revived)
242
+ src/dexitest.d missing-API bindings acceptance extension
243
+ src/dtypetest.d typed-data acceptance extension
244
+ experiments/ eleven torture experiments (see experiments/NOTES.md)
245
+ examples/hello_gem/ gem-shaped extension example (gemspec + ext/ + lib/)
246
+ test/rudy4_test.rb the gem's own suite (scaffold / doctor / vendor)
247
+ test/run.rb Ruby-side acceptance suite (22 assertions)
248
+ test/exitest.rb bindings acceptance suite (31 assertions)
249
+ test/typedtest.rb typed-data acceptance suite (9 assertions)
250
+ ```
251
+
252
+ `src/rudy/bind/value.d` is deliberately **not** generated: the modern Ruby
253
+ API is mostly opaque `VALUE` + function calls, so ~200 lines of hand-picked
254
+ `extern(C)` declarations replace the old 2,400-line bcd.gen blob. Struct
255
+ accesses (`RSTRING(str).ptr`) are gone — everything goes through exported
256
+ functions (`rb_string_value_ptr`, `rb_float_value`, `rb_ary_entry`, ...).
257
+
258
+ ## Getting started: the `rudy4` gem
259
+
260
+ The fastest way in is the `rudy4` gem. It scaffolds a self-contained
261
+ extension gem — the rudy4 D library is copied into it, so the project builds
262
+ with nothing but GDC and your Ruby headers, no checkout and no `RUDY4_ROOT`:
263
+
264
+ ```sh
265
+ gem install rudy4
266
+ rudy4 doctor # checks Ruby + headers + GDC, then compiles a probe
267
+ rudy4 init fast_math # writes a small extension gem
268
+ cd fast_math
269
+ rake compile # GDC builds ext/fast_math/fast_math.d
270
+ rake test # 7 assertions against the compiled extension
271
+ ```
272
+
273
+ Now edit `ext/fast_math/fast_math.d` and repeat `rake compile && rake test`.
274
+ The generated project is a normal gem: add it to another app as a path gem,
275
+ or `gem build` and publish it — RubyGems will compile the D source at install
276
+ time.
277
+
278
+ `rudy4 doctor` is the thing to run when a build fails: it verifies the Ruby
279
+ toolchain, reports the GDC version, and then *proves* the setup by scaffolding
280
+ and compiling a throwaway extension, so a failure names the missing piece
281
+ instead of dumping compiler output.
282
+
283
+ ## Requirements
284
+
285
+ - Ruby 4.0 (any install with `libruby` and headers)
286
+ - GDC ≥ 13 (tested: gdc-14 14.2.0, D front-end 2.10x)
287
+ - GNU make
288
+
289
+ ## Packaging: shipping a D-backed gem
290
+
291
+ Two worked examples, in order of usefulness:
292
+
293
+ 1. **`rudy4 init NAME`** (the `rudy4` gem, above) — the recommended path. It
294
+ writes a project whose `ext/NAME/rudy/` is a *vendored copy* of the rudy4 D
295
+ library, so it builds and installs with no reference to a rudy4 checkout.
296
+ 2. **`examples/hello_gem/`** — the in-repo reference for what that generated
297
+ layout can contain (wrapped D struct, object protocol, freeze-aware
298
+ mutators). It predates the gem and finds its sources by walking up to a
299
+ checkout (`RUDY4_ROOT`), so it is a development example, not the first-run
300
+ path:
301
+
302
+ ```sh
303
+ cd examples/hello_gem
304
+ bash test/smoke.sh # build + 17 assertions
305
+ gem build hello_gem.gemspec
306
+ RUDY4_ROOT=/path/to/rudy4 gem install ./hello_gem-0.1.0.gem
307
+ ruby -e 'require "hello_gem"; puts add(2, 3)'
308
+ ```
309
+
310
+ See `examples/hello_gem/README.md` for how the extconf.rb trick works and
311
+ why installing from outside the repo needs `RUDY4_ROOT`.
312
+
313
+ ### The `rudy4` gem itself
314
+
315
+ `lib/rudy4/`, `bin/rudy4`, `vendor/rudy/` and `rudy4.gemspec` are the gem:
316
+ `init` (scaffolder), `doctor` (toolchain check + build probe), `vendor`
317
+ (re-copy the D library into an existing project). `vendor/rudy/` is generated
318
+ from `src/rudy/` by `scripts/vendor_sync.sh`; CI runs
319
+ `scripts/vendor_check.sh` and fails if the two drift, so `src/rudy/` stays the
320
+ single source of truth.
321
+
322
+ ## Torture experiments (`experiments/`)
323
+
324
+ Eleven experiments probe the D↔Ruby seam far past what the acceptance
325
+ suites cover — GVL-released blocking I/O with a hand-written TCP server,
326
+ 48k-object TypedData GC storms against a concurrent `GC.collect()` churn
327
+ thread, 50-level exception ping-pong with `rb_rescue`/`rb_ensure`/nested
328
+ `rb_protect` and `rb_exc_raise` identity, perf, conversion round trips
329
+ across every supported D type (integers at ±2^63/2^64-1, NaN/inf,
330
+ multibyte/binary/invalid-encoding strings, GC.stress at the seam), the
331
+ `def!` argument-passing machinery itself (blocks, `*rest`, `**kwargs`,
332
+ visibility, singletons), a full object protocol (`dup`/`clone`/`==`/
333
+ `hash`/`inspect`/`freeze`), and a non-regression suite for the documented
334
+ non-goals (Fiber switches, Ractor refusal, GVL-held interruptibility).
335
+ They found
336
+ nineteen real bugs (ABI widths, a missing `rt_init()`, thread-local-by-default module
337
+ state, a TypedData free-callback routing bug, ...) plus eight toolchain /
338
+ boundary findings (#20–#27: object-protocol semantics, the zalloc
339
+ size-arg bug, GDC template-arg quirks, the gdc-14 ASan/rt_init
340
+ limitations, and the three non-goals), all
341
+ documented with
342
+ fixes in **experiments/NOTES.md** — that file is the real catalog of
343
+ D-specific traps when writing Ruby extensions. Run them with `make exp`
344
+ then `make exp-test`.
345
+
346
+ ## Known limitations (non-goals)
347
+
348
+ Three things rudy deliberately does **not** promise. Each is pinned by
349
+ `experiments/test_exp_nonregression.rb` so the boundary is evidence, not
350
+ silence (see NOTES.md findings #25–#27):
351
+
352
+ - **Fiber switching across D frames** — simple value round-trips across a
353
+ `Fiber.yield` inside a D frame work, but a D-GC object reachable only
354
+ from a fiber-stack local has no GC root (D's GC scans the thread stack,
355
+ not Ruby's fiber stacks).
356
+ - **Ractor-shareability** — calling `def!`'d methods from a non-main
357
+ Ractor is refused by the VM (`Ractor::UnsafeError`); D-wrapped objects
358
+ are not shareable. Use threads or separate processes.
359
+ - **Interruptibility of a long GVL-held D call** — a pure-D call holding
360
+ the GVL ignores Ctrl-C until it returns. Run long work under
361
+ `rb_thread_call_without_gvl` (with a cooperating unblock function) and
362
+ it becomes interruptible in milliseconds.
363
+
364
+ ## Build & test
365
+
366
+ ```sh
367
+ make # builds .build/dextest.so, .build/dexitest.so and .build/dtypetest.so
368
+ make test # runs test/run.rb + test/exitest.rb + test/typedtest.rb (62 assertions)
369
+ make exp # builds the eleven experiment extensions (experiments/)
370
+ make exp-test # runs all eleven torture suites (452 assertions, see experiments/NOTES.md)
371
+ make memcheck # ASan subset: builds .build.asan and runs the rt_init-free suites
372
+
373
+ make gem-test # the rudy4 gem's own suite (scaffold / doctor / vendor, 136 assertions)
374
+ make gem-test-full # + scaffolds a project and compiles it with GDC, end to end
375
+ make vendor-check # fails if vendor/rudy/ has drifted from src/rudy/
376
+ ```
377
+
378
+ The Makefile finds Ruby via `ruby -e` hdrdir/libdir queries (works with
379
+ asdf, rbenv, system Ruby). Point `RUBY` at a specific binary if needed:
380
+
381
+ ```sh
382
+ make test RUBY=~/.asdf/installs/ruby/4.0.7/bin/ruby
383
+ ```
384
+
385
+ ## Differences from 2010 rudy
386
+
387
+ | 2010 rudy | rudy4 |
388
+ |---|---|
389
+ | Ruby 1.8 | Ruby 4.0 |
390
+ | D1 | D2 (GDC 14) |
391
+ | `VALUE` = `uint` | `VALUE` = `ulong` (pointer-sized) |
392
+ | bcd.gen'd 2,415-line `ruby.d` | ~220-line hand-written binding |
393
+ | `RSTRING(s).ptr` struct access | function API only |
394
+ | `meta/` TMP library (~870 lines) | `std.traits` (`Parameters!`, `__traits`) |
395
+ | `dsss` build | plain Makefile |
396
+ | raw VALUE passthrough in def! | automatic D↔Ruby conversion |
397
+
398
+ ## License
399
+
400
+ MIT — see [LICENSE](LICENSE) (as the original rudy).