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
.
-
Install a Rust toolchain (if needed)
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-
Upgrade RubyGems and Bundler
$ gem update --system
-
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
-
First, compile Ruby like so:
$ RUBY_CFLAGS="-Og -ggdb" ruby-build --keep 3.1.2 /opt/rubies/3.1.2-debug
-
Make sure your
.vscode/launch.json
file is configured to use/opt/rubies/3.1.2-debug/bin/ruby
.
Using rbenv
-
First, compile Ruby like so:
$ RUBY_CFLAGS="-Og -ggdb" rbenv install --keep 3.1.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:
- Setup the
Rake::ExtensionTask
in theRakefile
- Setup a
cross-gem.yml
GitHub action to build the gem for multiple platforms. - 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
- Cross Gem Action to easily cross compile with GitHub actions
- Docker images
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
.