Open-source project
matsadler/magnus avatar
matsadler/magnus

magnus: Ruby bindings for Rust, in both directions

Ruby bindings for Rust. Write Ruby extension gems in Rust, or call Ruby from Rust.

917 stars56 forksRustMIT

At a glance

What is it?
Magnus is a Rust crate that lets you write a Ruby extension gem in Rust, or call Ruby code from a Rust binary, with automatic type conversion and Ruby objects wrapping Rust structs. It is aimed at teams that want Rust performance inside a Ruby codebase, or Ruby libraries inside a Rust service.
Who is it for?
Adopt magnus when you have an existing Ruby codebase that needs a hot path in Rust, or a Rust service that needs to drive Ruby libraries, and you are prepared to work with the Ruby garbage collector's ownership model. Skip it if you need a language-neutral binding, since the API is deliberately Ruby shaped, or if you want to avoid the C API entirely, because Magnus sits on rb-sys and links against a Ruby runtime.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • matsadler/magnus917 ★ · +3
Star history
Repository2026-10-232026-11-07
matsadler/magnus914917

3 Stars in the last 15 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

Two directions of embedding, one crate

Magnus is described as high level Ruby bindings for Rust, and the description states both directions in one sentence: write Ruby extension gems in Rust, or call Ruby code from a Rust binary.

That second direction is the one people forget. Most Ruby-to-Rust bridges only let you expose Rust functions to Ruby, which leaves you with a one-way street if you want to reuse an existing Ruby gem from a Rust service. Magnus supports both, and the same `Ruby` handle type is the entry point to the interpreter in either direction.

The design goal is that a Ruby developer should not have to learn the C API. The README's first example binds an ordinary Rust function to Ruby as a method with automatic type conversion, and the promise is that callers passing the wrong arguments or incompatible types get the same kind of `ArgumentError` or `TypeError` they are used to seeing from Ruby's built in methods.

That promise is the whole value proposition of a high level binding layer. The hard part of writing a Ruby extension in C is not the arithmetic, it is the type conversion and the error handling, because the conventions for those are Ruby specific. Magnus encodes them.

The crate is published on crates.io as `magnus`, the API documentation is on docs.rs, and the repository carries a CHANGELOG.md, a `Gemfile` for the Ruby side of the test suite, a `magnus-macros/` workspace member for the proc macros, and an `examples/` directory with eight worked examples.

The init function is the entry point

When Ruby loads an extension it calls an 'init' function defined in your extension. In that function you define your Ruby classes and bind Rust functions to Ruby methods, and the `#[magnus::init]` attribute marks the function so it is correctly exposed to Ruby.

The simplest example defines a global function:

rust
fn fib(n: usize) -> usize {
    match n {
        0 => 0,
        1 | 2 => 1,
        _ => fib(n - 1) + fib(n - 2),
    }
}

#[magnus::init]
fn init(ruby: &magnus::Ruby) -> Result<(), Error> {
    ruby.define_global_function("fib", magnus::function!(fib, 1))?;
    Ok(())
}

The `function!` macro takes the function and an arity, and the arity is what Ruby uses to raise the right `ArgumentError` when the count is wrong. The function returns `Result<(), Error>`, so a failure to define the function is reported to Ruby rather than swallowed.

Methods are the same shape with one extra parameter, the Ruby `self`. The example defines `blank?` on `String`:

rust
fn is_blank(rb_self: String) -> bool {
    !rb_self.contains(|c: char| !c.is_whitespace())
}

#[magnus::init]
fn init(ruby: &magnus::Ruby) -> Result<(), Error> {
    // returns the existing class if already defined
    let class = ruby.define_class("String", ruby.class_object())?;
    // 0 as self doesn't count against the number of arguments
    class.define_method("blank?", magnus::method!(is_blank, 0))?;
    Ok(())
}

