From 410c949b87424b4ac0df5e3f38930781c6eda147 Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Fri, 23 Jan 2026 20:56:19 +0800 Subject: feat(client): add tauri api macros --- crates/macros/Cargo.toml | 16 ++ crates/macros/src/lib.rs | 386 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 crates/macros/Cargo.toml create mode 100644 crates/macros/src/lib.rs (limited to 'crates') diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml new file mode 100644 index 0000000..cd5b4a6 --- /dev/null +++ b/crates/macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dropout-macros" +version = "0.1.0" +edition = "2021" +description = "Proc-macro crate providing #[dropout::api] for generating Tauri TypeScript bindings" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } +heck = "0.4" diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs new file mode 100644 index 0000000..1b26bf2 --- /dev/null +++ b/crates/macros/src/lib.rs @@ -0,0 +1,386 @@ +use proc_macro::TokenStream; +use quote::quote; +use std::collections::BTreeSet; +use syn::{ + parse::Parse, parse::ParseStream, parse_macro_input, punctuated::Punctuated, token::Comma, + Expr, FnArg, Ident, ItemFn, Lit, MetaNameValue, Pat, PathArguments, ReturnType, Type, +}; + +fn get_lit_str_value(nv: &MetaNameValue) -> Option { + // In syn v2 MetaNameValue.value is an Expr (usually Expr::Lit). Extract string literal if present. + match &nv.value { + Expr::Lit(expr_lit) => { + if let Lit::Str(s) = &expr_lit.lit { + Some(s.value()) + } else { + None + } + } + _ => None, + } +} + +fn is_state_or_window(ty: &Type) -> bool { + // Unwrap reference + let mut t = ty; + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Window" || ident == "State" { + return true; + } + } + } + false +} + +fn extract_ident_from_type(ty: &Type) -> Option { + // Peel references, arrays, etc. Only handle Path types + let mut t = ty; + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + // Handle Option, Result, etc. + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Option" { + // extract generic arg (use helper) + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + return extract_ident_from_type(inner); + } + } else { + // For multi-segment like core::java::JavaDownloadInfo we return last segment ident + return Some(ident); + } + } + } + None +} + +fn first_type_arg_from_pathargs(pa: &PathArguments) -> Option<&Type> { + // Given PathArguments (e.g. from a PathSegment), return the first GenericArgument::Type if present. + if let PathArguments::AngleBracketed(ab) = pa { + for arg in ab.args.iter() { + if let syn::GenericArgument::Type(ty) = arg { + return Some(ty); + } + } + } + None +} + +fn rust_type_to_ts(ty: &Type) -> (String, bool) { + // returns (ts_type, is_struct_like) + // is_struct_like signals that this type probably needs import from `import_from` + // Simple mapping: String -> string, primitives -> number, bool -> boolean, others -> ident + let mut t = ty; + // Unwrap references + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + // handle Option + if ident == "Option" { + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + let (inner_ts, inner_struct) = rust_type_to_ts(inner); + // make optional, represent as type | null + return (format!("{} | null", inner_ts), inner_struct); + } + } + return match ident.as_str() { + "String" => ("string".to_string(), false), + "str" => ("string".to_string(), false), + "bool" => ("boolean".to_string(), false), + "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" + | "usize" | "isize" | "f32" | "f64" => ("number".to_string(), false), + "Vec" => { + // Vec -> T[] + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + let (inner_ts, inner_struct) = rust_type_to_ts(inner); + return (format!("{}[]", inner_ts), inner_struct); + } + ("any[]".to_string(), false) + } + other => { + // treat as struct/complex type + (other.to_string(), true) + } + }; + } + } + ("any".to_string(), false) +} + +fn get_return_ts(ty: &ReturnType) -> (String, BTreeSet) { + // returns (promise_ts_type, set_of_structs_to_import) + let mut imports = BTreeSet::new(); + match ty { + ReturnType::Default => ("Promise".to_string(), imports), + ReturnType::Type(_, boxed) => { + // look for Result commonly + let t = &**boxed; + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Result" { + if let Some(ok_ty) = first_type_arg_from_pathargs(&seg.arguments) { + let (ts, is_struct) = rust_type_to_ts(ok_ty); + if is_struct { + if let Some(name) = extract_ident_from_type(ok_ty) { + imports.insert(name); + } + } + return (format!("Promise<{}>", ts), imports); + } + } else { + // not Result - map directly + let (ts, is_struct) = rust_type_to_ts(t); + if is_struct { + if let Some(name) = extract_ident_from_type(t) { + imports.insert(name); + } + } + return (format!("Promise<{}>", ts), imports); + } + } + } + // fallback + ("Promise".to_string(), imports) + } + } +} + +fn snake_to_camel(s: &str) -> String { + let mut parts = s.split('_'); + let mut out = String::new(); + if let Some(first) = parts.next() { + out.push_str(&first.to_ascii_lowercase()); + } + for p in parts { + if p.is_empty() { + continue; + } + let mut chs = p.chars(); + if let Some(c) = chs.next() { + out.push_str(&c.to_ascii_uppercase().to_string()); + out.push_str(&chs.as_str().to_ascii_lowercase()); + } + } + out +} + +#[proc_macro_attribute] +pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { + // Parse inputs as a punctuated list of MetaNameValue (e.g. export_to = "...", import_from = "...") + // `MetaList` implements `Parse` so we can parse the raw attribute token stream reliably + struct MetaList(Punctuated); + impl Parse for MetaList { + fn parse(input: ParseStream) -> syn::Result { + Ok(MetaList(Punctuated::parse_terminated(input)?)) + } + } + let metas = parse_macro_input!(attr as MetaList).0; + let input_fn = parse_macro_input!(item as ItemFn); + + // Extract attribute args: export_to, import_from + let mut export_to: Option = None; + let mut import_from: Option = None; + + for nv in metas.iter() { + if let Some(ident) = nv.path.get_ident() { + let name = ident.to_string(); + if name == "export_to" { + if let Some(v) = get_lit_str_value(nv) { + export_to = Some(v); + } + } else if name == "import_from" { + if let Some(v) = get_lit_str_value(nv) { + import_from = Some(v); + } + } + } + } + + // Analyze function + let fn_name_ident: Ident = input_fn.sig.ident.clone(); + let fn_name = fn_name_ident.to_string(); + let ts_fn_name = snake_to_camel(&fn_name); + + // Collect parameters (ignore State/Window) + let mut param_names: Vec = Vec::new(); + let mut param_defs: Vec = Vec::new(); + let mut import_types: BTreeSet = BTreeSet::new(); + + for input in input_fn.sig.inputs.iter() { + match input { + FnArg::Receiver(_) => { + // skip self + } + FnArg::Typed(pt) => { + // Get parameter identifier + let pat = &*pt.pat; + let param_ident = if let Pat::Ident(pi) = pat { + Some(pi.ident.to_string()) + } else { + // ignore complex patterns + continue; + }; + + // Check if type should be ignored (State, Window) + if is_state_or_window(&*pt.ty) { + continue; + } + + // Map type + let (ts_type, is_struct) = rust_type_to_ts(&*pt.ty); + if is_struct { + if let Some(name) = extract_ident_from_type(&*pt.ty) { + import_types.insert(name); + } + } + + if let Some(pn) = param_ident { + // Convert param name to camelCase - keep existing but ensure camelCase for TS + // We'll convert snake_case param names to camelCase + let ts_param_name = snake_to_camel(&pn); + param_names.push(ts_param_name.clone()); + param_defs.push(format!("{}: {}", ts_param_name, ts_type)); + } + } + } + } + + // Return type + let (return_ts_promise, return_imports) = get_return_ts(&input_fn.sig.output); + for r in return_imports { + import_types.insert(r); + } + + // Build TypeScript code string + let mut ts_lines: Vec = Vec::new(); + + ts_lines.push(r#"import { invoke } from "@tauri-apps/api/core""#.to_string()); + + if !import_types.is_empty() { + if let Some(import_from_str) = import_from.clone() { + let types_joined = import_types.iter().cloned().collect::>().join(", "); + ts_lines.push(format!( + "import {{ {} }} from \"{}\"", + types_joined, import_from_str + )); + } else { + // If no import_from provided, still import types from local path? We'll skip if not provided. + } + } + + // function signature + let params_sig = param_defs.join(", "); + let params_pass = if param_names.is_empty() { + "".to_string() + } else { + // Build object like { majorVersion, imageType } + format!("{}", param_names.join(", ")) + }; + + // Determine return generic for invoke: need the raw type (not Promise<...>) + let invoke_generic = + if return_ts_promise.starts_with("Promise<") && return_ts_promise.ends_with('>') { + &return_ts_promise["Promise<".len()..return_ts_promise.len() - 1] + } else { + "any" + }; + + let invoke_line = if param_names.is_empty() { + format!(" return invoke<{}>(\"{}\")", invoke_generic, fn_name) + } else { + format!( + " return invoke<{}>(\"{}\", {{ {} }})", + invoke_generic, fn_name, params_pass + ) + }; + + ts_lines.push(format!( + "export async function {}({}): {} {{", + ts_fn_name, params_sig, return_ts_promise + )); + ts_lines.push(invoke_line); + ts_lines.push("}".to_string()); + + let ts_contents = ts_lines.join("\n") + "\n"; + + // Prepare test function name + let test_fn_name = Ident::new( + &format!("tauri_export_bindings_{}", fn_name), + fn_name_ident.span(), + ); + + // Generate code for test function that writes the TS string to file + let export_to_literal = match export_to { + Some(ref s) => s.clone(), + None => String::new(), + }; + + // Build tokens + let original_fn = &input_fn; + let ts_string_literal = ts_contents.clone(); + + let write_stmt = if export_to_literal.is_empty() { + // No-op: don't write + // quote! { + // // No export_to provided; skipping file write. + // } + panic!("No export_to provided") + } else { + // We'll append to the file to avoid overwriting existing bindings from other macros. + // Use create(true).append(true) + let path = export_to_literal.clone(); + let ts_lit = syn::LitStr::new(&ts_string_literal, proc_macro2::Span::call_site()); + quote! { + { + // Ensure parent directories exist if possible (best-effort) + let path = std::path::Path::new(#path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Append generated bindings to file + match OpenOptions::new().create(true).append(true).open(path) { + Ok(mut f) => { + let _ = f.write_all(#ts_lit.as_bytes()); + println!("Successfully wrote to {}", path.display()); + } + Err(e) => { + eprintln!("dropout::api binding write failed: {}", e); + } + } + } + } + }; + + let gen = quote! { + #original_fn + + #[cfg(test)] + mod __dropout_export_tests { + use super::*; + use std::fs::OpenOptions; + use std::io::Write; + + #[test] + fn #test_fn_name() { + // Generated TypeScript bindings for function: #fn_name + #write_stmt + } + } + }; + + gen.into() +} -- cgit v1.2.3-70-g09d2