Ruby on Rust

This book describes how to use Rust to build fast, reliable and secure native gems for Ruby. You may be new to Rust, and that's OK! You'll find that Rust has many of the things you love about Ruby. And unlike C, incorrect code will usually fail at compile time rather than bringing down production.

By the end of this book, you will be confident to build, test, and deploy a Rusty Ruby gem of your own! If you get stuck along the way or want to improve Ruby on Rust, please join our Slack channel!

💡 Tip: Join the Slack channel to ask questions and get help from the community!

What's next?

Contributing to this book

This book is open source! Find a typo? Did we overlook something? Send us a pull request!. Help wanted!

Getting started

Creating a new gem

The easiest way to create a new gem is to use the bundle gem command. This will scaffold a new Rust gem using rb-sys and magnus.

  1. Install a Rust toolchain (if needed)

    $ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  2. Upgrade RubyGems and Bundler

    $ gem update --system
    
  3. Scaffold a new gem with a Rust extension

    $ bundle gem --ext=rust my_gem_name
    

This will create a new gem in the my_gem_name directory. Firstly, open up my_gem_name.gemspec in your text editor and make sure you update all fields that contain TODO. Inside the directory, you should now have a fully working Rust gem.

💡 Tip: Join the Slack channel to ask questions and get help from the community!

Building the gem and running tests

The default Rake task is configured to compile the Rust code and run tests. Simply run:

$ bundle exec rake

At this point you should start reading the docs for magnus to get familiar with the API. It is designed to be a safe and idiomatic wrapper around the Ruby C API.

Next steps

Background and Concepts

World's Simplest Rust Extension

To illustrate how native extensions work, we are going to create the simplest possible native extension for Ruby. This is only to show the core concepts of how native extensions work under the hood.


#![allow(unused)]
fn main() {
// simplest_rust_extension.rs

// Define the libruby functions we need so we can use them (in a real gem, rb-sys would do this for you)
extern "C" {
    fn rb_define_global_function(name: *const u8, func: extern "C" fn() -> usize, arity: isize);
    fn rb_str_new(ptr: *const u8, len: isize) -> usize;
}

extern "C" fn hello_from_rust() -> usize {
    unsafe { rb_str_new("Hello, world!".as_ptr(), 12) }
}

// Initialize the extension
#[no_mangle]
unsafe extern "C" fn Init_simplest_rust_extension() {
    rb_define_global_function("hello_from_rust\0".as_ptr(), hello_from_rust, 0);
}
}

Then, it's a matter of compiling and running like so:

$ rustc --crate-type=cdylib simplest_rust_extension.rs -o simplest_rust_extension.bundle -C link-arg="-Wl,-undefined,dynamic_lookup"

$ ruby -r ./simplest_rust_extension -e "puts hello_from_rust" #=> "Hello, world!"

What is a native extension?

Typically, Ruby code is compiled to a special instruction set which executes on a stack-based virtual machine. You can see what these instructions look like by running:

$ ruby --dump=insns -e '2 + 3'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,5)> (catch: FALSE)
0000 putobject         2           (   1)[Li]
0002 putobject         3
0004 opt_plus          <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave

In this example, 2 and 3 are pushed onto the stack, and then opt_plus performs the addition.

For a native gem, we bypass this mechanism entirely and instead expose native machine code to Ruby. In our native code, we can use the Ruby C API to interact with the Ruby VM.

How are native Gems loaded?

Under the hood, native extensions are compiled as shared libraries (.so, .bundle, etc.). When you require 'some_gem', if Ruby finds a some_gem.(so|bundle|lib), the shared library is loaded on demand using dlopen (or the system equivalent). After that, Ruby will call Init_some_gem so the native library can do its magic.

Why does it work with Rust and not other languages?

C is often referred to as the "lingua franca" of the programming language world, and Rust is fluent. Rust can compile functions to be compatible with the C calling conventions, and align items in memory in a way that C understands. Rust also does not have a garbage collector, which makes integration significantly easier.

