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.
| 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.
| 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 |
Interface |
InterfaceName |
: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> |
Enumerator (lazy) |
:pointer iterator handle |
Booleans cross as :int32 (0/1); the wrapper converts both
directions.
version: "0.5.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
# An explicit path in WEAVEFFI_LIBRARY wins, so callers can point at a
# specific build artifact regardless of its file name or location.
_wv_override = ENV['WEAVEFFI_LIBRARY']
if _wv_override && !_wv_override.empty?
ffi_lib _wv_override
else
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
endEnums become Ruby modules with constants:
module ContactType
PERSONAL = 0
WORK = 1
OTHER = 2
endStructs 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
endFunctions are snake_case class methods on the module, with the IDL
module prefix stripped by default (a kv.open_store function surfaces
as open_store, not kv_open_store; the attach_function bindings
keep the full C symbol names). Set strip_module_prefix: false in the
Ruby generator config (or under [global]) to keep prefixed names:
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)
endThe 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)
endCatch 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}"
endA module's error domain adds a base class extending Error with one
nested class per code, each pinning its stable CODE, plus a mapper
that falls back to the generic Error for codes outside the domain.
From the kvstore sample:
# Base error for the `kv` module's error domain.
class KvError < Error
# key not found
class KeyNotFound < KvError
CODE = 1001
def initialize(message = 'key not found')
super(1001, message)
end
end
# Expired, StoreFull, IoError follow the same shape.
end
# Builds the KvError subclass matching `code`, or a generic Error
# for codes outside the domain (panics, marshalling).
def self.kv_error_from(code, message)
cls = KV_ERROR_CODES[code]
return Error.new(code, message) if cls.nil?
message.empty? ? cls.new : cls.new(message)
endOnly callables marked throws: true in the IDL raise the typed
classes: their wrappers call check_kv_error!, so you can rescue
Kvstore::KvError::KeyNotFound for one code or Kvstore::KvError for
the whole domain. A callable without throws uses the generic
check_error!, which raises Error only if the producer misbehaves.
An interfaces: entry becomes a class wrapping an FFI::AutoPointer
subclass, so the C destructor runs when Ruby garbage-collects the
wrapper. Constructors become class methods (Store.open; a
constructor named new maps to the ordinary Store.new), methods are
snake_case instance methods, statics are class methods, and destroy
frees the native object deterministically. From the kvstore sample
(trimmed):
class StorePtr < FFI::AutoPointer
def self.release(ptr)
Kvstore.weaveffi_kv_Store_destroy(ptr)
end
end
# An embedded key-value store owning its entries
class Store
attr_reader :handle
# Wraps an owned pointer the producer handed over, without
# re-running initialize.
def self._from_ptr(ptr)
obj = allocate
obj.instance_variable_set(:@handle, StorePtr.new(ptr))
obj
end
def destroy
return if @handle.nil?
@handle.free
@handle = nil
end
# Open (or create) a store backed by the given filesystem path
def self.open(path)
err = ErrorStruct.new
result = Kvstore.weaveffi_kv_Store_open(path, err)
Kvstore.check_kv_error!(err)
raise Error.new(-1, 'null pointer') if result.null?
_from_ptr(result)
end
def put(key, value, kind, ttl_seconds) # raises typed KvError subclasses
# ...
end
def list_keys(prefix) # lazy Enumerator; see Iterators
# ...
end
def count() # generic check only (no throws)
# ...
end
def compact() # blocking async; see Async support
# ...
end
# Legacy single-shot put kept for compatibility
def legacy_put(key, value)
warn "[DEPRECATED] use put() with explicit kind"
# ...
end
# The largest number of live entries one store will hold
def self.default_capacity()
# ...
end
endFunctions elsewhere in the IDL pass the wrapper's handle across the
boundary (Kvstore.get_stats(store) returns a new Stats).
Deprecated members print a [DEPRECATED] warning at call time:
store = Kvstore::Store.open('/tmp/cache.kv')
store.put('alpha', "\x01".b, Kvstore::EntryKind::PERSISTENT, nil)
puts "#{store.count} / #{Kvstore::Store.default_capacity}"
reclaimed = store.compact
store.destroyA 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
endThe 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 ShapeOwnership: 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.
-
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"- 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 copied withread_string(len)and the returned buffer is released withweaveffi_free_bytes. - Structs and interfaces: wrappers hold an
FFI::AutoPointerwhosereleasecallback invokes the C_destroyfunction on GC. Use the explicitdestroymethod for deterministic cleanup. - Lists and maps: elements are copied into a Ruby
ArrayorHash; string elements are freed individually withweaveffi_free_string, then the backing pointer buffers are freed withweaveffi_free_bytes. - Boxed optional scalars: an absent value is
nil; a present one is dereferenced and the box is freed withweaveffi_free_bytes.
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. For a callable
marked throws: true, the error goes through the domain mapper
(task_error_from here, kv_error_from on Store#compact), so the
raised object is the typed class:
# 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 << task_error_from(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
endThere 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 callThe local callback reference keeps the FFI::Function alive until
queue.pop returns, so the completion callback cannot be collected
mid-flight.
Result ownership follows the async contract: string, bytes, array,
map, and boxed optional scalar results are borrowed for the callback's
duration, so the callback copies them into Ruby values (read_string,
element reads) before it returns and never frees them; the producer
does after the callback returns. Object results (records, rich enums,
interfaces, including optional ones) are the exception: the callback
receives ownership, and the wrapper adopts the pointer into its
FFI::AutoPointer (as TaskResult.new(result) does above), so the
destructor runs on GC or an explicit destroy.
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 and C++ targets.
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: OnMessageThe 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)Functions returning iter<T> return a lazy Enumerator that streams
one element per pull: each consumer step issues exactly one call to
the generated _next binding, so nothing is drained up front. Call
.to_a if you want an eager Array:
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
# Return an iterator over all sent messages
# Returns a lazy Enumerator that streams one element per pull; call
# `.to_a` to collect eagerly. The underlying producer iterator is
# launched on the first pull, so launch errors raise at that point
# rather than when this method returns. The iterator handle is
# released exactly once, when iteration finishes or is abandoned
# early (for example by `break`).
def self.get_messages()
Enumerator.new do |y|
err = ErrorStruct.new
iter = weaveffi_events_get_messages(err)
begin
check_error!(err)
unless 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)
check_error!(item_err)
break if has_item.zero?
item_ptr = out_item.read_pointer
if item_ptr.null?
y << ''
else
item = item_ptr.read_string
weaveffi_free_string(item_ptr)
y << item
end
end
end
ensure
weaveffi_events_GetMessagesIterator_destroy(iter) unless iter.null?
end
end
endThe producer iterator launches on the first pull, so a launch error
raises then, not when the method returns. Each string element is
copied with read_string and freed with weaveffi_free_string;
record elements are adopted by their FFI::AutoPointer-backed
wrapper. The ensure block destroys the handle exactly once, whether
iteration exhausts, raises, or is abandoned early (Ruby runs ensure
when the enumerator's fiber is torn down, for example after break).
The per-step error check follows the function's error strategy: the
throwing kvstore sample's Store#list_keys checks the launcher and
each next with check_kv_error!, so a failing step raises the typed
KvError subclass; the non-throwing get_messages uses the generic
check_error!, which raises only on a producer bug.
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.