Writing Ruby extensions in Rust: Part 1

Posted on November 17, 2018

Ruby is not known for its performance, and rewriting the whole application in a faster language is rarely an option in the real world. What to do if there are few bottlenecks in otherwise reasonably fast code? Native extensions to the rescue! Usually native extensions are written in C and there are some good tutorials, e.g. this one. But C code like below looks … scary?

#include <ruby.h>
#include <libclipboard.h>
#include "extconf.h"

static clipboard_c *cb = NULL;

VALUE set_text(VALUE _self, VALUE val) {
    Check_Type(val, T_STRING);
    VALUE result = Qnil;
    char *text = clipboard_text(cb);
    if (NULL != text) {
        result = rb_str_new(text, strlen(text));
        free(text);
    }
    if (false == clipboard_set_text(cb, StringValueCStr(val))) {
        rb_raise(rb_eRuntimeError, "Failed to write to clipboard.");
    }
    return result;
}

VALUE get_text(VALUE _self) {
    VALUE result = Qnil;
    char *text = clipboard_text(cb);
    if (NULL != text) {
        result = rb_str_new(text, strlen(text));
        free(text);
    }
    return result;
}

void Init_simple_clipboard() {
    cb = clipboard_new(NULL);
    if (NULL == cb) {
        rb_raise(rb_eRuntimeError, "Failed to create clipboard context.");
    }
    VALUE mod = rb_define_module("SimpleClipboard");
    rb_define_module_function(mod, "get_text", get_text, 0);
    rb_define_module_function(mod, "set_text", set_text, 1);
}

Is it possible to avoid at least some of the boilerplate and maybe use a slighty more modern language than C? One possibility is to use Rust and helix. Let’s create simple gem from scratch. I assume that Ruby and bundler are already installed (if you prefer system Ruby you will need ruby-dev package (Ubuntu) or similar for your system). We also need to install Rust:

curl https://sh.rustup.rs -sSf | sh

Let’s start from creating gem template:

$ bundle gem helix_csv

...

$ cd helix_csv

I chose name helix_csv for a reason. Later I plan to convert this gem to fast CSV parser. After fixing helix_csv.gemspec TODOs to make bundler happy and changing module HelixCsv to class HelixCSV in lib, we need to add helix dependency:

  spec.add_runtime_dependency "helix_runtime", "= 0.7.5"

and then run bundle install --path=vendor/bundle to install required gems. Next we need to replace Rakefile content with something like this:

require 'bundler/setup'
require 'helix_runtime/build_task'

HelixRuntime::BuildTask.new do |t|
end

task :default => :build

This will add tasks necessary to compile Rust code. Let’s add Rust template:

$ cargo init --lib

cargo for Rust is what bundler is for Ruby and much more. This command will create Cargo.toml (which is Rust’s equivalent of Gemfile) and Rust code template in src/lib.rs. We need to add [lib] section to Cargo.toml since we need dynamic system library:

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

and helix to the [dependencies] section:

[dependencies]
helix = "0.7.5"

Before we can start writing Rust code we need to create a glue between Ruby and Rust in lib/helix_csv.rb by adding these two lines to the top of that file:

require 'helix_runtime'
require 'helix_csv/native'

Now we can finally write gem code. Let’s replace content of src/lib.rs with:

#[macro_use]
extern crate helix;

ruby! {
    class HelixCSV {
        struct {
            n: i64,
        }

        def initialize(helix, n: i64) {
            HelixCSV { helix, n }
        }

        def n(&self) -> i64 {
            self.n
        }

        def add(&mut self, m: i64) -> i64 {
            self.n += m;
            self.n
        }
    }
}

To build extension we can either run rake build or just rake since default target is build anyway:

$ rake
{}
cargo rustc --release -- -C link-args=-Wl,-undefined,dynamic_lookup
    Updating crates.io index
   Compiling libcruby-sys v0.7.5
   Compiling libc v0.2.43
   Compiling cstr-macro v0.1.0
   Compiling helix v0.7.5
   Compiling helix_csv v0.1.0 (.../helix_csv)                  
    Finished release [optimized] target(s) in 8.50s    

Bundler has already created bin/console which will load our gem, so we can do some testing:

$ bin/console
2.5.3 :001 > helix_csv = HelixCSV.new(1)
 => #<HelixCSV:0x00005587b6c562a8> 
2.5.3 :002 > helix_csv.n
 => 1 
2.5.3 :003 > helix_csv.add(4)
 => 5 
2.5.3 :004 > helix_csv.n
 => 5 

Not terribly interesting gem, but it works! We can’t package the gem yet as gem install needs to build native extension and we need to instruct gem how to do it. It’s not very complicated though, we only need to add spec.extensions = %w[extconf.rb] to our helix_csv.gemspec and script extconf.rb to the root of our gem. The purpose of this script is to create Makefile:

abort "Rust compiler required (https://www.rust-lang.org/)" if `which rustc`.empty?

File.open("Makefile", "wb") do |f|

  f.puts(<<EOD)
all:
\tbundle --deployment --path vendor/bundle
\tbundle exec rake
clean:
install:
\trm -r vendor/bundle target
EOD

end

Since gem packager is using git to retrieve file list as a bare minimum we need to add files to the git cache (with appropriate .gitignore):

$ git add .

Finaly we can package our gem:

$ gem build helix_csv

Done! We can now publish the gem, or simply create new directory, add helix_csv-0.1.0.gem to vendor/cache, add something like this to Gemfile:

source "https://rubygems.org"

gem 'helix_csv'

then run:

$ bundle install --path=vendor/bundle
Fetching gem metadata from https://rubygems.org/....
Installing rake 12.3.1
Using bundler 1.17.1
Installing thor 0.20.3
Installing tomlrb 1.2.7
Installing helix_runtime 0.7.5 with native extensions
Installing helix_csv 0.1.0 with native extensions
Updating files in vendor/cache
Bundle complete! 1 Gemfile dependency, 6 gems now installed.
Bundled gems are installed into `./vendor/bundle`

$ bundle exec irb
2.5.3 :001 > require 'helix_csv'
 => true 
2.5.3 :002 > helix_csv = HelixCSV.new(1)
 => #<HelixCSV:0x00005587b6c562a8> 
2.5.3 :003 > helix_csv.n
 => 1 
2.5.3 :004 > helix_csv.add(4)
 => 5 
2.5.3 :005 > helix_csv.n
 => 5 

In the part 2 I’m going to implement CSV parser based on Rust CSV crate and do some benchmarks.