When Ruby loads a gem extension written in Rust, it has no idea the gem is actually written in Rust. Due to Rust's robust C FFI, you can code anything in Rust that you could with C.

Tutorial

Debugging

To debug Rust extensions, you can use either LLDB or GDB. First, you will need to compile with the dev Cargo profile, so debug symbols are available.

To do that you can run: RB_SYS_CARGO_PROFILE=dev rake compile. Alternatively, you can add a helper Rake task to make this easier:

# Rakefile

desc "Compile the extension with debug symbols"
task "compile:debug" do
  ENV["RB_SYS_CARGO_PROFILE"] = "dev"
  Rake::Task["compile"].invoke
end

💡 Tip: Join the Slack channel to ask questions and get help from the community!

VSCode + LLDB

The code-lldb extension for VSCode is a great way to debug Rust code. Here is an example configuration file:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "lldb",
      "request": "launch",
      "name": "Debug",
      "preLaunchTask": {
        "task": "compile:debug",
        "type": "rake"
      },
      "program": "~/.asdf/installs/ruby/3.1.1/bin/ruby",
      "args": ["-Ilib", "test/test_helper.rb"],
      "cwd": "${workspaceFolder}",
      "sourceLanguages": ["rust"]
    }
  ]
}

Debugging the Ruby C API

With this basic setup, you can set breakpoints and interactively debug your Rust code. However, if Ruby is not built with debug symbols, any calls into the Ruby C API become a black box. Luckily, it's straight-forward to fix this.

Compiling Ruby with debug symbols and source code

Using chruby or ruby-build

  1. First, compile Ruby like so:

    $ RUBY_CFLAGS="-Og -ggdb" ruby-build --keep 3.1.2 /opt/rubies/3.1.2-debug
    
  2. Make sure your .vscode/launch.json file is configured to use /opt/rubies/3.1.2-debug/bin/ruby.

Using rbenv

  1. First, compile Ruby like so:

    $ RUBY_CFLAGS="-Og -ggdb" rbenv install --keep 3.1.2
    
  2. Make sure your .vscode/launch.json file is configured to use $RBENV_ROOT/versions/3.1.2/bin/ruby.

Cross-Compilation

Publishing native gem binaries is incredibly important for Ruby on Rust gems. No one likes seeing the infamous Compiling native extensions. This could take a while... message when they install a gem. And in Rust, we all know that compiling can take a while...

It's important to make sure that your gem is as fast as possible to install, that's why rb-sys is built from the ground up to support this use-case. rb-sys integrates seamlessly with rake-compiler and rake-compiler-dock. By leveraging the hard-work of others, cross-compilation for Ruby gems is as simple and reliable as it would be for a C extension.

💡 Tip: Join the Slack channel to ask questions and get help from the community!

Using the rb-sys-dock helper

The rb-sys-dock executable allows you to easily enter the Docker container used to cross compile your gem. You can use your tool to build your gem, and then exit the container. The gem will be available in the pkg directory.

$ bundle exec rb-sys-dock -p aarch64-linux --build
$ ls pkg # => my_gem_name-0.1.0-aarch64-linux.gem

GitHub Actions

The oxi-test gem is meant to serve as the canonical example of how to setup cross gem compilation. Here's a walkthrough of the important files to reference:

  1. Setup the Rake::ExtensionTask in the Rakefile
  2. Setup a cross-gem.yml GitHub action to build the gem for multiple platforms.
  3. Download the cross-gem artifacts from the GitHub action and test them out.

In the wild

💡 Tip: Add your gem to this list by opening a PR!

Resources

Documenting

Generating documentation for your Ruby project is easy with YARD. Unfortunately, YARD doesn't support documenting Rust code. But don't worry! The yard-rustdoc gem can generate documentation for your Rust code and include it in your YARD documentation.

Check out the getting started guide to learn how to use yard-rustdoc.

API Documentation