Skip to main content

js2rust_bridge_macro/
lib.rs

1//! Proc-macro for generating Rust FFI bindings from js2rust C ABI export metadata.
2//!
3//! Usage:
4//! ```rust,ignore
5//! // In your Rust code (after js2zig-build ran in build.rs):
6//! use js2rust_bridge_macro::js2rust_bridge;
7//! js2rust_bridge!(main);  // Looks for $OUT_DIR/js2zig/main/cabi_exports.json
8//! ```
9//!
10//! The group name is appended to generated function names to avoid collisions:
11//! `greet` → `greet_main`, `add` → `add_main`.
12
13use proc_macro::TokenStream;
14use quote::{format_ident, quote};
15use serde::Deserialize;
16
17/// Find the workspace root by looking for a Cargo.toml with [workspace].
18fn find_workspace_root(start: &str) -> String {
19    let mut current = std::path::PathBuf::from(start);
20    loop {
21        let cargo_toml = current.join("Cargo.toml");
22        if cargo_toml.exists() {
23            if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
24                if content.contains("[workspace]") {
25                    return current.to_string_lossy().to_string();
26                }
27            }
28        }
29        if !current.pop() {
30            return std::path::PathBuf::from(start)
31                .parent()
32                .unwrap()
33                .to_string_lossy()
34                .to_string();
35        }
36    }
37}
38
39/// Extract group name from the JSON file path.
40/// e.g. `out/main/cabi_exports.json` → `main`
41fn extract_group_name(path: &std::path::Path) -> String {
42    let raw = path
43        .parent()
44        .and_then(|p| p.file_name())
45        .and_then(|n| n.to_str())
46        .unwrap_or("unknown")
47        .to_string();
48    sanitize_ident(&raw)
49}
50
51/// Sanitize a string into a valid Rust identifier fragment.
52fn sanitize_ident(s: &str) -> String {
53    let mut out = String::with_capacity(s.len());
54    for ch in s.chars() {
55        if ch.is_ascii_alphanumeric() || ch == '_' {
56            out.push(ch);
57        } else {
58            out.push('_');
59        }
60    }
61    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
62        out = format!("_{}", out);
63    }
64    if out.is_empty() {
65        out.push_str("unknown");
66    }
67    out
68}
69
70/// C ABI export metadata (mirrors the JSON schema written by js2rustc).
71#[derive(Debug, Deserialize)]
72struct CabiExport {
73    name: String,
74    params: Vec<CabiParam>,
75    ret_type: String,
76    has_free_func: bool,
77}
78
79#[derive(Debug, Deserialize)]
80struct CabiParam {
81    #[allow(dead_code)]
82    name: String,
83    zig_type: String,
84}
85
86/// Generate bindings from a `cabi_exports.json` path.
87fn generate_from_path(json_path: &std::path::Path, group_name: &str, span: proc_macro2::Span) -> TokenStream {
88    // Read and parse JSON
89    let json_content = match std::fs::read_to_string(json_path) {
90        Ok(s) => s,
91        Err(e) => {
92            return syn::Error::new(
93                span,
94                format!(
95                    "js2rust_bridge: cannot read '{}': {}",
96                    json_path.display(),
97                    e
98                ),
99            )
100            .to_compile_error()
101            .into();
102        }
103    };
104
105    let exports: Vec<CabiExport> = match serde_json::from_str(&json_content) {
106        Ok(v) => v,
107        Err(e) => {
108            return syn::Error::new(
109                span,
110                format!(
111                    "js2rust_bridge: failed to parse '{}': {}",
112                    json_path.display(),
113                    e
114                ),
115            )
116            .to_compile_error()
117            .into();
118        }
119    };
120
121    // Generate code with the group name as suffix for functions
122    let generated = generate_bindings(&exports, group_name);
123
124    match generated.parse::<TokenStream>() {
125        Ok(ts) => ts,
126        Err(e) => syn::Error::new(span, format!("internal error: {}", e))
127            .to_compile_error()
128            .into(),
129    }
130}
131
132/// Function-like proc-macro: `js2rust_bridge!(group_name);`
133///
134/// Simplified syntax: only specify the group name.
135/// The macro automatically looks for `$OUT_DIR/js2zig/{group_name}/cabi_exports.json`.
136///
137/// Generates FFI bindings + safe wrappers for one group.
138#[proc_macro]
139pub fn js2rust_bridge(input: TokenStream) -> TokenStream {
140    // Parse input as an identifier (group name)
141    let group_name = match syn::parse::<syn::Ident>(input.clone()) {
142        Ok(ident) => ident.to_string(),
143        Err(_) => {
144            // Fallback: try parsing as string literal (backward compatibility)
145            match syn::parse::<syn::LitStr>(input) {
146                Ok(s) => {
147                    let json_path = s.value();
148                    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
149                        .expect("CARGO_MANIFEST_DIR not set");
150                    let workspace_root = find_workspace_root(&manifest_dir);
151                    let resolved_path = std::path::Path::new(&workspace_root).join(&json_path);
152                    let group_name = extract_group_name(&resolved_path);
153                    return generate_from_path(&resolved_path, &group_name, s.span());
154                }
155                Err(e) => return e.to_compile_error().into(),
156            }
157        }
158    };
159
160    // Read OUT_DIR environment variable (set by Cargo during build)
161    let out_dir = match std::env::var("OUT_DIR") {
162        Ok(dir) => dir,
163        Err(_) => {
164            return syn::Error::new(
165                proc_macro2::Span::call_site(),
166                "js2rust_bridge: OUT_DIR not set.\n\
167                 Make sure you have a build script that calls `js2zig_build::transpile()`.",
168            )
169            .to_compile_error()
170            .into();
171        }
172    };
173
174    // Build path: {OUT_DIR}/js2zig/{group_name}/cabi_exports.json
175    let json_path = std::path::Path::new(&out_dir)
176        .join("js2zig")
177        .join(&group_name)
178        .join("cabi_exports.json");
179
180    generate_from_path(&json_path, &group_name, proc_macro2::Span::call_site())
181}
182
183/// Generate Rust FFI bindings + safe wrappers from C ABI export metadata.
184fn generate_bindings(exports: &[CabiExport], group_suffix: &str) -> String {
185    let mut extern_fns = Vec::new();
186    let mut safe_wrappers = Vec::new();
187
188    let raw_mod = format_ident!("__js2rust_ffi_raw_{group_suffix}");
189    let safe_mod = format_ident!("__js2rust_ffi_safe_{group_suffix}");
190
191    for exp in exports {
192        let fn_name = format_ident!("{}", exp.name);
193        let free_fn_name = format_ident!("free_{}", exp.name);
194
195        // Build parameter list for extern declaration
196        let mut extern_params = Vec::new();
197        let mut safe_params = Vec::new();
198        let mut call_args = Vec::new();
199
200        for (idx, param) in exp.params.iter().enumerate() {
201            let param_ident = format_ident!("arg{}", idx);
202            let param_ty = zig_type_to_rust_ffi_type(&param.zig_type);
203            extern_params.push(quote! { #param_ident: #param_ty });
204            safe_params.push(quote! { #param_ident: #param_ty });
205            call_args.push(quote! { #param_ident });
206        }
207
208        let ret_ty = zig_ret_type_to_rust_ffi(&exp.ret_type);
209
210        // Generate `unsafe extern "C"` declaration
211        extern_fns.push(quote! {
212            pub fn #fn_name( #(#extern_params),* ) -> #ret_ty;
213        });
214
215        if exp.has_free_func {
216            extern_fns.push(quote! {
217                pub fn #free_fn_name(ptr: *mut std::ffi::c_void);
218            });
219        }
220
221        // Generate safe wrapper (with group suffix to avoid name collisions)
222        let safe_wrapper = generate_safe_wrapper(exp, &fn_name, &free_fn_name, &raw_mod, group_suffix);
223        safe_wrappers.push(safe_wrapper);
224    }
225
226    // Output: separate mod for raw FFI, then safe wrappers at top level
227    let output = quote! {
228        #[allow(non_snake_case)]
229        #[allow(dead_code)]
230        mod #raw_mod {
231            unsafe extern "C" {
232                #(#extern_fns)*
233            }
234        }
235
236        #[allow(non_snake_case)]
237        #[allow(dead_code)]
238        mod #safe_mod {
239            use super::#raw_mod;
240
241            #(#safe_wrappers)*
242        }
243
244        // Re-export safe wrappers at the invocation site
245        pub use #safe_mod::*;
246    };
247
248    output.to_string()
249}
250
251/// Generate a safe Rust wrapper function for a C ABI export.
252fn generate_safe_wrapper(
253    exp: &CabiExport,
254    fn_name: &syn::Ident,
255    free_fn_name: &syn::Ident,
256    raw_mod: &syn::Ident,
257    group_suffix: &str,
258) -> proc_macro2::TokenStream {
259    // Safe wrapper name: `greet` → `greet_main`
260    let wrapper_name = format_ident!("{}_{}", exp.name, group_suffix);
261    let mut safe_params = Vec::new();
262    let mut ffi_args = Vec::new();
263
264    // Build safe parameter list (convert &str → *const c_char if needed)
265    for (idx, param) in exp.params.iter().enumerate() {
266        let param_ident = format_ident!("arg{}", idx);
267        let safe_ty = zig_type_to_rust_safe_type(&param.zig_type);
268        safe_params.push(quote! { #param_ident: #safe_ty });
269        ffi_args.push(convert_safe_to_ffi(&param.zig_type, &param_ident));
270    }
271
272    let (ret_ty, call_expr) = if exp.ret_type == "[]const u8" {
273        // String return: call FFI, convert to String, free
274        (
275            quote! { String },
276            quote! {
277                {
278                    let ptr = unsafe { super::#raw_mod::#fn_name(#(#ffi_args),*) };
279                    if ptr.is_null() {
280                        String::new()
281                    } else {
282                        let s = unsafe {
283                            std::ffi::CStr::from_ptr(ptr)
284                                .to_string_lossy()
285                                .into_owned()
286                        };
287                        unsafe { super::#raw_mod::#free_fn_name(ptr as *mut std::ffi::c_void) };
288                        s
289                    }
290                }
291            },
292        )
293    } else {
294        let rust_ret = zig_ret_type_to_rust_safe(&exp.ret_type);
295        (
296            rust_ret.clone(),
297            quote! {
298                unsafe { super::#raw_mod::#fn_name(#(#ffi_args),*) }
299            },
300        )
301    };
302
303    quote! {
304        #[allow(non_snake_case)]
305        pub fn #wrapper_name( #(#safe_params),* ) -> #ret_ty {
306            #call_expr
307        }
308    }
309}
310
311// ── Type conversion helpers ─────────────────────────────────────────
312
313/// Convert Zig FFI type to Rust FFI type (for `unsafe extern "C"`).
314fn zig_type_to_rust_ffi_type(zig_type: &str) -> proc_macro2::TokenStream {
315    match zig_type {
316        "[]const u8" => quote! { *const std::ffi::c_char },
317        "i32" => quote! { i32 },
318        "i64" => quote! { i64 },
319        "f64" => quote! { f64 },
320        "bool" => quote! { bool },
321        "void" => quote! { () },
322        _ => quote! { *mut std::ffi::c_void },
323    }
324}
325
326/// Convert Zig return type to Rust FFI return type.
327fn zig_ret_type_to_rust_ffi(ret_type: &str) -> proc_macro2::TokenStream {
328    match ret_type {
329        "[]const u8" => quote! { *const std::ffi::c_char },
330        "i32" => quote! { i32 },
331        "i64" => quote! { i64 },
332        "f64" => quote! { f64 },
333        "bool" => quote! { bool },
334        "void" => quote! { () },
335        _ => quote! { *mut std::ffi::c_void },
336    }
337}
338
339/// Convert Zig type to safe Rust type (for wrapper function parameters).
340fn zig_type_to_rust_safe_type(zig_type: &str) -> proc_macro2::TokenStream {
341    match zig_type {
342        "[]const u8" => quote! { &str },
343        "i32" => quote! { i32 },
344        "i64" => quote! { i64 },
345        "f64" => quote! { f64 },
346        "bool" => quote! { bool },
347        _ => quote! { *mut std::ffi::c_void },
348    }
349}
350
351/// Convert safe Rust type to FFI type (for calling `unsafe extern "C"` functions).
352fn convert_safe_to_ffi(zig_type: &str, ident: &syn::Ident) -> proc_macro2::TokenStream {
353    match zig_type {
354        "[]const u8" => quote! { std::ffi::CString::new(#ident).unwrap().into_raw() },
355        _ => quote! { #ident },
356    }
357}
358
359/// Convert Zig return type to safe Rust return type.
360fn zig_ret_type_to_rust_safe(ret_type: &str) -> proc_macro2::TokenStream {
361    match ret_type {
362        "[]const u8" => quote! { String },
363        "i32" => quote! { i32 },
364        "i64" => quote! { i64 },
365        "f64" => quote! { f64 },
366        "bool" => quote! { bool },
367        "void" => quote! { () },
368        _ => quote! { *mut std::ffi::c_void },
369    }
370}