Skip to main content

webylib_macros/
lib.rs

1//! Procedural macros for webylib.
2//!
3//! Three macros land here:
4//! - `#[wallet_op]` — generates lock/run-effect/emit-event/persist plumbing
5//!   for a wallet operation. Mirrors webycash-server's `#[gen_server]`.
6//! - `#[asset_storage]` — generates per-asset CRUD on the `Store` trait
7//!   from a struct definition.
8//! - `#[ffi_export]` — generates the `extern "C"` shim, error-code
9//!   marshaling, opaque-handle lifecycle, and async-callback bridging
10//!   for the FFI layer. **This is the macro that replaces the legacy
11//!   22K-LOC handwritten `src/ffi/wallet_ops.rs` with one source of truth.**
12
13#![forbid(unsafe_code)]
14#![warn(missing_docs)]
15
16use proc_macro::TokenStream;
17use proc_macro2::TokenStream as TokenStream2;
18use quote::{format_ident, quote};
19use syn::{
20    parse::{Parse, ParseStream},
21    parse_macro_input,
22    spanned::Spanned,
23    FnArg, ItemFn, Pat, ReturnType, Type,
24};
25
26// ─────────────────────────────────────────────────────────────────────────────
27// #[ffi_export]
28// ─────────────────────────────────────────────────────────────────────────────
29
30/// Attribute macro that takes an async (or sync) Rust function and emits a
31/// matching `extern "C"` shim.
32///
33/// Supported signatures:
34///
35/// ```ignore
36/// #[ffi_export]
37/// pub fn balance(handle: WeyWalletHandle) -> i64 { ... }
38///
39/// #[ffi_export(name = "weby_wallet_open")]
40/// pub async fn open(path: &str) -> Result<WeyWalletHandle, Error> { ... }
41/// ```
42///
43/// Generates:
44///   * `extern "C" fn weby_wallet_balance(handle: WeyWalletHandle) -> i64`
45///   * `extern "C" fn weby_wallet_open(path: *const c_char,
46///                                     out: *mut WeyWalletHandle) -> i32`
47///     — async wrapped via the FFI runtime; result marshaled to
48///     `(error_code, out)`.
49///
50/// The macro recognises a small set of marshaling rules:
51///   * `&str` → `*const c_char`            (read with `CStr::from_ptr`)
52///   * `String` → `*const c_char`          (caller frees nothing; we copy)
53///   * `Result<T, E>` → returns `i32`      with T written via `*out` ptr
54///   * `Result<(), E>` → returns `i32`
55///   * `i32` / `i64` / `u32` / `u64` / `bool` / opaque handle types → as-is
56#[proc_macro_attribute]
57pub fn ffi_export(attr: TokenStream, item: TokenStream) -> TokenStream {
58    let args = parse_macro_input!(attr as FfiArgs);
59    let func = parse_macro_input!(item as ItemFn);
60
61    match expand_ffi_export(args, func) {
62        Ok(ts) => ts.into(),
63        Err(e) => e.to_compile_error().into(),
64    }
65}
66
67struct FfiArgs {
68    /// Optional override for the C-side function name.
69    name: Option<String>,
70    /// Optional prefix prepended to the Rust ident (default: `weby_`).
71    prefix: Option<String>,
72}
73
74impl Parse for FfiArgs {
75    fn parse(input: ParseStream) -> syn::Result<Self> {
76        let mut name = None;
77        let mut prefix = None;
78        if input.is_empty() {
79            return Ok(FfiArgs { name, prefix });
80        }
81        let metas: syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]> =
82            input.parse_terminated(syn::MetaNameValue::parse, syn::Token![,])?;
83        for m in metas {
84            let key = m
85                .path
86                .get_ident()
87                .map(|i| i.to_string())
88                .unwrap_or_default();
89            let val = match m.value {
90                syn::Expr::Lit(syn::ExprLit {
91                    lit: syn::Lit::Str(s),
92                    ..
93                }) => s.value(),
94                other => {
95                    return Err(syn::Error::new(other.span(), "expected string literal"));
96                }
97            };
98            match key.as_str() {
99                "name" => name = Some(val),
100                "prefix" => prefix = Some(val),
101                _ => return Err(syn::Error::new(m.path.span(), "unknown ffi_export option")),
102            }
103        }
104        Ok(FfiArgs { name, prefix })
105    }
106}
107
108fn expand_ffi_export(args: FfiArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
109    let original = func.clone();
110    let rust_ident = func.sig.ident.clone();
111    let prefix = args.prefix.unwrap_or_else(|| "weby_".to_string());
112    let c_ident = match args.name {
113        Some(n) => format_ident!("{}", n),
114        None => format_ident!("{}{}", prefix, rust_ident),
115    };
116
117    let is_async = func.sig.asyncness.is_some();
118
119    // Map each input. Track:
120    //   - the C-ABI parameter list we'll emit
121    //   - the conversion code to translate to Rust types
122    //   - the Rust call argument list
123    let mut c_params = Vec::<TokenStream2>::new();
124    let mut conversions = Vec::<TokenStream2>::new();
125    let mut call_args = Vec::<TokenStream2>::new();
126
127    for input in &func.sig.inputs {
128        let FnArg::Typed(pat_ty) = input else {
129            return Err(syn::Error::new(
130                input.span(),
131                "ffi_export does not support `self` receivers; use a free function with an opaque handle parameter",
132            ));
133        };
134        let Pat::Ident(pat_ident) = &*pat_ty.pat else {
135            return Err(syn::Error::new(
136                pat_ty.pat.span(),
137                "expected simple identifier",
138            ));
139        };
140        let name = pat_ident.ident.clone();
141        let ty = &*pat_ty.ty;
142        let kind = classify_input(ty)?;
143        match kind {
144            InputKind::CStrRef => {
145                let raw = format_ident!("__{}_raw", name);
146                c_params.push(quote! { #raw: *const ::std::os::raw::c_char });
147                conversions.push(quote! {
148                    let #name: &str = match unsafe {
149                        if #raw.is_null() { return -1; }
150                        ::std::ffi::CStr::from_ptr(#raw).to_str()
151                    } {
152                        Ok(s) => s,
153                        Err(_) => return -2,
154                    };
155                });
156                call_args.push(quote! { #name });
157            }
158            InputKind::Scalar(scalar) => {
159                c_params.push(quote! { #name: #scalar });
160                call_args.push(quote! { #name });
161            }
162            InputKind::Opaque(path) => {
163                c_params.push(quote! { #name: #path });
164                call_args.push(quote! { #name });
165            }
166        }
167    }
168
169    // Map the return type. `post_call` MUST be an expression that the
170    // generated `extern "C"` body returns.
171    let (c_return, post_call): (TokenStream2, TokenStream2) = match &func.sig.output {
172        ReturnType::Default => (quote! { () }, quote! { drop(__result) }),
173        ReturnType::Type(_, ty) => match classify_output(ty)? {
174            // Sync scalar / opaque: the generated body returns __result directly.
175            OutputKind::Scalar(s) => (quote! { #s }, quote! { __result }),
176            OutputKind::Opaque(path) => (quote! { #path }, quote! { __result }),
177            OutputKind::ResultUnit => (
178                quote! { i32 },
179                quote! {
180                    match __result {
181                        Ok(()) => 0,
182                        Err(_) => -100,
183                    }
184                },
185            ),
186            OutputKind::ResultScalar(s) => {
187                c_params.push(quote! { __out: *mut #s });
188                (
189                    quote! { i32 },
190                    quote! {
191                        match __result {
192                            Ok(v) => unsafe {
193                                if !__out.is_null() { *__out = v; }
194                                0
195                            },
196                            Err(_) => -100,
197                        }
198                    },
199                )
200            }
201            OutputKind::ResultOpaque(path) => {
202                c_params.push(quote! { __out: *mut #path });
203                (
204                    quote! { i32 },
205                    quote! {
206                        match __result {
207                            Ok(v) => unsafe {
208                                if !__out.is_null() { *__out = v; }
209                                0
210                            },
211                            Err(_) => -100,
212                        }
213                    },
214                )
215            }
216        },
217    };
218
219    // Strip async/visibility from the original; we re-wrap.
220    func.sig.asyncness = None;
221
222    let invocation = if is_async {
223        // Each generated fn carries its own lazy-initialised Tokio runtime.
224        // The first call pays the construction cost; subsequent calls reuse.
225        // Caller crate must depend on `tokio` with `rt-multi-thread`.
226        quote! {
227            let __result = {
228                static __RT: ::std::sync::OnceLock<::tokio::runtime::Runtime> =
229                    ::std::sync::OnceLock::new();
230                let rt = __RT.get_or_init(|| {
231                    ::tokio::runtime::Builder::new_multi_thread()
232                        .enable_all()
233                        .build()
234                        .expect("ffi_export tokio runtime")
235                });
236                rt.block_on(async {
237                    #rust_ident(#(#call_args),*).await
238                })
239            };
240        }
241    } else {
242        quote! {
243            let __result = #rust_ident(#(#call_args),*);
244        }
245    };
246
247    let extern_fn = quote! {
248        /// Auto-generated FFI export. Do not edit directly; modify the
249        /// underlying Rust function and recompile.
250        #[no_mangle]
251        pub unsafe extern "C" fn #c_ident(#(#c_params),*) -> #c_return {
252            // Conversions (e.g., *const c_char → &str). On failure these
253            // early-return with a negative error code.
254            #(#conversions)*
255            #invocation
256            #post_call
257        }
258    };
259
260    Ok(quote! {
261        #original
262        #extern_fn
263    })
264}
265
266enum InputKind {
267    CStrRef,
268    Scalar(TokenStream2),
269    Opaque(TokenStream2),
270}
271
272fn classify_input(ty: &Type) -> syn::Result<InputKind> {
273    if let Type::Reference(r) = ty {
274        if let Type::Path(p) = &*r.elem {
275            if p.path.is_ident("str") {
276                return Ok(InputKind::CStrRef);
277            }
278        }
279    }
280    if let Type::Path(p) = ty {
281        if let Some(seg) = p.path.segments.last() {
282            let s = seg.ident.to_string();
283            match s.as_str() {
284                "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "usize" | "isize"
285                | "bool" | "f32" | "f64" => {
286                    return Ok(InputKind::Scalar(quote! { #ty }));
287                }
288                _ => {
289                    // Treat as opaque pass-through (likely a `#[repr(C)]`
290                    // handle struct or `*mut T`).
291                    return Ok(InputKind::Opaque(quote! { #ty }));
292                }
293            }
294        }
295    }
296    if let Type::Ptr(_) = ty {
297        return Ok(InputKind::Opaque(quote! { #ty }));
298    }
299    Err(syn::Error::new(
300        ty.span(),
301        "ffi_export: unsupported parameter type",
302    ))
303}
304
305enum OutputKind {
306    Scalar(TokenStream2),
307    Opaque(TokenStream2),
308    ResultUnit,
309    ResultScalar(TokenStream2),
310    ResultOpaque(TokenStream2),
311}
312
313fn classify_output(ty: &Type) -> syn::Result<OutputKind> {
314    if let Type::Path(p) = ty {
315        if let Some(seg) = p.path.segments.last() {
316            if seg.ident == "Result" {
317                if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
318                    let inner = args.args.first();
319                    match inner {
320                        Some(syn::GenericArgument::Type(Type::Tuple(t))) if t.elems.is_empty() => {
321                            return Ok(OutputKind::ResultUnit);
322                        }
323                        Some(syn::GenericArgument::Type(inner_ty)) => {
324                            return Ok(match classify_input(inner_ty)? {
325                                InputKind::Scalar(s) => OutputKind::ResultScalar(s),
326                                InputKind::Opaque(p) => OutputKind::ResultOpaque(p),
327                                InputKind::CStrRef => {
328                                    return Err(syn::Error::new(
329                                        inner_ty.span(),
330                                        "ffi_export: returning &str isn't supported; use String + caller-allocated buffer instead",
331                                    ));
332                                }
333                            });
334                        }
335                        _ => {}
336                    }
337                }
338                return Err(syn::Error::new(
339                    seg.ident.span(),
340                    "ffi_export: malformed Result<...>",
341                ));
342            }
343        }
344        let s = p
345            .path
346            .segments
347            .last()
348            .map(|s| s.ident.to_string())
349            .unwrap_or_default();
350        match s.as_str() {
351            "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "usize" | "isize"
352            | "bool" | "f32" | "f64" => {
353                return Ok(OutputKind::Scalar(quote! { #ty }));
354            }
355            _ => {
356                return Ok(OutputKind::Opaque(quote! { #ty }));
357            }
358        }
359    }
360    Err(syn::Error::new(
361        ty.span(),
362        "ffi_export: unsupported return type",
363    ))
364}
365
366// ─────────────────────────────────────────────────────────────────────────────
367// Minimal stubs for the OTHER two macros so the trait surface lights up.
368// Real implementations land in M2.
369// ─────────────────────────────────────────────────────────────────────────────
370
371/// `#[wallet_op]` — placeholder that re-emits the input unchanged.
372/// Real plumbing (lock/run/persist) lands when webylib-core operations
373/// migrate from `webylib/src/wallet/operations.rs`.
374#[proc_macro_attribute]
375pub fn wallet_op(_attr: TokenStream, item: TokenStream) -> TokenStream {
376    item
377}
378
379/// `#[asset_storage]` — placeholder that re-emits the input unchanged.
380/// Generates per-asset CRUD on the `Store` trait when populated.
381#[proc_macro_attribute]
382pub fn asset_storage(_attr: TokenStream, item: TokenStream) -> TokenStream {
383    item
384}