package

github.com/OrelSokolov/rubycall.cr

master / published Oct 12, 2026 / repository

«RubyCall»: вызов MRI Ruby из Crystal поверх libruby (C API). Зеркало pycall.cr: глобальный MUTEX + выделенный воркер-тред, rb_protect на каждый вызов, keep-слоты для GC (rb_gc_register_address). См. PLAN.md.

rubycall.cr

RubyCall — calling MRI Ruby from Crystal, in-process over libruby (the Ruby C API). It mirrors pycall.cr (a port of the pycall gem): there Python is embedded via libpython, here Ruby is embedded via libruby. No existing Crystal shard covers this (Anyolite is mruby with the opposite emphasis; crystalruby is Crystal inside Ruby).

Implemented and verified against system Ruby 3.3.8 (pkg-config "ruby", libruby 3.3); build and specs ran 3× in a row, stable green.

Installation

dependencies:
  rubycall_cr:
    github: OrelSokolov/rubycall.cr
shards install

Requires ruby-dev headers (pkg-config "ruby").

Usage

require "rubycall_cr"

RubyCall.init!

math = RubyCall.import("Math")          # rb_const_get → RWrap
pi = math.const_get("PI")

obj = RubyCall.eval("Rational(37, 10)")
obj.floor                              # method_missing → rb_funcall
arr = RubyCall.eval("[3, 1, 2]")
arr.size                               # => 3
arr[0]                                 # rb_ary_entry

sorted = RubyCall.eval("[3, 1, 2].sort")

See examples/smoke.cr for an end-to-end tour of the API (prints "SMOKE OK").

Feature comparison vs pycall.cr (and the pycall gem)

Featurepycall.cr / pycall gemrubycall.crStatus
init!PyCall.init! (Py_Initialize)RubyCall.init! — ruby_init on the worker thread✅
ImportPyCall.import_module → PyWraprb_const_get(Object, :Math) → RWrap / rb_eval_string✅
method_missing (attr / call / setattr)macro method_missingmacro method_missing → rb_funcall + rb_intern✅
Operators__add__ etc.rb_funcall(recv, :+, 1, other)✅
ComparisonsPyObject_RichComparerb_funcall(recv, :==, ...)✅
[] / []=GetItem/SetItemrb_ary_entry / rb_hash_aref / funcall :[]✅
Collection wrappersPyCall::List/Dict/Tuple/SetRArray / RHash (dispatch on rb_type: T_ARRAY/T_HASH/T_STRING)✅
ErrorsPyError (type/value/traceback)RubyCall::RubyError (rb_errinfo → class/message/backtrace)✅
ConversionConversion.from_crystal/to_crystalSame both ways: nil/bool/int/float/string/symbol/array/hash; bignum outside i64 stays RWrap✅
Blocks/Procs → callablePyCall::Callable (libffi)rb_block_call + data-wrap callback trampolines (with_block)✅
eval/execvia __main__rb_eval_string_protect✅
kwargspython kwargsRuby 3 keyword args — symbol keys only (rb_id2sym(rb_intern(key)); string keys silently raise missing keywords)🔶
GIL/GVL disciplinePyCall.gil (PyGILState) + MUTEXGlobal MUTEX + dedicated worker thread (see below); no PyGILState_Check fast path🔶

Threading model (mandatory, not optional)

MRI has no PyGILState_Ensure, ruby_init must run on the main thread, and Crystal fibers migrate between OS threads. Therefore:

  1. One global RubyCall::MUTEX serializes request submission — exactly one operation in flight, so composite calls (funcall → convert → wrap) never interleave.
  2. A dedicated worker thread is the only place rb_* runs. One native stack ⇒ MRI's conservative GC scans it correctly; the GVL never migrates across threads.
  3. Replies go to per-request channels (a pipe-based BlockingQueue — Crystal Channel cannot be used from a raw thread); the requesting fiber waits for its own result.
  4. Direct LibRuby.rb_* calls outside the worker are forbidden by code invariant (all helpers route through RubyCall.dispatch).

Additional hard invariants:

  • Every rb_funcall/rb_eval_string goes through rb_protect (MRI's longjmp would unwind a Crystal fiber's stack); state != 0 → rb_errinfo → RubyError.
  • Every wrapped VALUE lives in a keep-slot registered with rb_gc_register_address (MRI's GC does not see the Crystal heap or fiber stacks); unregistration happens in finalizers (deferred worker requests, like pycall's pending decrefs).

Deviations & Crystal-specific notes

  • Object is forbidden as a Crystal generic argument → RbValue/ RbResult/RbArg union aliases.
  • Math::PI and floor are not module methods in Ruby: constants via const_get, floor on Numerics.
  • yield / Crystal STDERR.puts inside a ruby {} block on the worker are forbidden — iteration via to_a, output only from the requesting fiber.
  • rb_ary_len is absent in 3.3 — size via funcall.

Testing

cd rubycall.cr && crystal spec            # 15 examples, 0 failures
cd rubycall.cr && crystal run examples/smoke.cr

The suite runs the full oracle (oracle/expected.yml, recorded with /usr/bin/ruby) plus stress: 8 fibers × 100 iterations on a shared array and parallel with_block blocks from 4 fibers.

API

  • RubyCall

    Хелперы низкого уровня.