Two details are worth copying. `define_class` returns the existing class if it is already defined, so re-opening `String` rather than replacing it is safe and is the normal Ruby idiom. And the arity of 0 in `method!(is_blank, 0)` is because `self` does not count against the argument count, which is exactly how Ruby behaves for its own methods.

The `ruby.class_object()` argument is the superclass, so this is how you reopen an existing class rather than defining a new one.

Type conversion and funcall cover both kinds of Ruby method

Not every Ruby method has a direct counterpart in Magnus, and the README splits the cases in two.

Some Ruby methods have direct counterparts in Ruby's C API and therefore in Magnus. Ruby's `Object#frozen?` method is available as `magnus::ReprValue::check_frozen`, and `Array#[]` becomes `magnus::RArray::aref`. Those are the calls that a direct binding can make without going through the interpreter's dispatch.

Other Ruby methods, defined only in Ruby, must be called with `magnus::ReprValue::funcall`. All of Magnus' Ruby wrapper types implement the `ReprValue` trait, so `funcall` can be used on all of them:

rust
let s: String = value.funcall("test", ())?; // 0 arguments
let x: bool = value.funcall("example", ("foo",))?; // 1 argument
let i: i64 = value.funcall("other", (42, false))?; // 2 arguments, etc

The return type of `funcall` drives the conversion, and it returns `Err(magnus::Error)` if the type conversion fails or if the method call raised an error. If you want to skip conversion entirely, the return type should be `magnus::Value`.

That last point is the practical performance lever. `funcall` with a concrete Rust type on the left converts the result, which costs something and also means a Ruby exception surfacing as a Rust error. Returning `magnus::Value` keeps the raw object and pushes the conversion to the point where you actually need it.

This is also where the automatic conversion shows its edges. A Ruby method that returns something unexpected, say `nil` where you declared `String`, produces a conversion error rather than a Ruby exception at the call site, and the two failure modes are distinguishable in the error type.

Wrapping Rust structs so Ruby can hold them

The other half of Magnus is exposing Rust types to Ruby, so that a Ruby program can hold a Rust value as an ordinary object.

There are two routes, as the README puts it. There is a convenience macro, `#[magnus::wrap]`, and there is a more customised approach by implementing the `magnus::TypedData` trait.

The macro route looks like this:

rust
use magnus::{function, method, prelude::*, Error, Ruby};

#[magnus::wrap(class = "Point")]
struct Point {
    x: isize,
    y: isize,
}

impl Point {
    fn new(x: isize, y: isize) -> Self {
        Self { x, y }
    }

    fn x(&self) -> isize {
        self.x
    }

The wrapped type can then be returned to Ruby as a wrapped object, and passed back to Rust, where it is automatically unwrapped to a native Rust reference. That round trip is what makes the pattern usable: a Ruby object holding a Rust value can be handed to a Rust function and arrives as a typed reference, with no marshalling in between.

The `class` argument names the Ruby class the type is exposed as, and the methods are bound with `method!(Point::x, 0)` in the same way as the free functions. `define_singleton_method("new", function!(Point::new, 2))` gives the class its constructor.

The `TypedData` trait is the manual alternative for when the macro is not enough, and it is also the mechanism the Cargo feature list is built around: `typed_data` is a feature, and many of the other features, `block`, `debug`, `fiber`, `mutex`, `thread` and `time`, all enable it. That dependency is not accidental. A wrapped Rust object is attached to the Ruby object as typed data, and the feature flags control how that data is protected and how it interacts with Ruby's own features.

Ruby's GC owns the memory, so RefCell is the pattern

The mutability section explains a constraint that surprises people coming from Rust.

Because Ruby's GC manages the memory where your Rust type is stored, Magnus can't bind functions with mutable references. A Rust function that takes `&mut self` cannot be bound to a Ruby method, because Ruby decides when and whether to call it and cannot promise the borrow will be valid for the duration.

The workaround is the newtype pattern with `RefCell`:

rust
use std::cell::RefCell;

struct Point {
    x: isize,
    y: isize,
}

#[magnus::wrap(class = "Point")]
struct MutPoint(RefCell<Point>);

impl MutPoint {
    fn set_x(&self, i: isize) {
        self.0.borrow_mut().x = i;
    }
}

The wrapped type holds a `RefCell`, and mutation goes through `borrow_mut()`. The runtime borrow checking that Rust gives you for free in ordinary code now happens at the point of the `borrow_mut` call, and a Ruby caller that triggers two overlapping borrows gets a panic rather than undefined behaviour. The README points at `examples/mut_point.rs` for the complete example.

Error handling follows the same pattern of using Ruby's own exception classes. The example raises a `RangeError` from Rust:

rust
#[magnus::wrap(class = "Point")]
struct MutPoint(RefCell<Point>);

impl MutPoint {
    fn add_x(ruby: &Ruby, rb_self: &Self, val: isize) -> Result<isize, Error> {
        if let Some(sum) = rb_self.0.borrow().x.checked_add(val) {
            rb_self.0.borrow_mut().x = sum;
            Ok(sum)
        } else {
            return Err(Error::new(ruby.exception_range_error(), "result out of range"));
        }
    }
}

Notice the method takes `ruby: &Ruby` as its first parameter when it needs the interpreter, and that the error carries a Ruby exception instance rather than a string. That is what makes the failure look like a normal Ruby exception to the code that catches it.

Subclassing needs Default, an allocator and an initialiser

Ruby's object model allows a class to be subclassed, and for a wrapped Rust type to participate in that, the README lists three requirements. The type must implement the `Default` trait, define an allocator, and define an initialiser.

rust
#[derive(Default)]
#[wrap(class = "Point")]
struct MutPoint(RefCell<Point>);

impl MutPoint {
    fn initialize(&self, x: isize, y: isize) {
        let mut this = self.0.borrow_mut();
        this.x = x;
        this.y = y;
    }
}

The allocator is bound on the class with `class.define_alloc_func::<MutPoint>()`, and the initialiser is bound as a method named `initialize`, which is the Ruby convention for the constructor hook that runs before `new` returns.

Why all three? Ruby allocates the object, so Rust needs a way to allocate the value it stores, and it needs a default-constructed state to start from before `initialize` fills it in. `Default` on the inner type gives the zero value, the alloc func tells Ruby how to make one, and `initialize` sets the fields.

This is the part of the API that most directly shapes how you write the Rust side, and it is why the examples directory includes `inheritance.rs` alongside `point.rs` and `mut_point.rs`.

Cargo features decide how much of Ruby you pull in

The Cargo.toml is where the scope of Magnus becomes clear.

The default feature set is long and specific: `block`, `debug`, `fiber`, `io`, `mutex`, `numeric`, `process`, `range`, `r_complex`, `r_file`, `r_match`, `r_rational`, `r_regexp`, `r_struct`, `thread`, `time` and `typed_data`. Optional extras include `bytes` for the bytes crate, `chrono` for date and time conversions, and `embed`, which pulls in `rb-sys/link-ruby`.

That feature list is the practical guide to what Magnus covers, because it maps one to one onto the parts of the Ruby standard library you can convert to and from. A `numeric` feature for numbers, `r_complex` for complex numbers, `r_rational` for rationals, `r_regexp` and `r_match` for regular expressions and match data, `r_file` for files, `r_struct` for structs, and `r_struct` in turn enables `range`. The `r_` prefix distinguishes the types that come from Ruby's C API from the general features.

The underlying dependency is `rb-sys` at 0.9.124, with `default-features = false` and the features `bindgen-rbimpls`, `bindgen-deprecated-types` and `stable-api`. The `stable-api` feature is the important one: it lets Magnus bind against Ruby's stable API rather than the internals, so the extension you build keeps working across Ruby versions that keep that API stable. The `bindgen` features mean the bindings are generated at build time rather than checked in.

The crate itself is `edition = "2024"` with `rust-version = "1.85"`, and there is a workspace member `magnus-macros` for the proc macros. The `examples/` directory is a working guide: `hello_world.rs`, `fibonacci.rs`, `point.rs`, `mut_point.rs`, `inheritance.rs`, and directories for `complete_object`, `custom_exception_ruby`, `custom_exception_rust` and `rust_blank`.

The licence field on the repository says MIT, and the package.json equivalent in Cargo.toml agrees.

On releases, note a small inconsistency worth checking before you pin. The Cargo.toml in the repository reads `version = "0.10.0"` while the tagged releases listed are 0.9.3 and 0.8.4, both dated 2026-10-16, with 0.9.2 on 2026-10-03. The manifest is ahead of the tags, so a `magnus = "0.9"` dependency line in an extension's Cargo.toml, as the README shows, resolves to the 0.9 series rather than the manifest version.

Editorial conclusion

Adopt magnus when you have an existing Ruby codebase that needs a hot path in Rust, or a Rust service that needs to drive Ruby libraries, and you are prepared to work with the Ruby garbage collector's ownership model. Skip it if you need a language-neutral binding, since the API is deliberately Ruby shaped, or if you want to avoid the C API entirely, because Magnus sits on rb-sys and links against a Ruby runtime. Verify first that the Ruby version you target is supported by the rb-sys features listed in Cargo.toml, including `stable-api`, and that the Cargo features you enable cover the types you need to convert. The licence field on the repository says MIT, the last push was on 2026-10-18, and the current tagged releases are 0.9.3 and 0.8.4.

Frequently asked questions

What is magnus used for?

Magnus is a high level Ruby bindings crate for Rust. It lets you write Ruby extension gems in Rust, or call Ruby code from a Rust binary, with automatic type conversion so that wrong arguments or incompatible types produce the same `ArgumentError` or `TypeError` a Ruby developer expects.

How do I set up a Rust Ruby extension gem with magnus?

Set the `crate-type` attribute in Cargo.toml so the crate builds as a dynamic system library, with `crate-type = ["cdylib"]` and a dependency on `magnus = "0.9"`. Then mark your init function with `#[magnus::init]` and use it to define your Ruby classes and bind your Rust functions.

How do I call a Ruby method from Rust with magnus?

Methods with a direct counterpart in Ruby's C API are available as Rust methods, for example `magnus::ReprValue::check_frozen` for `Object#frozen?` and `magnus::RArray::aref` for `Array#[]`. Methods defined only in Ruby are called with `magnus::ReprValue::funcall`, which returns `Err(magnus::Error)` if the call raises or the conversion fails.

Why can a magnus wrapped Rust type not use mutable references?

Because Ruby's GC manages the memory where the Rust type is stored, Magnus cannot bind functions with mutable references. The README's approach is the newtype pattern with `RefCell`, wrapping the struct so mutation happens through `borrow_mut()`, as shown in the `examples/mut_point.rs` example.

How do I let Ruby subclass a magnus wrapped type?

The type must implement the `Default` trait, define an allocator with `class.define_alloc_func::<MutPoint>()`, and define an initialiser bound as a method named `initialize`. The `examples/inheritance.rs` example shows the full pattern.

What licence is magnus released under?

The licence field on the repository says MIT, and the Cargo.toml declares the same licence with the description high level Ruby bindings. The tagged releases are 0.9.3 and 0.8.4 on 2026-10-16 and 0.9.2 on 2026-10-03, with the last push on 2026-10-18.

Official sources

  1. License: MIT
  2. matsadler/magnus on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/matsadler-magnus.svg)](https://hysenlabs.com/projects/matsadler-magnus)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/matsadler-magnus.svg)](https://hysenlabs.com/projects/matsadler-magnus)

Was this useful?