Project Setup & Structure
Time: 25 minutes | Difficulty: Intermediate
Learn how to structure production-ready Ruby gems with Rust extensions. We'll build a URL parser that showcases real-world patterns.
Project Anatomyโ
A production rb-sys project has a specific structure. Let's understand each component:
my_gem/
โโโ ๐ฆ Cargo.toml # Workspace configuration
โโโ ๐ Gemfile # Ruby dependencies
โโโ ๐จ Rakefile # Build automation
โโโ ๐ my_gem.gemspec # Gem specification
โโโ ๐ README.md # Documentation
โโโ ๐ Gemfile.lock # Locked dependencies
โโโ ๐ ext/
โ โโโ my_gem/
โ โโโ ๐ฆ Cargo.toml # Extension dependencies
โ โโโ ๐ง extconf.rb # Build configuration
โ โโโ ๐๏ธ build.rs # Build script (optional)
โ โโโ ๐ src/
โ โโโ ๐ lib.rs # Rust entry point
โ โโโ ๐ *.rs # Additional modules
โโโ ๐ lib/
โ โโโ ๐ my_gem.rb # Ruby entry point
โ โโโ ๐ my_gem/
โ โโโ ๐ version.rb # Version constant
โ โโโ ๐ *.rb # Ruby modules
โโโ ๐งช test/ # Test files
โโโ ๐ benchmark/ # Performance tests
โโโ ๐ examples/ # Usage examples
Key Files Explainedโ
๐ฆ Cargo.toml (Workspace)
# Root Cargo.toml - Defines the workspace
[workspace]
members = ["ext/*"]
resolver = "2"
# Shared dependencies for all crates
[workspace.dependencies]
magnus = { version = "0.7", features = ["rb-sys"] }
rb-sys = "0.9"
๐ Gemspec Configuration
# my_gem.gemspec
Gem::Specification.new do |spec|
spec.name = "my_gem"
spec.version = MyGem::VERSION
spec.authors = ["Your Name"]
spec.email = ["you@example.com"]
spec.summary = "Fast URL parsing for Ruby"
spec.description = "A Ruby gem using Rust for blazing-fast URL parsing"
spec.homepage = "https://github.com/you/my_gem"
spec.license = "MIT"
# Gem files
spec.files = Dir[
"ext/**/*.{rs,toml,rb,lock}",
"lib/**/*.rb",
"LICENSE",
"README.md"
]
# Extension configuration
spec.extensions = ["ext/my_gem/extconf.rb"]
spec.require_paths = ["lib"]
# Ruby version requirement
spec.required_ruby_version = ">= 3.0"
# Dependencies
spec.add_dependency "rb_sys", "~> 0.9"
# Development dependencies
spec.add_development_dependency "rake", "~> 13.0"
spec.add_development_dependency "minitest", "~> 5.0"
end
๐จ Rakefile with rb-sys
# Rakefile
require "bundler/gem_tasks"
require "minitest/test_task"
Minitest::TestTask.create
# Add rb-sys extension task
require "rb_sys/extensiontask"
RbSys::ExtensionTask.new("my_gem") do |ext|
ext.lib_dir = "lib/my_gem"
end
task default: [:compile, :test]
# Custom tasks
desc "Run benchmarks"
task :bench => :compile do
ruby "benchmark/run.rb"
end
desc "Open console with extension loaded"
task :console => :compile do
exec "irb -r ./lib/my_gem.rb"
end
Real Example: URL Parser Gemโ
Let's build a production-ready URL parser using the Rust url crate. This demonstrates key patterns you'll use in real projects.
Step 1: Create the Gem Structureโ
# Create gem with Rust extension
bundle gem --ext=rust url_parser
cd url_parser
# Set up workspace
cat > Cargo.toml << 'EOF'
[workspace]
members = ["ext/*"]
resolver = "2"
[workspace.dependencies]
magnus = { version = "0.7", features = ["rb-sys"] }
url = "2.5"
EOF
Step 2: Configure the Extensionโ
# ext/url_parser/Cargo.toml
[package]
name = "url_parser"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
# High-level Ruby bindings with rb-sys feature
magnus = { version = "0.7", features = ["rb-sys"] }
# The main Rust library we're wrapping
url = "2.4"
[build-dependencies]
rb-sys-env = "0.1"
Step 3: Implement the Rust Extensionโ
// ext/url_parser/src/lib.rs
use magnus::{function, method, prelude::*, Error, Ruby};
use url::Url;
use std::fmt;
// Simple URL wrapper class
#[magnus::wrap(class = "UrlParser::URL")]
struct UrlWrapper {
inner: Url,
}
impl UrlWrapper {
// Parse a URL string
fn parse(url_str: String) -> Result<Self, Error> {
match Url::parse(&url_str) {
Ok(url) => Ok(UrlWrapper { inner: url }),
Err(err) => {
Err(Error::new(magnus::exception::arg_error(), format!("Invalid URL: {}", err)))
}
}
}
// Basic getters
fn scheme(&self) -> String {
self.inner.scheme().to_string()
}
fn host(&self) -> Option<String> {
self.inner.host_str().map(|s| s.to_string())
}
fn path(&self) -> String {
self.inner.path().to_string()
}
fn query(&self) -> Option<String> {
self.inner.query().map(|s| s.to_string())
}
// String representation of the URL
fn to_string_ruby(&self) -> String {
self.inner.to_string()
}
}
impl fmt::Display for UrlWrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.inner)
}
}
// Module-level utilities
fn is_valid_url(url_str: String) -> bool {
Url::parse(&url_str).is_ok()
}
// Module init function - Ruby extension entry point
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
// Define the main module
let module = ruby.define_module("UrlParser")?;
// Add utility function at module level
module.define_singleton_method("valid_url?", function!(is_valid_url, 1))?;
// Define and configure the URL class
let class = module.define_class("URL", ruby.class_object())?;
class.define_singleton_method("parse", function!(UrlWrapper::parse, 1))?;
// Instance methods
class.define_method("scheme", method!(UrlWrapper::scheme, 0))?;
class.define_method("host", method!(UrlWrapper::host, 0))?;
class.define_method("path", method!(UrlWrapper::path, 0))?;
class.define_method("query", method!(UrlWrapper::query, 0))?;
class.define_method("to_s", method!(UrlWrapper::to_string_ruby, 0))?;
Ok(())
}
Step 4: Create Ruby Wrapperโ
The Ruby side provides a clean API and additional conveniences:
# lib/url_parser.rb
require_relative "url_parser/version"
require_relative "url_parser/url_parser"
module UrlParser
class Error < StandardError; end
# Parse a URL string and return a URL object
def self.parse(url_string)
URL.parse(url_string)
rescue => e
raise Error, "Failed to parse URL: #{e.message}"
end
# Check if a URL has an HTTPS scheme
def self.https?(url_string)
return false unless valid_url?(url_string)
url = parse(url_string)
url.scheme == "https"
end
end
Step 5: Add Comprehensive Testsโ
# test/test_url_parser.rb
require "test_helper"
class TestUrlParser < Minitest::Test
def test_basic_url_parsing
url = UrlParser::URL.parse("https://example.com/path?query=value")
assert_equal "https", url.scheme
assert_equal "example.com", url.host
assert_equal "/path", url.path
assert_equal "query=value", url.query
end
def test_url_validation
assert UrlParser.valid_url?("https://example.com")
refute UrlParser.valid_url?("not a url")
end
def test_https_check
assert UrlParser.https?("https://example.com")
refute UrlParser.https?("http://example.com")
end
def test_invalid_url_raises_error
assert_raises UrlParser::Error do
UrlParser.parse("not://a.valid/url")
end
end
end
Advanced Project Patternsโ
Workspace Benefitsโ
Using a Cargo workspace provides:
- Shared dependencies - One
Cargo.lock
for reproducible builds - Faster compilation - Dependencies compiled once
- Multiple extensions - Easy to add more Rust crates
- Unified testing - Run all Rust tests together
Build Configurationโ
Custom extconf.rb Options
# ext/my_gem/extconf.rb
require "mkmf"
require "rb_sys/mkmf"
create_rust_makefile("my_gem/my_gem") do |r|
# Enable link-time optimization
r.profile = ENV.fetch("RB_SYS_CARGO_PROFILE", "release")
# Add feature flags
r.features = ["performance"] if ENV["ENABLE_PERF"]
# Extra Rust flags
r.extra_rustflags = ["--cfg", "ruby_3_0"] if RUBY_VERSION >= "3.0"
# Target specific configuration
if RbConfig::CONFIG["target_os"] == "darwin"
r.target = "x86_64-apple-darwin"
end
end
Build Script (build.rs)
// ext/my_gem/build.rs
fn main() {
// rb-sys automatically sets up the build environment
// You can add custom build configuration here
println!("cargo:rustc-cfg=has_custom_feature");
// Link to system libraries if needed
#[cfg(target_os = "macos")]
println!("cargo:rustc-link-lib=framework=Security");
}
Dependency Managementโ
# ext/my_gem/Cargo.toml
[dependencies]
# Required dependencies
magnus = { version = "0.7", features = ["rb-sys"] }
# Feature-gated dependencies
serde = { version = "1.0", optional = true }
serde_json = { version = "1.0", optional = true }
[features]
default = []
json = ["serde", "serde_json"]
performance = ["lto"]
[profile.release]
lto = "thin"
codegen-units = 1
opt-level = 3
Error Handling Strategyโ
use magnus::{Error, exception};
use std::fmt;
// Custom error type
#[derive(Debug)]
enum UrlError {
InvalidScheme(String),
InvalidHost(String),
ParseError(url::ParseError),
}
impl fmt::Display for UrlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UrlError::InvalidScheme(s) => write!(f, "Invalid scheme: {}", s),
UrlError::InvalidHost(h) => write!(f, "Invalid host: {}", h),
UrlError::ParseError(e) => write!(f, "Parse error: {}", e),
}
}
}
// Convert to Ruby exceptions
impl From<UrlError> for Error {
fn from(err: UrlError) -> Self {
match err {
UrlError::InvalidScheme(_) | UrlError::InvalidHost(_) => {
Error::new(exception::arg_error(), err.to_string())
}
UrlError::ParseError(_) => {
Error::new(exception::runtime_error(), err.to_string())
}
}
}
}
Project Organization Best Practicesโ
1. Module Structureโ
ext/my_gem/src/
โโโ lib.rs # Entry point and module declarations
โโโ types.rs # Type definitions and conversions
โโโ errors.rs # Error types and handling
โโโ implementation/ # Core functionality
โ โโโ mod.rs
โ โโโ parser.rs
โ โโโ validator.rs
โโโ ruby_api.rs # Ruby class/module definitions
2. Testing Strategyโ
test/
โโโ test_helper.rb # Common test utilities
โโโ unit/ # Unit tests
โ โโโ test_parser.rb
โ โโโ test_errors.rb
โโโ integration/ # Integration tests
โ โโโ test_api.rb
โโโ performance/ # Benchmark tests
โโโ bench_parser.rb
3. Documentationโ
# Document Ruby methods with YARD
module UrlParser
# Parses a URL string into components
#
# @param url_string [String] the URL to parse
# @return [URL] parsed URL object
# @raise [Error] if URL is invalid
#
# @example Parse a simple URL
# url = UrlParser.parse("https://example.com")
# url.scheme #=> "https"
def self.parse(url_string)
# Implementation
end
end
Shipping Your Gemโ
Pre-release Checklistโ
- All tests passing
- Benchmarks show expected performance
- Documentation complete (README, YARD)
- CHANGELOG updated
- Version bumped
- Platform compatibility verified
Building Platform Gemsโ
# Build native gems for multiple platforms
rake native gem
# This creates:
# - url_parser-1.0.0.gem (source gem)
# - url_parser-1.0.0-x86_64-linux.gem
# - url_parser-1.0.0-x86_64-darwin.gem
# - url_parser-1.0.0-arm64-darwin.gem
๐ฏ Key Takeawaysโ
- Use workspaces for better dependency management
- Structure for growth - organize code into modules
- Handle errors gracefully - map to Ruby exceptions
- Test comprehensively - unit, integration, performance
- Document thoroughly - both Ruby and Rust code
- Plan for platforms - test across OS and Ruby versions
๐ Next Stepsโ
- Basic Patterns - Common implementation patterns
- Working with Ruby Objects - Manipulating Ruby data
- Testing Extensions - Comprehensive testing strategies
- Cross-Platform Development - Building for all platforms