Skip to content

tenderworks/roost

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Roost

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.

How it works

  1. Your build.rs calls roost::build::init() to clear the metadata file
  2. During compilation, #[ffi_export] and #[ffi_struct] write type metadata to ffi_metadata.txt
  3. After cargo build, roost-bindgen reads that file and generates a Ruby FFI bindings file
  4. Ruby loads the bindings via the ffi gem — no C extension compilation, no hand-written FFI declarations

Quick start

1. Set up your gem

my_gem/
├── ext/my_gem/
│   ├── Cargo.toml
│   ├── build.rs
│   ├── extconf.rb
│   └── src/lib.rs
├── lib/
│   └── my_gem.rb
├── my_gem.gemspec
└── Gemfile

2. Cargo.toml

[package]
name = "my_gem"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
roost = "0.1"

[build-dependencies]
roost = "0.1"

3. build.rs

fn main() {
    roost::build::init();
}

4. Write your Rust code

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 }
}

5. extconf.rb

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

6. Ruby wrapper

# 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
end

7. gemspec

Gem::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"
end

What gets generated

Given 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

Type mapping

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

Memory management

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
end

Roost maps *mut c_char returns to :pointer (not :string) specifically so you retain the pointer for freeing.

roost-bindgen CLI

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)

Example

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]

About

Generate FFI bindings from Rust extensions

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages