Recently, I explored how to embed Ruby code within an executable binary using Artichoke, an implementation of the Ruby language written in Rust. In this post, I’ll share my experience and provide a step-by-step guide so you can do the same. By the end, you’ll be able to create your own executable that includes and runs embedded Ruby code.
Note: The steps in this tutorial are based on guidance provided by the creator of Artichoke in this comment on a related GitHub issue
Prerequisites
To follow along, you’ll need to have Rust installed. Visit rustup.rs for installation instructions. In this tutorial, I used Ubuntu Linux, but you can adapt the commands to your operating system.
Setting Up the Project
Start by creating a new Rust project:
cargo new binrubyscript cd binrubyscript
This command creates a new directory called binrubyscript
with a basic Rust project template.
Adding Dependencies
We need to add two dependencies to our project:
artichoke
: The Ruby interpreter written in Rust.rust-embed
: A tool to embed static files into the binary.
Add rust-embed
to your Cargo.toml
:
cargo add rust-embed
Then, add Artichoke directly from the Git repository:
cargo add --git https://github.com/artichoke/artichoke artichoke
Note: Artichoke is still in pre-release, so we’re using the version directly from the Git repository.
Your Cargo.toml
should now look like this:
[package] name = "binrubyscript" version = "0.1.0" edition = "2021" [dependencies] rust-embed = "8.5.0" artichoke = { git = "https://github.com/artichoke/artichoke" }
Embedding Ruby Files into the Binary
Creating the Ruby Files
Create a directory to store your embedded Ruby files:
mkdir -p src/embedded_ruby
Create the file src/embedded_ruby/binrubyscript.rb
with the following content:
#!/usr/bin/env ruby puts "Hello World from embedded Ruby script using Artichoke! (#{__FILE__})"
Configuring Rust Embed
In the src/main.rs
file, add the following code to configure Rust Embed and embed the Ruby files:
use rust_embed::RustEmbed; // Define the Ruby files to be embedded #[derive(RustEmbed)] #[folder = "src/embedded_ruby"] struct Sources;
This code defines a Sources
struct that will embed all files in the src/embedded_ruby
directory into the binary.
Initializing the Artichoke Interpreter
In src/main.rs
, import the Artichoke prelude and instantiate the interpreter:
use artichoke::prelude::*; fn main() -> Result<(), Box<dyn std::error::Error>> { println!("Hello World from pure Rust!"); let mut interp = artichoke::interpreter()?; Ok(()) }
Here, we modify the main
function to return a Result
, which allows us to use the ?
operator for error handling. Then, we instantiate the Artichoke interpreter.
Loading the Embedded Ruby Files into the Interpreter
Add an init
function to load the embedded files into the Artichoke virtual file system:
// Function to load the embedded Ruby files into the Artichoke interpreter fn init(interp: &mut Artichoke) -> Result<(), Error> { for source in Sources::iter() { if let Some(content) = Sources::get(&source) { interp.def_rb_source_file(&*source, content.data)?; } } Ok(()) } fn main() -> Result<(), Box<dyn std::error::Error>> { println!("Hello World from pure Rust!"); let mut interp = artichoke::interpreter()?; init(&mut interp)?; Ok(()) }
Here, the init
function iterates over all embedded files and registers them with the interpreter.
Loading and Executing the Ruby Script
Now, let’s modify the main
function to load and execute the embedded Ruby script:
use std::path::Path; // ... fn main() -> Result<(), Box<dyn std::error::Error>> { // ... // Load and execute the embedded Ruby script match interp.eval_file(Path::new("binrubyscript.rb")) { Ok(_) => (), Err(err) => { eprintln!("Error executing Ruby script: {}", err); } } Ok(()) }
Here, we use interp.eval_file
to evaluate the binrubyscript.rb
file that was embedded.
Handling Errors from the Ruby Script
To ensure the program exits with an error code if the Ruby script fails, let’s modify the main
function:
use std::process; fn main() -> Result<(), Box<dyn std::error::Error>> { // ... // Load and execute the embedded Ruby script match interp.eval_file(Path::new("binrubyscript.rb")) { Ok(_) => (), Err(err) => { eprintln!("Error executing Ruby script: {}", err); process::exit(1); // Exit with error code 1 } } Ok(()) }
Now, if an error occurs while executing the Ruby script, the program will exit with a non-zero error code.
Final main.rs
File
use artichoke::prelude::*; use rust_embed::RustEmbed; use std::path::Path; // Import Path use std::process; // The Ruby source code to be embedded #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, RustEmbed)] #[folder = "src/embedded_ruby"] pub struct Sources; /// Load Ruby sources into the Artichoke virtual file system. /// /// # Errors /// /// If an exception is raised on the Artichoke interpreter, it is returned. pub fn init(interp: &mut Artichoke) -> Result<(), Error> { for source in Sources::iter() { if let Some(content) = Sources::get(&source) { interp.def_rb_source_file(&*source, content.data)?; } } Ok(()) } fn main() -> Result<(), Box<dyn std::error::Error>> { println!("Hello World from pure Rust!"); let mut interp = artichoke::interpreter()?; init(&mut interp)?; // Now load and run the Ruby script match interp.eval_file(Path::new("binrubyscript.rb")) { Ok(_) => (), Err(err) => { eprintln!("Error executing Ruby script: {}", err); process::exit(1); // Exit with code 1 } } Ok(()) }
Adding a Secondary Ruby File
To test whether multiple files are correctly embedded and loaded, let’s add a secondary Ruby file.
Creating the Secondary File
Create the file src/embedded_ruby/lib/secondary.rb
with the following content:
puts "Hello World from secondary.rb (#{__FILE__})"
Modify binrubyscript.rb
to require this file:
#!/usr/bin/env ruby require_relative "lib/secondary.rb" puts "Hello World from embedded Ruby script using Artichoke! (#{__FILE__})"
Testing the Project
Now we can compile and run our project to see everything in action:
cargo build --release cargo run
The expected output should be similar to:
Hello World from pure Rust! Hello World from secondary.rb (lib/secondary.rb) Hello World from embedded Ruby script using Artichoke! (binrubyscript.rb)
This confirms that the embedded Ruby script was successfully executed, including the required secondary file.
Testing Error Handling
To check how the program handles errors in the Ruby script, modify binrubyscript.rb
by adding a line that raises an exception:
raise "An intentional error for testing."
Run the program again:
cargo run
The output should contain the message:
Error executing Ruby script: Ruby exception: An intentional error for testing.
And the program’s exit code will be non-zero, indicating that an error occurred.
Conclusion
By following these steps, you’ve learned how to embed and execute Ruby code within a Rust executable using Artichoke. This process involved adding dependencies, configuring file embedding, initializing the Artichoke interpreter, loading and executing the Ruby code, and handling errors.
Important: The embedded Ruby scripts are included in their original, plain-text form within the binary. They are neither obfuscated nor compiled into bytecode.
Complete Source Code
The complete source code for this project is available on GitHub:
Feel free to clone the repository and experiment on your own.
References
I hope this guide has been helpful. If you have any questions or suggestions, please leave a comment below!