Ruby
Overview
The Ruby target produces pure-Ruby FFI bindings using the
ffi gem to call the C ABI directly. There’s
no native extension to compile; gem install ffi is the only
prerequisite. The generator emits a single .rb file plus a gemspec
ready for gem build and gem install.
The trade-off is that FFI gem calls are slower than a hand-written C extension. For typical FFI workloads the overhead is negligible compared to the work done inside the Rust library.
What gets generated
| File | Purpose |
|---|---|
ruby/lib/weaveffi.rb | FFI bindings: library loader, attach_function declarations, wrapper classes |
ruby/weaveffi.gemspec | Gem specification with ffi ~> 1.15 dependency |
ruby/README.md | Prerequisites and usage instructions |
The file names follow the gem name (IDL package.name): a package
named events produces lib/events.rb and events.gemspec;
weaveffi is the default.
Type mapping
| IDL type | Ruby type | FFI type |
|---|---|---|
i32 | Integer | :int32 |
u32 | Integer | :uint32 |
i64 | Integer | :int64 |
f64 | Float | :double |
i8 | Integer | :int8 |
i16 | Integer | :int16 |
u8 | Integer | :uint8 |
u16 | Integer | :uint16 |
u64 | Integer | :uint64 |
f32 | Float | :float |
bool | true/false | :int32 (0/1 conversion) |
string | String | :string (param) / :pointer (return) |
bytes | String (binary) | :pointer + :size_t |
handle | Integer | :uint64 |
Struct | StructName | :pointer |
Enum (plain) | Integer | :int32 |
Enum (rich) | EnumName | :pointer |
T? | T or nil | :pointer for scalars; same pointer for strings/structs |
[T] | Array | :pointer + :size_t |
{K: V} | Hash | key/value pointer arrays + :size_t |
iter<T> | Array | :pointer iterator handle |
Booleans cross as :int32 (0/1); the wrapper converts both
directions.
Example IDL → generated code
version: "0.4.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: "A contact record"
fields:
- { name: id, type: i64 }
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
functions:
- name: create_contact
params:
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: list_contacts
params: []
return: "[Contact]"
The generated module extends FFI::Library and selects the right
shared library at load time:
require 'ffi'
module WeaveFFI
extend FFI::Library
case FFI::Platform::OS
when /darwin/ then ffi_lib 'libweaveffi.dylib'
when /mswin|mingw/ then ffi_lib 'weaveffi.dll'
else ffi_lib 'libweaveffi.so'
end
end
Enums become Ruby modules with constants:
module ContactType
PERSONAL = 0
WORK = 1
OTHER = 2
end
Structs become classes wrapping an FFI::AutoPointer so the C
destructor is called when Ruby garbage-collects the wrapper:
class ContactPtr < FFI::AutoPointer
def self.release(ptr)
WeaveFFI.weaveffi_contacts_Contact_destroy(ptr)
end
end
class Contact
attr_reader :handle
def initialize(handle)
@handle = ContactPtr.new(handle)
end
def first_name
result = WeaveFFI.weaveffi_contacts_Contact_get_first_name(@handle)
return '' if result.null?
str = result.read_string
WeaveFFI.weaveffi_free_string(result)
str
end
def email
result = WeaveFFI.weaveffi_contacts_Contact_get_email(@handle)
return nil if result.null?
str = result.read_string
WeaveFFI.weaveffi_free_string(result)
str
end
end
Functions are class methods on the module and raise on failure:
def self.create_contact(first_name, email, contact_type)
err = ErrorStruct.new
result = weaveffi_contacts_create_contact(
first_name, email, contact_type, err)
check_error!(err)
result
end
def self.get_contact(id)
err = ErrorStruct.new
result = weaveffi_contacts_get_contact(id, err)
check_error!(err)
raise Error.new(-1, 'null pointer') if result.null?
Contact.new(result)
end
The shared error machinery:
class ErrorStruct < FFI::Struct
layout :code, :int32, :message, :pointer
end
class Error < StandardError
attr_reader :code
def initialize(code, message)
@code = code
super(message)
end
end
def self.check_error!(err)
return if err[:code].zero?
code = err[:code]
msg_ptr = err[:message]
msg = msg_ptr.null? ? '' : msg_ptr.read_string
weaveffi_error_clear(err.to_ptr)
raise Error.new(code, msg)
end
Catch errors with standard begin/rescue:
require 'weaveffi'
begin
handle = WeaveFFI.create_contact("Alice", nil, WeaveFFI::ContactType::WORK)
rescue WeaveFFI::Error => e
puts "Error #{e.code}: #{e.message}"
end
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style Enum crosses as a bare :int32 discriminant; a
rich enum instead lowers to an opaque object handle, so the
generator emits a wrapper class with the same ownership model as a
struct wrapper, an FFI::AutoPointer (ShapePtr) that calls the C
_destroy on garbage collection.
For a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and Labeled { label: string, count: u8 }, the generated class carries one discriminant constant per
variant, a tag reader, a self.<variant> factory per variant, and a
field reader per payload:
class ShapePtr < FFI::AutoPointer
def self.release(ptr)
WeaveFFI.weaveffi_shapes_Shape_destroy(ptr)
end
end
# An algebraic shape (sum type with associated data)
class Shape
attr_reader :handle
def initialize(handle)
@handle = ShapePtr.new(handle)
end
# Variant discriminants returned by #tag
EMPTY = 0
CIRCLE = 1
RECTANGLE = 2
LABELED = 3
def tag
WeaveFFI.weaveffi_shapes_Shape_tag(@handle)
end
# A circle with a radius
def self.circle(radius)
err = WeaveFFI::ErrorStruct.new
result = WeaveFFI.weaveffi_shapes_Shape_Circle_new(radius, err)
WeaveFFI.check_error!(err)
new(result)
end
# A labeled shape with a small count
def self.labeled(label, count)
err = WeaveFFI::ErrorStruct.new
result = WeaveFFI.weaveffi_shapes_Shape_Labeled_new(label, count, err)
WeaveFFI.check_error!(err)
new(result)
end
# Radius in points
def circle_radius
WeaveFFI.weaveffi_shapes_Shape_Circle_get_radius(@handle)
end
def labeled_count
WeaveFFI.weaveffi_shapes_Shape_Labeled_get_count(@handle)
end
end
The remaining surface follows the same pattern: factories
Shape.empty, Shape.circle, Shape.rectangle, and Shape.labeled;
readers circle_radius, rectangle_width, rectangle_height,
labeled_label, and labeled_count. Each maps to a
weaveffi_shapes_Shape_<Variant>_new /
weaveffi_shapes_Shape_<Variant>_get_<field> symbol, and
weaveffi_shapes_Shape_tag returns the discriminant.
Construct a couple of variants, read the tag and a field, then pass the wrapper to a module function:
require 'weaveffi'
circle = WeaveFFI::Shape.circle(2.0)
labeled = WeaveFFI::Shape.labeled('unit', 3)
if circle.tag == WeaveFFI::Shape::CIRCLE
puts circle.circle_radius # 2.0
end
puts labeled.labeled_count # 3
puts WeaveFFI.describe(circle) # render via the C ABI
bigger = WeaveFFI.scale(circle, 3.0) # returns a new Shape
Ownership: the ShapePtr FFI::AutoPointer calls
weaveffi_shapes_Shape_destroy when Ruby garbage-collects the wrapper;
call #destroy for deterministic cleanup. The Shape returned by
WeaveFFI.scale is managed the same way.
Build instructions
-
Generate the bindings:
weaveffi generate api.yaml -o generated --target ruby -
Build the Rust shared library:
cargo build --release -p your_library -
Build and install the gem:
cd generated/ruby gem build weaveffi.gemspec gem install weaveffi-0.1.0.gem -
Make the cdylib findable at runtime:
- macOS:
DYLD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb - Linux:
LD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb - Windows: place
weaveffi.dllnext to the script or add its directory toPATH.
- macOS:
The Ruby module name and gem name can be customised via generator configuration:
[ruby]
module_name = "MyBindings"
gem_name = "my_bindings"
Memory and ownership
- Strings in: Ruby strings are passed as
:stringparameters and the FFI gem encodes them to null-terminated C strings. - Strings out: the wrapper reads the returned
:pointerwithread_string, then callsweaveffi_free_stringto release the Rust-owned buffer. - Bytes: an
FFI::MemoryPointeris allocated for inputs; outputs are read withread_string(len)and the Rust side is responsible for the buffer it returned. - Structs: wrappers hold an
FFI::AutoPointerwhosereleasecallback invokes the C_destroyfunction on GC. Use the explicitdestroymethod for deterministic cleanup. - Maps: keys and values are marshalled into parallel
FFI::MemoryPointerbuffers; the wrapper rebuilds a RubyHashfrom the returned arrays.
Async support
Async IDL functions (async: true) are exposed as blocking wrapper
methods. The wrapper creates a Queue, builds an FFI::Function
completion callback that pushes either the converted result or an
Error onto it, calls the _async-suffixed C launcher, then pops the
queue and raises if the producer reported an error:
# Blocks until the async producer completes.
def self.run_task(name)
queue = Queue.new
callback = FFI::Function.new(
:void, [:pointer, :pointer, :pointer]
) do |_context, err_ptr, result|
err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr)
if err && err[:code] != 0
# ... read code/message, weaveffi_error_clear ...
queue << Error.new(code, msg)
else
# ... null-pointer guard ...
queue << TaskResult.new(result)
end
end
weaveffi_tasks_run_task_async(name, callback, FFI::Pointer::NULL)
value = queue.pop
raise value if value.is_a?(Error)
value
end
There is no promise/future type and no concurrent-ruby dependency:
the calling thread blocks until the completion callback fires. Wrap
the call in a Thread when you need concurrency:
t = Thread.new { WeaveFFI.run_task('demo') }
result = t.value # joins; re-raises a WeaveFFI::Error from the call
The local callback reference keeps the FFI::Function alive until
queue.pop returns, so the completion callback cannot be collected
mid-flight.
For functions marked cancellable: true the C launcher takes an extra
cancel-token parameter. The wrapper always passes FFI::Pointer::NULL
for it. The token isn’t exposed (the generated comment reads
“cancellation token not exposed; pass-through is NULL”). Cancellation
tokens are currently surfaced only by the C, C++, and Kotlin targets.
Callbacks and listeners
IDL callbacks declare a C function-pointer type; a listener pairs
one with register/unregister entry points:
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
The generated module declares the FFI callback type and exposes a
register/unregister pair. Registering takes a block, wraps it in an
FFI::Function trampoline, and returns a uint64 subscription id:
callback :weaveffi_events_OnMessage_fn, [:string, :pointer], :void
attach_function :weaveffi_events_register_message_listener,
[:weaveffi_events_OnMessage_fn, :pointer], :uint64
attach_function :weaveffi_events_unregister_message_listener, [:uint64], :void
# Registers a OnMessage listener block. Returns a subscription id for
# unregister_message_listener.
def self.register_message_listener(&block)
trampoline = FFI::Function.new(:void, [:string, :pointer]) do |message, _context|
block.call(message)
end
listener_id = weaveffi_events_register_message_listener(trampoline, FFI::Pointer::NULL)
@listener_refs[listener_id] = trampoline
listener_id
end
def self.unregister_message_listener(listener_id)
weaveffi_events_unregister_message_listener(listener_id)
@listener_refs.delete(listener_id)
nil
end
- GC safety: the
FFI::Functiontrampoline is pinned in a module-level registry (@listener_refs), keyed by subscription id, so it cannot be garbage-collected while the producer may still call it. Unregistering deletes the registry entry. - Subscription ids: registration returns the
uint64id produced byweaveffi_events_register_message_listener(fn, context); pass it tounregister_message_listenerto stop delivery and release the trampoline. - Threading: the callback fires on the producer’s thread, not the
thread that registered it. Do not block inside it; marshal results
to your own thread or event loop (a
Queueworks well).
Typical round trip:
id = WeaveFFI.register_message_listener { |message| puts message }
WeaveFFI.send_message('hello')
WeaveFFI.unregister_message_listener(id)
Iterators
Functions returning iter<T> receive an opaque iterator handle from
the C ABI. The wrapper drains it eagerly with the generated _next
binding, frees each returned string, destroys the handle, and returns
a fully materialised Array; there’s no lazy Enumerator:
attach_function :weaveffi_events_get_messages, [:pointer], :pointer
attach_function :weaveffi_events_GetMessagesIterator_next,
[:pointer, :pointer, :pointer], :int32
attach_function :weaveffi_events_GetMessagesIterator_destroy,
[:pointer], :void
def self.get_messages()
err = ErrorStruct.new
iter = weaveffi_events_get_messages(err)
check_error!(err)
items = []
return items if iter.null?
loop do
out_item = FFI::MemoryPointer.new(:pointer)
item_err = ErrorStruct.new
has_item = weaveffi_events_GetMessagesIterator_next(iter, out_item, item_err)
# ... destroy the iterator and check_error! if item_err is set ...
break if has_item.zero?
item_ptr = out_item.read_pointer
# ... empty string for NULL ...
items << item_ptr.read_string
weaveffi_free_string(item_ptr)
end
weaveffi_events_GetMessagesIterator_destroy(iter)
items
end
If _next reports an error the wrapper destroys the handle first and
then raises WeaveFFI::Error via check_error!; on success the
handle is destroyed before the array is returned.
Troubleshooting
LoadError: Could not open library 'libweaveffi.dylib': the cdylib is not on the loader path. SetDYLD_LIBRARY_PATH/LD_LIBRARY_PATHor copy the library next to your script.FFI::NotFoundError: Function 'weaveffi_*' not found: the cdylib does not export the symbol. Rebuild the Rust crate after regenerating the IDL.- Segmentation faults on Ruby exit: the generated wrappers pin
listener trampolines in
@listener_refsand keep async completion callbacks referenced until they fire. If you call theattach_functionbindings directly, keep your ownFFI::Functionobjects alive for the lifetime of the call; letting them be garbage-collected mid-call corrupts the C side. - Strings come back as binary garbage: UTF-8 strings should round
trip through
read_string; for binary data useread_bytes(length)with theout_lenreturned by the C ABI.