rb_sys_test_helpers_macros/lib.rs
1#![allow(rustdoc::bare_urls)]
2#![doc = include_str!("../readme.md")]
3
4use proc_macro::{TokenStream, TokenTree};
5use quote::quote;
6use syn::{spanned::Spanned, ItemFn, ReturnType};
7
8/// A proc-macro which generates a `#[test]` function has access to a valid Ruby VM.
9///
10/// Doing this properly it is not trivial, so this function abstracts away the
11/// details. Under the hood, it ensures:
12///
13/// 1. The Ruby VM is setup and initialized once and only once.
14/// 2. All code runs on the same OS thread.
15/// 3. Exceptions are properly handled and propagated as Rust `Result<T,
16/// RubyException>` values.
17///
18/// ### Example
19///
20/// ```
21/// use rb_sys_test_helpers_macros::ruby_test;
22///
23/// #[ruby_test]
24/// fn test_it_works() {
25/// unsafe { rb_sys::rb_eval_string("1 + 1\0".as_ptr() as _) };
26/// }
27///
28/// #[ruby_test(gc_stress)]
29/// fn test_with_stress() {
30/// unsafe { rb_sys::rb_eval_string("puts 'GC is stressing me out.'\0".as_ptr() as _) };
31/// }
32/// ```
33///
34/// Tests can also return a `Result` to use the `?` operator:
35///
36/// ```
37/// use rb_sys_test_helpers_macros::ruby_test;
38/// use std::error::Error;
39///
40/// #[ruby_test]
41/// fn test_with_result() -> Result<(), Box<dyn Error>> {
42/// let value = some_fallible_operation()?;
43/// Ok(())
44/// }
45/// # fn some_fallible_operation() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }
46/// ```
47#[proc_macro_attribute]
48pub fn ruby_test(args: TokenStream, input: TokenStream) -> TokenStream {
49 let input: ItemFn = match syn::parse2(input.into()) {
50 Ok(input) => input,
51 Err(err) => return err.to_compile_error().into(),
52 };
53
54 let mut gc_stress = false;
55
56 for arg in args {
57 match arg {
58 TokenTree::Ident(ident) => match ident.to_string().as_str() {
59 "gc_stress" => gc_stress = true,
60 kw => {
61 return syn::Error::new(kw.span(), format!("unknown argument: {}", kw))
62 .to_compile_error()
63 .into();
64 }
65 },
66 _ => {
67 return syn::Error::new(arg.span().into(), format!("expected identifier: {}", arg))
68 .to_compile_error()
69 .into();
70 }
71 }
72 }
73
74 let block = input.block;
75 let attrs = input.attrs;
76 let vis = input.vis;
77 let sig = &input.sig;
78
79 // Check if the function returns a Result type
80 let returns_result = matches!(&sig.output, ReturnType::Type(_, _));
81
82 let block = if gc_stress {
83 quote! {
84 rb_sys_test_helpers::with_gc_stress(|| {
85 #block
86 })
87 }
88 } else {
89 quote! { #block }
90 };
91
92 let block = quote! {
93 let ret = {
94 #block
95 };
96 rb_sys_test_helpers::trigger_full_gc!();
97 ret
98 };
99
100 // Helper to generate the error logging code
101 let log_ruby_exception = quote! {
102 match std::env::var("RUST_BACKTRACE") {
103 Ok(val) if val == "1" || val == "full" => {
104 eprintln!("ruby exception:");
105 let errinfo = format!("{:#?}", err);
106 let errinfo = errinfo.replace("\n", "\n ");
107 eprintln!(" {}", errinfo);
108 },
109 _ => (),
110 }
111 };
112
113 // Generate different code based on whether the test returns a Result or not
114 let test_fn = if returns_result {
115 // For Result-returning tests, propagate errors properly
116 quote! {
117 #[test]
118 #(#attrs)*
119 #vis #sig {
120 rb_sys_test_helpers::with_ruby_vm(|| {
121 let result = rb_sys_test_helpers::protect(|| {
122 #block
123 });
124
125 match result {
126 Err(err) => {
127 #log_ruby_exception
128 Err(err.into())
129 },
130 Ok(inner_result) => inner_result,
131 }
132 }).expect("test execution failure")
133 }
134 }
135 } else {
136 // For unit-returning tests, use the original behavior
137 quote! {
138 #[test]
139 #(#attrs)*
140 #vis #sig {
141 rb_sys_test_helpers::with_ruby_vm(|| {
142 let result = rb_sys_test_helpers::protect(|| {
143 #block
144 });
145
146 let ret = match result {
147 Err(err) => {
148 #log_ruby_exception
149 Err(err)
150 },
151 Ok(v) => Ok(v),
152 };
153
154 ret
155 }).expect("test execution failure").expect("ruby exception");
156 }
157 }
158 };
159
160 test_fn.into()
161}