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//!
5//! ```rust,ignore
6//! js2rust_bridge! {
7//!     "js_src/main.js",    // core JS file path (relative to CARGO_MANIFEST_DIR)
8//!     // Sync host functions (optional, comma-separated):
9//!     host_add(i64, i64) -> i64,
10//!     host_concat(str, str) -> str,
11//!     // Async host functions (called with `await` from JS):
12//!     async fetch_user(str) -> { id: i64, name: str },
13//! }
14//! ```
15//!
16//! The macro transpiles JS to Zig inline, writes output to
17//! `.js2zig-cache/{group}/`, and generates Rust FFI bindings.
18//! The group name is derived from the file name (sanitized for Zig identifiers).
19//! A minimal `build.rs` is only needed to link the compiled static library.
20
21use proc_macro::TokenStream;
22use quote::{format_ident, quote};
23use serde::Deserialize;
24use syn::{
25    braced, parenthesized, parse::{Parse, ParseStream}, Ident, LitStr, Token
26};
27
28// ── C ABI export metadata (mirrors the JSON schema) ───────────────
29
30#[derive(Debug, Deserialize)]
31struct CabiExport {
32    name: String,
33    params: Vec<CabiParam>,
34    ret_type: String,
35    has_free_func: bool,
36}
37
38#[derive(Debug, Deserialize)]
39struct CabiParam {
40    #[allow(dead_code)]
41    name: String,
42    zig_type: String,
43}
44
45// ── Macro input parsing ───────────────────────────────────────────
46
47/// Parsed host function declaration: `[async] name(type1, type2) -> ret_type`
48struct HostFnDecl {
49    name: String,
50    params: Vec<String>,
51    return_type: String,
52    is_async: bool,
53    /// For async functions with struct return: Vec<(field_name, field_type)>
54    async_return_fields: Vec<(String, String)>,
55}
56
57/// Full macro input.
58struct MacroInput {
59    /// Core JS file path (e.g. "js_src/main.js").
60    js_file: String,
61    /// Group name derived from the file stem (sanitized).
62    group: String,
63    host_fns: Vec<HostFnDecl>,
64}
65
66impl Parse for MacroInput {
67    fn parse(input: ParseStream) -> syn::Result<Self> {
68        // Core JS file path (string literal)
69        let js_file_lit: LitStr = input.parse()?;
70        let js_file = js_file_lit.value();
71
72        // Derive group name from file stem, sanitized for Zig identifiers.
73        let stem = std::path::Path::new(&js_file)
74            .file_stem()
75            .and_then(|s| s.to_str())
76            .unwrap_or("main");
77        let group = js2zig_core::analyzer::sanitize_module_name(stem);
78
79        // Optional host function declarations
80        let mut host_fns = Vec::new();
81        while input.peek(Token![,]) {
82            input.parse::<Token![,]>()?;
83            if input.is_empty() {
84                break;
85            }
86
87            // Parse first — could be `async` keyword or function name
88            let is_async = input.peek(Token![async]);
89            if is_async {
90                input.parse::<Token![async]>()?;
91            }
92            let name: Ident = input.parse()?;
93            let name_str = name.to_string();
94
95            // parameter types in parentheses
96            let paren_content;
97            parenthesized!(paren_content in input);
98            let mut params = Vec::new();
99            while !paren_content.is_empty() {
100                let ty: Ident = paren_content.parse()?;
101                params.push(ty.to_string());
102                if paren_content.peek(Token![,]) {
103                    paren_content.parse::<Token![,]>()?;
104                }
105            }
106
107            // return type after `->`
108            input.parse::<Token![->]>()?;
109
110            // Check for struct return type `{ field: type, ... }` or simple Ident
111            let (return_type, async_fields) = if input.peek(syn::token::Brace) {
112                let struct_content;
113                braced!(struct_content in input);
114                let mut fields = Vec::new();
115                while !struct_content.is_empty() {
116                    let field_name: Ident = struct_content.parse()?;
117                    struct_content.parse::<Token![:]>()?;
118                    let field_type: Ident = struct_content.parse()?;
119                    fields.push((field_name.to_string(), field_type.to_string()));
120                    if struct_content.peek(Token![,]) {
121                        struct_content.parse::<Token![,]>()?;
122                    }
123                }
124                // For struct returns, return_type is "void" (actual type is in async_fields)
125                ("void".to_string(), fields)
126            } else {
127                let ret: Ident = input.parse()?;
128                (ret.to_string(), Vec::new())
129            };
130
131            host_fns.push(HostFnDecl {
132                name: name_str,
133                params,
134                return_type,
135                is_async,
136                async_return_fields: async_fields,
137            });
138        }
139
140        Ok(MacroInput {
141            js_file,
142            group,
143            host_fns,
144        })
145    }
146}
147
148// ── Type name conversion ──────────────────────────────────────────
149
150/// Convert macro-level type name to `js2zig_core::HostType`.
151fn type_name_to_host_type(name: &str) -> Result<js2zig_core::HostType, String> {
152    match name {
153        "i64" => Ok(js2zig_core::HostType::I64),
154        "i32" => Ok(js2zig_core::HostType::I32),
155        "f64" => Ok(js2zig_core::HostType::F64),
156        "bool" => Ok(js2zig_core::HostType::Bool),
157        "str" => Ok(js2zig_core::HostType::Str),
158        "void" => Ok(js2zig_core::HostType::Void),
159        other => Err(format!("js2rust_bridge: unknown host type '{}'. \
160            Valid types: i64, i32, f64, bool, str, void", other)),
161    }
162}
163
164// ── Main proc-macro entry point ───────────────────────────────────
165
166/// Function-like proc-macro: `js2rust_bridge!("js_src/main.js", host_fns...)`.
167///
168/// Transpiles JS to Zig, generates Rust FFI bindings, and optionally
169/// runs `zig build` to compile the static library.
170#[proc_macro]
171pub fn js2rust_bridge(input: TokenStream) -> TokenStream {
172    let input_tokens: proc_macro2::TokenStream = input.into();
173
174    match syn::parse2::<MacroInput>(input_tokens) {
175        Ok(parsed) => generate(&parsed),
176        Err(e) => e.to_compile_error().into(),
177    }
178}
179
180// ── Transpile + generate FFI ──────────────────────────────────────
181
182fn generate(input: &MacroInput) -> TokenStream {
183    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
184        .unwrap_or_else(|_| ".".to_string());
185
186    // Resolve core JS file path
187    let js_file_path = std::path::Path::new(&manifest_dir).join(&input.js_file);
188
189    // Resolve cache directory for Zig output
190    let cache_dir = std::path::Path::new(&manifest_dir)
191        .join(".js2zig-cache");
192
193    // Convert host function declarations to js2zig_core::HostFunction
194    let mut host_functions = Vec::new();
195    for hf in &input.host_fns {
196        let params: Result<Vec<_>, _> = hf.params.iter()
197            .map(|t| type_name_to_host_type(t))
198            .collect();
199        let params = match params {
200            Ok(p) => p,
201            Err(e) => return syn::Error::new(proc_macro2::Span::call_site(), e)
202                .to_compile_error().into(),
203        };
204
205        let return_type = match type_name_to_host_type(&hf.return_type) {
206            Ok(js2zig_core::HostType::Void) => None,
207            Ok(t) => Some(t),
208            Err(e) => return syn::Error::new(proc_macro2::Span::call_site(), e)
209                .to_compile_error().into(),
210        };
211
212        // Convert async return fields
213        let async_return_fields: Result<Vec<_>, _> = hf.async_return_fields.iter()
214            .map(|(name, ty)| {
215                type_name_to_host_type(ty).map(|t| (name.clone(), t))
216            })
217            .collect();
218        let async_return_fields = match async_return_fields {
219            Ok(v) => v,
220            Err(e) => return syn::Error::new(proc_macro2::Span::call_site(), e)
221                .to_compile_error().into(),
222        };
223
224        host_functions.push(js2zig_core::HostFunction {
225            name: hf.name.clone(),
226            params,
227            return_type,
228            is_async: hf.is_async,
229            async_return_fields,
230        });
231    }
232
233    // Build ProjectConfig
234    let config = js2zig_core::ProjectConfig {
235        name: input.group.clone(),
236        js_file: js_file_path.clone(),
237        out_dir: cache_dir.clone(),
238        host_config: if host_functions.is_empty() {
239            None
240        } else {
241            Some(js2zig_core::HostConfig {
242                functions: host_functions,
243            })
244        },
245        force_rebuild: false,
246        run_zig_build: false,
247    };
248
249    // Transpile!
250    let project_result = match js2zig_core::transpile_project(&config) {
251        Ok(result) => result,
252        Err(e) => {
253            return syn::Error::new(
254                proc_macro2::Span::call_site(),
255                format!("js2rust_bridge: transpilation failed: {}", e),
256            )
257            .to_compile_error()
258            .into();
259        }
260    };
261
262    // Find the group result (there is exactly one group from analyze_single_group)
263    let group_result = project_result.groups.first();
264
265    let group_result = match group_result {
266        Some(g) => g,
267        None => {
268            return syn::Error::new(
269                proc_macro2::Span::call_site(),
270                "js2rust_bridge: no groups found in transpilation result",
271            )
272            .to_compile_error()
273            .into();
274        }
275    };
276
277    // Parse cabi_exports_json
278    let exports: Vec<CabiExport> = match serde_json::from_str(&group_result.cabi_exports_json) {
279        Ok(v) => v,
280        Err(e) => {
281            return syn::Error::new(
282                proc_macro2::Span::call_site(),
283                format!("js2rust_bridge: failed to parse cabi_exports: {}", e),
284            )
285            .to_compile_error()
286            .into();
287        }
288    };
289
290    // Optionally run zig build (side effect — generates static library for linking)
291    let zig_project_dir = cache_dir.join(&input.group);
292    if zig_project_dir.join("build.zig").exists() {
293        let _ = std::process::Command::new("zig")
294            .arg("build")
295            .current_dir(&zig_project_dir)
296            .status();
297    }
298
299    // Generate Rust FFI bindings from cabi exports
300    let generated = generate_bindings(&exports, &input.group);
301
302    match generated.parse::<TokenStream>() {
303        Ok(ts) => ts,
304        Err(e) => syn::Error::new(
305            proc_macro2::Span::call_site(),
306            format!("internal error: {}", e),
307        )
308        .to_compile_error()
309        .into(),
310    }
311}
312
313// ── FFI bindings generation ───────────────────────────────────────
314
315fn generate_bindings(exports: &[CabiExport], group_suffix: &str) -> String {
316    let mut extern_fns = Vec::new();
317    let mut safe_wrappers = Vec::new();
318
319    let raw_mod = format_ident!("__js2rust_ffi_raw_{group_suffix}");
320    let safe_mod = format_ident!("__js2rust_ffi_safe_{group_suffix}");
321
322    for exp in exports {
323        let fn_name = format_ident!("{}", exp.name);
324        let free_fn_name = format_ident!("free_{}", exp.name);
325
326        let mut extern_params = Vec::new();
327        for (idx, param) in exp.params.iter().enumerate() {
328            let param_ident = format_ident!("arg{}", idx);
329            let param_ty = zig_type_to_rust_ffi_type(&param.zig_type);
330            extern_params.push(quote! { #param_ident: #param_ty });
331        }
332
333        let ret_ty = zig_ret_type_to_rust_ffi(&exp.ret_type);
334
335        extern_fns.push(quote! {
336            pub fn #fn_name( #(#extern_params),* ) -> #ret_ty;
337        });
338
339        if exp.has_free_func {
340            extern_fns.push(quote! {
341                pub fn #free_fn_name(ptr: *mut std::ffi::c_void);
342            });
343        }
344
345        let safe_wrapper = generate_safe_wrapper(exp, &fn_name, &free_fn_name, &raw_mod, group_suffix);
346        safe_wrappers.push(safe_wrapper);
347    }
348
349    // Always provide js2rust_init/deinit safe wrappers (C ABI exports from lib.zig)
350    let runtime_init = quote! {
351        /// Initialize the Zig runtime (allocator + Io for async functions).
352        /// Call this before any async export function.
353        pub fn js2rust_init() {
354            extern "C" {
355                #[link_name = "js2rust_init"]
356                fn _js2rust_init();
357            }
358            unsafe { _js2rust_init() };
359        }
360        /// Release Zig runtime resources.
361        pub fn js2rust_deinit() {
362            extern "C" {
363                #[link_name = "js2rust_deinit"]
364                fn _js2rust_deinit();
365            }
366            unsafe { _js2rust_deinit() };
367        }
368    };
369    safe_wrappers.push(runtime_init);
370
371    let output = quote! {
372        #[allow(non_snake_case)]
373        #[allow(dead_code)]
374        mod #raw_mod {
375            unsafe extern "C" {
376                #(#extern_fns)*
377            }
378        }
379
380        #[allow(non_snake_case)]
381        #[allow(dead_code)]
382        mod #safe_mod {
383            use super::#raw_mod;
384
385            #(#safe_wrappers)*
386        }
387
388        pub use #safe_mod::*;
389    };
390
391    output.to_string()
392}
393
394fn generate_safe_wrapper(
395    exp: &CabiExport,
396    fn_name: &syn::Ident,
397    free_fn_name: &syn::Ident,
398    raw_mod: &syn::Ident,
399    group_suffix: &str,
400) -> proc_macro2::TokenStream {
401    let wrapper_name = format_ident!("{}_{}", exp.name, group_suffix);
402    let mut safe_params = Vec::new();
403    let mut ffi_args = Vec::new();
404
405    for (idx, param) in exp.params.iter().enumerate() {
406        let param_ident = format_ident!("arg{}", idx);
407        let safe_ty = zig_type_to_rust_safe_type(&param.zig_type);
408        safe_params.push(quote! { #param_ident: #safe_ty });
409        ffi_args.push(convert_safe_to_ffi(&param.zig_type, &param_ident));
410    }
411
412    let (ret_ty, call_expr) = if exp.ret_type == "[]const u8" {
413        (
414            quote! { String },
415            quote! {
416                {
417                    let ptr = unsafe { super::#raw_mod::#fn_name(#(#ffi_args),*) };
418                    if ptr.is_null() {
419                        String::new()
420                    } else {
421                        let s = unsafe {
422                            std::ffi::CStr::from_ptr(ptr)
423                                .to_string_lossy()
424                                .into_owned()
425                        };
426                        unsafe { super::#raw_mod::#free_fn_name(ptr as *mut std::ffi::c_void) };
427                        s
428                    }
429                }
430            },
431        )
432    } else {
433        let rust_ret = zig_ret_type_to_rust_safe(&exp.ret_type);
434        (
435            rust_ret.clone(),
436            quote! {
437                unsafe { super::#raw_mod::#fn_name(#(#ffi_args),*) }
438            },
439        )
440    };
441
442    quote! {
443        #[allow(non_snake_case)]
444        pub fn #wrapper_name( #(#safe_params),* ) -> #ret_ty {
445            #call_expr
446        }
447    }
448}
449
450// ── Type conversion helpers ───────────────────────────────────────
451
452fn zig_type_to_rust_ffi_type(zig_type: &str) -> proc_macro2::TokenStream {
453    match zig_type {
454        "[]const u8" => quote! { *const std::ffi::c_char },
455        "i32" => quote! { i32 },
456        "i64" => quote! { i64 },
457        "f64" => quote! { f64 },
458        "bool" => quote! { bool },
459        "void" => quote! { () },
460        _ => quote! { *mut std::ffi::c_void },
461    }
462}
463
464fn zig_ret_type_to_rust_ffi(ret_type: &str) -> proc_macro2::TokenStream {
465    match ret_type {
466        "[]const u8" => quote! { *const std::ffi::c_char },
467        "i32" => quote! { i32 },
468        "i64" => quote! { i64 },
469        "f64" => quote! { f64 },
470        "bool" => quote! { bool },
471        "void" => quote! { () },
472        _ => quote! { *mut std::ffi::c_void },
473    }
474}
475
476fn zig_type_to_rust_safe_type(zig_type: &str) -> proc_macro2::TokenStream {
477    match zig_type {
478        "[]const u8" => quote! { &str },
479        "i32" => quote! { i32 },
480        "i64" => quote! { i64 },
481        "f64" => quote! { f64 },
482        "bool" => quote! { bool },
483        _ => quote! { *mut std::ffi::c_void },
484    }
485}
486
487fn convert_safe_to_ffi(zig_type: &str, ident: &syn::Ident) -> proc_macro2::TokenStream {
488    match zig_type {
489        "[]const u8" => quote! { std::ffi::CString::new(#ident).unwrap().into_raw() },
490        _ => quote! { #ident },
491    }
492}
493
494fn zig_ret_type_to_rust_safe(ret_type: &str) -> proc_macro2::TokenStream {
495    match ret_type {
496        "[]const u8" => quote! { String },
497        "i32" => quote! { i32 },
498        "i64" => quote! { i64 },
499        "f64" => quote! { f64 },
500        "bool" => quote! { bool },
501        "void" => quote! { () },
502        _ => quote! { *mut std::ffi::c_void },
503    }
504}