.daniel's musings - Writing Ruby With Go


Writing Ruby With Go

September 7, 2024 3:08 PM

A few days ago I came across this repository magnus. The project's description reads "High level Ruby bindings for Rust. Write Ruby extension gems in Rust, or call Ruby code from a Rust binary." I was intrigued by this and decided to give it a proper look.

A challenger appears

After looking at the project for a bit, I decided to write a little program that would run Ruby code from a Go binary. I wanted to see how easy it would be to run Ruby code from Go. The goals for this project were simple:

A lesson in C

For those who don't know, Ruby is written in C. This means that Ruby has a C API that you can use to interact with Ruby. This is how gems like nokogiri work. They use the C API to interact with Ruby. This is also how you can write Ruby extensions in C.

So in order to run Ruby code from Go, we need to use the C API. This is where understanding C comes in handy. I had to read a bit of the Ruby C API documentation to understand how to run Ruby code from C, thank you Dash.

The code

Lets start with the Ruby code. The Ruby code is simple, it just prints "Hello, World!".

puts "Hello, World!"

Now lets look at the C code. The C code is a bit more complex but also not really because we are relying on the Ruby C API to do most of the work.

Headers and includes

#ifndef RUBYWRAPPER_H
#define RUBYWRAPPER_H

#include <ruby.h>

void init_ruby();
VALUE eval_ruby_code(const char* code, int* state);
const char* value_to_cstr(VALUE v);
const char* get_error_message();
int is_string_type(VALUE v);

#endif //RUBYWRAPPER_H

Above we have the header file for our C code. We define a few functions that we will use in our C code. The functions are:

The implementation

#include "rubywrapper.h"
#include <string.h>

void init_ruby() {
    ruby_init();
    ruby_init_loadpath();
}

VALUE eval_ruby_code(const char* code, int* state) {
    return rb_eval_string_protect(code, state);
}

const char* value_to_cstr(VALUE v) {
    volatile VALUE str = rb_funcall(v, rb_intern("to_s"), 0);
    return RSTRING_PTR(str);
}

const char* get_error_message() {
    VALUE exception = rb_errinfo();
    rb_set_errinfo(Qnil);
    volatile VALUE message = rb_funcall(exception, rb_intern("message"), 0);
    return RSTRING_PTR(message);
}

int is_string_type(VALUE v) {
    return RB_TYPE_P(v, T_STRING);
}

Anything undefined in the above code is a Ruby C API function. The code is pretty self explanatory. We initialize Ruby, evaluate Ruby code, convert Ruby values to C strings, get error messages and check if a value is a string.

The Go code

package qu

/*
#cgo CFLAGS: -I/Users/dm/.asdf/installs/ruby/3.1.2/include/ruby-3.1.0 -I/Users/dm/.asdf/installs/ruby/3.1.2/include/ruby-3.1.0/x86_64-darwin22 -I/Users/dm/.asdf/installs/ruby/3.1.2/include
#cgo LDFLAGS: -L/Users/dm/.asdf/installs/ruby/3.1.2/lib -lruby
#include "rubywrapper.h"
*/

import "C"
import (
    "fmt"
    "unsafe"
)

func Initialize() {
	C.init_ruby()
}

func Eval(code string) (string, error) {
	cstr := C.CString(code)
	defer C.free(unsafe.Pointer(cstr))

	var state C.int
	result := C.eval_ruby_code(cstr, &state)

	if state != 0 {
		errMsg := C.GoString(C.get_error_message())
		return "", fmt.Errorf("Ruby exception: %s", errMsg)
	}

	if C.is_string_type(result) == 0 {
		return "", fmt.Errorf("result is not a string")
	}

	return C.GoString(C.value_to_cstr(result)), nil
}
package qu

/*
#include <ruby.h>
#include "rubywrapper.h"
*/
import "C"

type Value struct {
	rubyValue C.VALUE
}

// ToString converts a Ruby value to a Go string
func (v Value) ToString() string {
	return C.GoString(C.value_to_cstr(v.rubyValue))
}

NB: The paths in the #cgo directives are specific to my machine. You will need to change them to match your Ruby installation.

The Eval function is the most important function in the Go code. It takes a string of Ruby code, evaluates it and returns the result. If there is an error, it returns the error message. Again, we're relying on the Ruby C API to do most of the work, the only thing we're doing making sure that the Go code is safe and communicating with the C code.

Behold, the Ruby code

Now that we have all the code, we can run the Ruby code from Go. Here is the main Go code:

package qu

import (
	"fmt"

	"github.com/mmatongo/qu/qu"
)

func main() {
	qu.Initialize()

	result, _ := qu.Eval(`puts "Hello, World!";`)
	fmt.Println(result)
}

When you run the above code, you should see the following output:

Hello, World!

How it works (in a nutshell of details)

The first step is going to be to interface with the Ruby C API. This is done by writing a C wrapper around the Ruby C API. The C wrapper will have functions that will interact with the Ruby C API. The Go code will then call these functions to interact with Ruby. Considering that Go was designed to work with C, this is a pretty straightforward process. The only thing you need to be careful about is memory management.

In the Go code we defer the freeing of the C string that we allocate in the Eval function. This is to make sure that we free the memory after we're done with it. I had a few segfaults before I figured this out. One way of overcoming this would be to release the memory in the C code but keeping it in the Go code makes it easier to manage.

You'll also notice that we have a state variable in the Eval function thats passed to the eval_ruby_code function. This allows us to check if there was an error when evaluating the Ruby code. If there was an error, we can get the error message using the get_error_message function.

I should mention that this is a very simple example and uses what is regarded as the dirty way of interacting with Ruby. There are better ways of doing this and I will be exploring them in future posts. But for now, this is a good starting point.

Next steps

The next step is to write a more complex program that will interact with Ruby. I want to write a program that will call a Ruby function from Go. This will be a bit more complex but I think it will be a good exercise.

A rewrite of the above code using a more standard and idiomatic way of interacting with Ruby is also on the cards. I want to see how easy it is to write idiomatic Go code that interacts with Ruby.