Project

rudy4

0.0
The project is in a healthy, maintained state
rudy4 lets you write Ruby native extensions in D (compiled by GDC) instead of C. This gem ships the rudy4 D library plus the `rudy4` command: `rudy4 init NAME` scaffolds a self-contained extension gem (vendoring the D sources, so it builds with no checkout), and `rudy4 doctor` checks your Ruby + GDC setup by compiling and loading a probe extension.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

rudy4

Gem Version Gem Downloads CI License: MIT

Write Ruby native extensions in D. A from-scratch rewrite of rudy (2010), targeting Ruby 4.0 and GDC 14 (D front-end 2.10x).

Already on RubyGems — scaffold and build a working D-backed extension gem in under a minute:

gem install rudy4
rudy4 doctor              # verify Ruby + GDC toolchain (compiles a probe)
rudy4 init fast_math      # write a small, self-contained extension gem
cd fast_math && rake compile && rake test

See Getting started for the details.

The 2010 original bound Ruby 1.8's C API via a bcd.gen-generated ruby.d, required a separate meta/ template-metaprogramming library, and built with the long-dead dsss tool. This repo keeps the one idea worth keeping — def! — and drops everything else.

The idea: def!

Register a D function as a Ruby method with automatic argument conversion:

module myext;

import rudy.defs;

long add_five(long n) { return n + 5; }

pragma(mangle, "Init_myext")
extern(C) void Init_myext()
{
    def!(add_five);            // global function (on Object)
    def!("MyClass", add_five); // instance method of MyClass
}

From Ruby:

require "myext"
add_five(37)   # => 42

Compile the extension into a myext.so Ruby can require (here myext.d lives next to a src/ tree with the library):

