Roost generates Ruby FFI bindings from your Rust code automatically. Annotate functions with #[ffi_export] and structs with #[ffi_struct], and Roost emits the metadata needed to produce Ruby FFI attach_function and FFI::Struct declarations at build time.
- Your
build.rscallsroost::build::init()to clear the metadata file - During compilation,
#[ffi_export]and#[ffi_struct]write type metadata toffi_metadata.txt - After
cargo build,roost-bindgenreads that file and generates a Ruby FFI bindings file - Ruby loads the bindings via the
ffigem — no C extension compilation, no hand-written FFI declarations
my_gem/
├── ext/my_gem/
│ ├── Cargo.toml
│ ├── build.rs
│ ├── extconf.rb
│ └── src/lib.rs
├── lib/
│ └── my_gem.rb
├── my_gem.gemspec
└── Gemfile
[package]
name = "my_gem"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
roost = "0.1"
[build-dependencies]
roost = "0.1"fn main() {
roost::build::init();
}use roost::{ffi_export, ffi_struct};
use std::ffi::{c_char, CStr, CString};
#[ffi_export]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[ffi_export]
pub fn greet(name: *const c_char) -> *mut c_char {
let name_str = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("stranger");
CString::new(format!("Hello, {}!", name_str)).unwrap().into_raw()
}
#[ffi_export]
pub fn free_string(s: *mut c_char) {
if !s.is_null() {
unsafe { drop(CString::from_raw(s)) };
}
}
#[ffi_struct]
pub struct DivResult {
pub quotient: i32,
pub remainder: i32,
}
#[ffi_export]
pub fn divmod(a: i32, b: i32) -> DivResult {
DivResult { quotient: a / b, remainder: a % b }
}This is the build entry point that RubyGems runs during gem install:
require 'fileutils'
ext_dir = __dir__
root_dir = File.expand_path('../..', ext_dir)
lib_dir = File.join(root_dir, 'lib', 'my_gem')
# Build the Rust cdylib
Dir.chdir(ext_dir) do
system('cargo build --release') || abort('cargo build failed')
end
# Generate Ruby FFI bindings from metadata
Dir.chdir(ext_dir) do
system(
'roost-bindgen',
'--lib', 'my_gem',
'--module', 'MyGem::Native',
'--metadata', 'ffi_metadata.txt',
'--out', File.join(lib_dir, 'native.rb')
) || abort('bindgen failed')
end
# Copy the compiled library
dylib = case RUBY_PLATFORM
when /darwin/ then 'libmy_gem.dylib'
when /mingw|mswin/ then 'my_gem.dll'
else 'libmy_gem.so'
end
FileUtils.mkdir_p(lib_dir)
FileUtils.cp(File.join(ext_dir, 'target', 'release', dylib), lib_dir)
# RubyGems expects a Makefile from extconf.rb
File.write(File.join(ext_dir, 'Makefile'), "install:\n\t@echo done\nclean:\n\t@echo done\n")Install roost-bindgen with:
cargo install roost-bindgen# lib/my_gem.rb
require_relative 'my_gem/native'
module MyGem
def self.add(a, b) = Native.add(a, b)
def self.greet(name)
ptr = Native.greet(name)
begin
ptr.read_string
ensure
Native.free_string(ptr)
end
end
def self.divmod(a, b)
result = Native.divmod(a, b)
[result[:quotient], result[:remainder]]
end
endGem::Specification.new do |spec|
spec.name = "my_gem"
spec.version = "0.1.0"
spec.summary = "My Rust-powered gem"
spec.files = Dir["lib/**/*.rb", "ext/**/*"]
spec.extensions = ["ext/my_gem/extconf.rb"]
spec.add_dependency "ffi"
endGiven the Rust code above, roost-bindgen produces:
# Auto-generated by roost-bindgen. Do not edit.
require 'ffi'
module MyGem
module Native
extend FFI::Library
lib_name = FFI.map_library_name('my_gem')
ffi_lib File.join(File.dirname(__FILE__), lib_name)
class DivResult < FFI::Struct
layout :quotient, :int32, :remainder, :int32
end
attach_function :add, [:int32, :int32], :int32
attach_function :greet, [:string], :pointer
attach_function :free_string, [:pointer], :void
attach_function :divmod, [:int32, :int32], DivResult.by_value
end
end| Rust | Ruby FFI |
|---|---|
i8, i16, i32, i64 |
:int8, :int16, :int32, :int64 |
u8, u16, u32, u64 |
:uint8, :uint16, :uint32, :uint64 |
f32, f64 |
:float, :double |
bool |
:bool |
*const c_char |
:string |
*mut c_char |
:pointer |
*const T / *mut T |
:pointer |
#[ffi_struct] types |
StructName.by_value |
| no return type | :void |
Functions that return *mut c_char (heap-allocated strings) should have a corresponding free_* function. In your Ruby wrapper, use ensure to guarantee cleanup:
def self.greet(name)
ptr = Native.greet(name)
begin
ptr.read_string
ensure
Native.free_string(ptr)
end
endRoost maps *mut c_char returns to :pointer (not :string) specifically so you retain the pointer for freeing.
roost-bindgen --lib <crate_name> --module <Ruby::Module> --out <path>
| Flag | Required | Description |
|---|---|---|
--lib |
yes | Cargo crate name (used in FFI.map_library_name) |
--module |
yes | Ruby module path, e.g. MyGem::Native (handles nesting) |
--out |
yes | Output path for the generated .rb file |
--metadata |
no | Path to ffi_metadata.txt (defaults to ./ffi_metadata.txt) |
See examples/my_gem/ for a complete working gem.
cd examples/my_gem
bundle install
ruby ext/my_gem/extconf.rb
ruby demo.rb
# => 7
# => Hello, World!
# => [3, 1]