From c917dc17b6e23dd51529fc2762073cfc2bd9e022 Mon Sep 17 00:00:00 2001 From: Oliver Morgan Date: Sun, 7 Jun 2026 14:45:10 +0100 Subject: [PATCH 1/2] Add TruffleRuby support by replacing Magnus with rb-sys This refactors the native Rust extension to use direct rb-sys bindings instead of Magnus, allowing the gem to compile and run on TruffleRuby while preserving MRI support. Magnus depends on CRuby internals that are not available on TruffleRuby, so the native boundary now uses public Ruby C-API-compatible rb-sys calls only. Changes - Replace magnus with rb-sys in the native extension. - Implement MRML::Template wrapping with Ruby typed data. - Add explicit Ruby callback error handling to avoid raising across Rust stack frames. - Use zero-copy string reads for Ruby input strings. - Return UTF-8 tagged Ruby strings from native output. - Implement clone / dup using Rust-side AST cloning instead of serialize-and-reparse. - Add TruffleRuby to the GitHub Actions Ruby matrix. - Add tests for TruffleRuby-relevant type handling, invalid UTF-8 input, UTF-8 output encoding, and clone behavior. - Document TruffleRuby source-install support. Verification Tested successfully on both engines: RBENV_VERSION=truffleruby-34.0.1 bundle exec rake clobber compile test RBENV_VERSION=3.4.9 bundle exec rake clobber compile test Both passed with: 14 runs, 28 assertions, 0 failures, 0 errors, 0 skips --- .github/workflows/build.yml | 2 +- Cargo.lock | 37 +---- ext/mrml/Cargo.toml | 2 +- ext/mrml/src/lib.rs | 321 +++++++++++++++++++++++++++++------- test/mrml_test.rb | 49 ++++++ 5 files changed, 314 insertions(+), 97 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fb9060..218b038 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - ruby: ['3.2', '3.3', '3.4', '4.0'] + ruby: ['3.2', '3.3', '3.4', '4.0', 'truffleruby'] runs-on: ${{ matrix.os }} steps: diff --git a/Cargo.lock b/Cargo.lock index 224d6b7..f74b036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,29 +187,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "magnus" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012" -dependencies = [ - "magnus-macros", - "rb-sys", - "rb-sys-env", - "seq-macro", -] - -[[package]] -name = "magnus-macros" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "memchr" version = "2.7.6" @@ -226,8 +203,8 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" name = "mrml" version = "0.1.0" dependencies = [ - "magnus", "mrml 5.1.0", + "rb-sys", "serde", "serde_json", ] @@ -307,12 +284,6 @@ dependencies = [ "syn", ] -[[package]] -name = "rb-sys-env" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a" - [[package]] name = "regex" version = "1.12.2" @@ -354,12 +325,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "seq-macro" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" - [[package]] name = "serde" version = "1.0.228" diff --git a/ext/mrml/Cargo.toml b/ext/mrml/Cargo.toml index bb3b81b..f5deaab 100644 --- a/ext/mrml/Cargo.toml +++ b/ext/mrml/Cargo.toml @@ -6,7 +6,7 @@ edition = "2018" [dependencies] mrml = "5.1" -magnus = "0.8" +rb-sys = "0.9" [dependencies.serde] version = "1.0" diff --git a/ext/mrml/src/lib.rs b/ext/mrml/src/lib.rs index bdd063b..349e899 100644 --- a/ext/mrml/src/lib.rs +++ b/ext/mrml/src/lib.rs @@ -1,46 +1,40 @@ -use magnus::{ - function, method, prelude::*, value::Lazy, Ruby, - Error, ExceptionClass, RModule -}; +use std::ffi::c_void; +use std::os::raw::{c_char, c_long}; +use std::panic::{self, AssertUnwindSafe}; +use std::ptr; +use std::slice; +use std::{mem, result}; use mrml::mjml::Mjml; use mrml::prelude::print::Printable; use mrml::prelude::render::RenderOptions; -static MODULE: Lazy = - Lazy::new(|ruby| ruby.class_object().const_get("MRML").unwrap()); - -static ERROR: Lazy = - Lazy::new(|ruby| ruby.get_inner(&MODULE).const_get("Error").unwrap()); - -fn mrml_error() -> ExceptionClass { - Ruby::get().unwrap().get_inner(&ERROR) -} - -macro_rules! error { - ($ex:ident) => { - Error::new(mrml_error(), $ex.to_string()) - }; -} +use rb_sys::{ + rb_cObject, rb_data_type_struct__bindgen_ty_1, rb_data_type_t, + rb_data_typed_object_wrap, rb_define_class_under, rb_define_method, + rb_define_module, rb_define_singleton_method, rb_eTypeError, rb_exc_new, + rb_exc_raise, rb_intern, rb_obj_class, rb_utf8_str_new, rb_const_get, + rb_undef_alloc_func, ruby_value_type, size_t, VALUE, Qnil, RB_TYPE, + RSTRING_LEN, RSTRING_PTR, RTYPEDDATA_GET_DATA, RTYPEDDATA_P, + RTYPEDDATA_TYPE +}; -#[magnus::wrap(class = "MRML::Template", free_immediately, size)] +#[derive(Clone)] struct Template { res: Mjml } impl Template { - fn new(input: String) -> Result { - match mrml::parse(&input) { - Ok(output) => Ok(Self { res: output.element }), - Err(ex) => Err(error!(ex)) - } + fn new(input: &str) -> Result { + mrml::parse(input) + .map(|output| Self { res: output.element }) + .map_err(|ex| ex.to_string()) } - fn from_json(input: String) -> Result { - match serde_json::from_str::(&input) { - Ok(res) => Ok(Self { res }), - Err(ex) => Err(error!(ex)) - } + fn from_json(input: &str) -> Result { + serde_json::from_str::(input) + .map(|res| Self { res }) + .map_err(|ex| ex.to_string()) } fn get_title(&self) -> Option { @@ -51,48 +45,257 @@ impl Template { self.res.get_preview() } - fn to_mjml(&self) -> String { - self.res.print_dense().unwrap() + fn to_mjml(&self) -> Result { + self.res.print_dense().map_err(|ex| ex.to_string()) + } + + fn to_json(&self) -> Result { + serde_json::to_string(&self.res).map_err(|ex| ex.to_string()) + } + + fn to_html(&self) -> Result { + self.res.render(&RenderOptions::default()).map_err(|ex| ex.to_string()) } +} + +const TEMPLATE_TYPE_NAME: &[u8] = b"MRML::Template\0"; + +enum AppError { + Mrml(String), + Type(String) +} - fn to_json(&self) -> Result { - match serde_json::to_string(&self.res) { - Ok(res) => Ok(res), - Err(ex) => Err(error!(ex)) - } +impl From for AppError { + fn from(error: String) -> Self { + Self::Mrml(error) } +} - fn to_html(&self) -> Result { - match self.res.render(&RenderOptions::default()) { - Ok(res) => Ok(res), - Err(ex) => Err(error!(ex)) - } +impl From for AppError { + fn from(_error: std::str::Utf8Error) -> Self { + Self::Type("input string must be valid UTF-8".to_string()) } } -impl Clone for Template { - fn clone(&self) -> Self { - Self::new(self.to_mjml()).unwrap() +struct TemplateDataType(rb_data_type_t); + +// Ruby treats rb_data_type_t as immutable process-wide metadata after init. +unsafe impl Sync for TemplateDataType {} + +static TEMPLATE_TYPE: TemplateDataType = TemplateDataType( + rb_data_type_t { + wrap_struct_name: TEMPLATE_TYPE_NAME.as_ptr() as *const c_char, + function: rb_data_type_struct__bindgen_ty_1 { + dmark: None, + dfree: Some(template_free), + dsize: Some(template_size), + dcompact: None, + reserved: [ptr::null_mut(); 1] + }, + parent: ptr::null(), + data: ptr::null_mut(), + flags: 0 + } +); + +unsafe extern "C" fn template_free(ptr: *mut c_void) { + if !ptr.is_null() { + drop(Box::from_raw(ptr as *mut Template)); } } -#[magnus::init] -fn init(ruby: &Ruby) -> Result<(), Error> { - let module = ruby.define_module("MRML")?; - let class = module.define_class("Template", ruby.class_object())?; +unsafe extern "C" fn template_size(_ptr: *const c_void) -> size_t { + mem::size_of::