gdc -shared -o myext.so \
  -fPIC -Isrc \
  $(ruby -e 'print RbConfig::CONFIG["rubyhdrdir"]') \
  $(ruby -e 'print RbConfig::CONFIG["rubyarchhdrdir"]') \
  src/rudy/*.d src/rudy/bind/*.d myext.d \
  $(ruby -e 'print RbConfig::CONFIG["LIBRUBYARG_SHARED"]') \
  -Wl,-rpath,$(ruby -e 'print RbConfig::CONFIG["libdir"]') -lm

or just make with this repo's Makefile, which does the same (header dirs, libruby and rpath are pulled from RbConfig), builds dextest.so from src/dextest.d, and make test runs the acceptance suite.

Arguments arrive as raw VALUEs and are converted to the declared D parameter types (long, double, string, bool, ...); the return value is converted back. No manual rb_num2long boilerplate — the trampoline does it.

Supported D parameter types: bool, int/uint/long/ulong (C_long aliases follow the platform), double, string (copied via idup), static and dynamic arrays, and hashes (AAs). Return types: all of the above plus cdouble/creal (mapped to Ruby Complex via rb_funcallv). To receive a raw, unconverted VALUE, declare the parameter (or return type) as Raw!VALUE — passthrough is opt-in since VALUE is an ulong alias and a plain ulong param means "decode me".

Conversion semantics worth knowing (pinned by exp_convert):

  • bool follows Ruby truthiness (nil/false → false, everything else — including 0 and "" — → true), matching how Ruby itself sees the argument.
  • string decodes the full byte content (String#bytesize bytes), not the character count — multibyte and binary strings survive intact.
  • int/uint decode through the range-checked converters: out-of-range values raise RangeError instead of silently wrapping; unsigned D types also reject negative inputs.

D gotchas

The torture experiments found twenty-four D-specific traps; the full catalog with fixes lives in experiments/NOTES.md. The ones that will bite you on day one:

  • Uninitialized double x; is NaN — not 0.0 (D's float.init is NaN). Always write double x = 0.0; for accumulators; a NaN accumulator silently poisons every sum that touches it. This bit three separate times in the torture tests.
  • A .so extension dlopen'd by Ruby never runs D's rt_init() — the D GC lazy-initializes so allocation appears to work, but the first GC.collect() or Thread.sleep from D hard-aborts. Call import core.runtime : rt_init; rt_init(); at the top of Init_*.
  • Module variables are thread-local by default — Ruby threads are OS pthreads, so shared state needs __gshared.
  • D exceptions must never escape an extern(C) frame (or a no-GVL callback) — catch in D, raise Ruby-side after re-acquiring the GVL.
  • Uninitialized integers and pointers are fine (0/null).
  • gdc-14 + -fsanitize=address crashes on any explicit rt_init() (ModuleInfo NULL SEGV, finding #23) — ASan coverage is limited to the rt_init-free experiments, which pass clean (make memcheck, finding #24).

Wrapping D structs: RudyTypedData

def! converts arguments, but sometimes you want the D struct itself to be a Ruby object (stateful objects, not just pure functions). The old Data_Wrap_Struct path needs rb_data_object_get, which is an inline C macro — uncallable from D. The TypedData API avoids that entirely:

import rudy.typeddata;

struct Matrix { size_t rows, cols; double* data; }

mixin RudyTypedData!(Matrix, "matrix");
// generates: matrix_type (rb_data_type_t descriptor),
//            matrix_wrap(VALUE klass, Matrix*),
//            matrix_get(VALUE obj)   // raises TypeError on wrong type

extern(C) VALUE matrix_alloc(VALUE klass)
{
    import core.stdc.stdlib : calloc;
    return matrix_wrap(klass, cast(Matrix*) calloc(1, Matrix.sizeof));
}

The mixin generates the descriptor (dfree defaults to free() for malloc'd structs; pass &rudy_typed_nofree for D-GC-owned memory, or any custom extern(C) void function(void*) for cleanup). matrix_get uses rb_check_typeddata, so unwrapping a non-Matrix raises TypeError — no D-side registry needed. Full example (alloc func, initialize, []/ []=, det, to_a, 100k-object GC stress): src/dtypetest.d, suite: test/typedtest.rb (9 assertions).

Optional arguments via D default values

A def!'d function whose parameters declare D default values is registered with arity -1 and gets a variadic (argc/argv) trampoline: missing arguments are substituted with their declared defaults, so from Ruby the trailing parameters behave like optional arguments.

long var_sum(long a, long b = 2, long c = 30) { return a + b + c; }
def!("MyModule", var_sum);
MyModule.var_sum(1)      # => 33   (b, c defaulted)
MyModule.var_sum(1, 2)   # => 33   (c defaulted)
MyModule.var_sum(1, 2, 0) # => 3
MyModule.var_sum         # ArgumentError (a is required)

Raw VALUE parameters (and VALUE returns via to_ruby_value) pass through untouched, so D functions can mix optional and raw-VALUE args.

Blocks, *rest, **kwargs, Self, and visibility

Special parameters are filled by the trampoline itself, never decoded from argv (at most one of Rest/KwArgs/RubyBlock per function; RubyBlock must be the last parameter; declaring one switches the registration to the uniform argc/argv trampoline):

import rudy.args;
import rudy.convert;

long with_block(long n, RubyBlock blk)
{
    if (!blk.given) return n;
    return blk.call(n);              // yields n, returns block result
}

long rest_sum(long first, Rest rest) // var_sum(1, 2, 3) -> rest = [2, 3]
{
    long total = first;
    foreach (v; rest) total += d_type!long(v);
    return total;
}

string kw_pick(KwArgs kw)            // kw_pick(n: 5) / kw_pick()
{
    long n = kw.get!long("n", 0);    // D default when the key is absent
    import std.conv : to;
    return "n=" ~ to!string(n) ~ (kw.given ? " (given)" : "");
}

Semantics pinned by exp_defargs (44 assertions):

  • RubyBlock.call with no block given raises LocalJumpError — the same contract as yield. given tells the two cases apart; calling other def!'d D methods from inside a block body works (reentrancy).
  • A Rest of zero args is an empty array, not nil — matching Ruby's own *rest semantics (length == 0, empty agrees).
  • KwArgs keys arrive as Symbols; kw.to!(V, string) normalizes them via to_s. Decode values into concrete D types (kw.to!(long, string)); to walk values untouched use kw.to!(Raw!VALUE, string) — a plain VALUE/ulong V would run a numeric decode over every value and raise on non-numbers.
  • Visibility and singletons: def_private!/def_protected! define instance-level methods (probe through Object.new.extend(Mod) or send), def_singleton! a class-level method, def_singleton_on! attaches to one specific object ("Mod::CONST" paths walk the scope chain). Self receives the actual receiver.
  • Returning a Ruby object built in D requires a Raw!VALUE return (finding #17): a plain VALUE return is converted as a machine word.

Beyond def! — direct C-API bindings

src/rudy/bind/value.d also binds the pieces you need to write extension methods by hand (extern(C) bodies registered directly):

  • Blocks/iteration: rb_yield, rb_yield_values2, rb_yield_splat, rb_block_call (C-implemented blocks!), rb_block_given_p, rb_each
  • Structured exceptions: rb_rescue (single StandardError family), rb_ensure, rb_protect, rb_exc_raise (re-raise the same exception VALUE), rb_errinfo/rb_set_errinfo
  • Per-object methods: rb_define_singleton_method
  • GVL: rb_thread_call_without_gvl / _with_gvl (pure-D work can release the GVL so other Ruby threads run), RUBY_UBF_IO
  • Arg scanning: rb_scan_args (argc/argv style)

src/dexitest.d + test/exitest.rb exercise all of these (31 assertions) alongside the fixed-arity def! suite; the exception and GVL bindings are additionally tortured by experiments/exp_exc and experiments/exp_http (see experiments/NOTES.md).

Layout

lib/rudy4/              the rudy4 gem: CLI, generator, doctor, templates
bin/rudy4               the rudy4 command
vendor/rudy/            vendored copy of src/rudy/ (shipped in the gem)
rudy4.gemspec           the gem itself
scripts/vendor_sync.sh  src/rudy/ -> vendor/rudy/  (vendor_check.sh guards drift)
src/rudy/bind/value.d   hand-written binding to the Ruby 4.0 C API
src/rudy/convert.d      to_ruby_value!(T) / d_type!(T) conversions
src/rudy/defs.d         def! templates + extern(C) trampolines
src/rudy/typeddata.d    RudyTypedData mixin: wrap D structs as Ruby objects
src/rudy/objproto.d     RudyObjectProtocol mixin: dup/clone/==/hash/inspect/freeze
src/dextest.d           acceptance-test extension (old "dexter" revived)
src/dexitest.d          missing-API bindings acceptance extension
src/dtypetest.d         typed-data acceptance extension
experiments/           eleven torture experiments (see experiments/NOTES.md)
examples/hello_gem/    gem-shaped extension example (gemspec + ext/ + lib/)
test/rudy4_test.rb      the gem's own suite (scaffold / doctor / vendor)
test/run.rb             Ruby-side acceptance suite (22 assertions)
test/exitest.rb         bindings acceptance suite (31 assertions)
test/typedtest.rb       typed-data acceptance suite (9 assertions)

src/rudy/bind/value.d is deliberately not generated: the modern Ruby API is mostly opaque VALUE + function calls, so ~200 lines of hand-picked extern(C) declarations replace the old 2,400-line bcd.gen blob. Struct accesses (RSTRING(str).ptr) are gone — everything goes through exported functions (rb_string_value_ptr, rb_float_value, rb_ary_entry, ...).

Getting started: the rudy4 gem

The fastest way in is the rudy4 gem on RubyGems. It scaffolds a self-contained extension gem — the rudy4 D library is copied into it, so the project builds with nothing but GDC and your Ruby headers, no checkout and no RUDY4_ROOT:

gem install rudy4
rudy4 doctor              # checks Ruby + headers + GDC, then compiles a probe
rudy4 init fast_math      # writes a small extension gem
cd fast_math
rake compile              # GDC builds ext/fast_math/fast_math.d
rake test                 # 7 assertions against the compiled extension

Now edit ext/fast_math/fast_math.d and repeat rake compile && rake test. The generated project is a normal gem: add it to another app as a path gem, or gem build and publish it — RubyGems will compile the D source at install time.

rudy4 doctor is the thing to run when a build fails: it verifies the Ruby toolchain, reports the GDC version, and then proves the setup by scaffolding and compiling a throwaway extension, so a failure names the missing piece instead of dumping compiler output.

Requirements

  • Ruby 4.0 (any install with libruby and headers)
  • GDC ≥ 13 (tested: gdc-14 14.2.0, D front-end 2.10x)
  • GNU make

Packaging: shipping a D-backed gem

Two worked examples, in order of usefulness:

  1. rudy4 init NAME (the rudy4 gem, above) — the recommended path. It writes a project whose ext/NAME/rudy/ is a vendored copy of the rudy4 D library, so it builds and installs with no reference to a rudy4 checkout.

  2. examples/hello_gem/ — the in-repo reference for what that generated layout can contain (wrapped D struct, object protocol, freeze-aware mutators). It predates the gem and finds its sources by walking up to a checkout (RUDY4_ROOT), so it is a development example, not the first-run path:

    cd examples/hello_gem
    bash test/smoke.sh                              # build + 17 assertions
    gem build hello_gem.gemspec
    RUDY4_ROOT=/path/to/rudy4 gem install ./hello_gem-0.1.0.gem
    ruby -e 'require "hello_gem"; puts add(2, 3)'

    See examples/hello_gem/README.md for how the extconf.rb trick works and why installing from outside the repo needs RUDY4_ROOT.

The rudy4 gem itself

lib/rudy4/, bin/rudy4, vendor/rudy/ and rudy4.gemspec are the gem: init (scaffolder), doctor (toolchain check + build probe), vendor (re-copy the D library into an existing project). vendor/rudy/ is generated from src/rudy/ by scripts/vendor_sync.sh; CI runs scripts/vendor_check.sh and fails if the two drift, so src/rudy/ stays the single source of truth.

Torture experiments (experiments/)

Eleven experiments probe the D↔Ruby seam far past what the acceptance suites cover — GVL-released blocking I/O with a hand-written TCP server, 48k-object TypedData GC storms against a concurrent GC.collect() churn thread, 50-level exception ping-pong with rb_rescue/rb_ensure/nested rb_protect and rb_exc_raise identity, perf, conversion round trips across every supported D type (integers at ±2^63/2^64-1, NaN/inf, multibyte/binary/invalid-encoding strings, GC.stress at the seam), the def! argument-passing machinery itself (blocks, *rest, **kwargs, visibility, singletons), a full object protocol (dup/clone/==/ hash/inspect/freeze), and a non-regression suite for the documented non-goals (Fiber switches, Ractor refusal, GVL-held interruptibility). They found nineteen real bugs (ABI widths, a missing rt_init(), thread-local-by-default module state, a TypedData free-callback routing bug, ...) plus eight toolchain / boundary findings (#20–#27: object-protocol semantics, the zalloc size-arg bug, GDC template-arg quirks, the gdc-14 ASan/rt_init limitations, and the three non-goals), all documented with fixes in experiments/NOTES.md — that file is the real catalog of D-specific traps when writing Ruby extensions. Run them with make exp then make exp-test.

Known limitations (non-goals)

Three things rudy deliberately does not promise. Each is pinned by experiments/test_exp_nonregression.rb so the boundary is evidence, not silence (see NOTES.md findings #25–#27):

  • Fiber switching across D frames — simple value round-trips across a Fiber.yield inside a D frame work, but a D-GC object reachable only from a fiber-stack local has no GC root (D's GC scans the thread stack, not Ruby's fiber stacks).
  • Ractor-shareability — calling def!'d methods from a non-main Ractor is refused by the VM (Ractor::UnsafeError); D-wrapped objects are not shareable. Use threads or separate processes.
  • Interruptibility of a long GVL-held D call — a pure-D call holding the GVL ignores Ctrl-C until it returns. Run long work under rb_thread_call_without_gvl (with a cooperating unblock function) and it becomes interruptible in milliseconds.

Build & test

make           # builds .build/dextest.so, .build/dexitest.so and .build/dtypetest.so
make test      # runs test/run.rb + test/exitest.rb + test/typedtest.rb (62 assertions)
make exp       # builds the eleven experiment extensions (experiments/)
make exp-test  # runs all eleven torture suites (452 assertions, see experiments/NOTES.md)
make memcheck  # ASan subset: builds .build.asan and runs the rt_init-free suites

make gem-test       # the rudy4 gem's own suite (scaffold / doctor / vendor, 136 assertions)
make gem-test-full  # + scaffolds a project and compiles it with GDC, end to end
make vendor-check   # fails if vendor/rudy/ has drifted from src/rudy/

The Makefile finds Ruby via ruby -e hdrdir/libdir queries (works with asdf, rbenv, system Ruby). Point RUBY at a specific binary if needed:

make test RUBY=~/.asdf/installs/ruby/4.0.7/bin/ruby

Differences from 2010 rudy

2010 rudy rudy4
Ruby 1.8 Ruby 4.0
D1 D2 (GDC 14)
VALUE = uint VALUE = ulong (pointer-sized)
bcd.gen'd 2,415-line ruby.d ~220-line hand-written binding
RSTRING(s).ptr struct access function API only
meta/ TMP library (~870 lines) std.traits (Parameters!, __traits)
dsss build plain Makefile
raw VALUE passthrough in def! automatic D↔Ruby conversion

License

MIT — see LICENSE (as the original rudy).