use convert_case::{Case, Casing}; use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{FnArg, ItemFn, Pat, parse_macro_input}; /// A macro to help generate wrappers which are used by some clients to support deserialisation of parameters /// for calls, and serialisation of return values, usually with JSON in mind. /// /// # Parameters /// /// This paragraph explains how the proc-macro can be called. /// /// * `JSONReturnType` /// - Use it like `but_api(JSONReturnType)` where `JSONReturnType::from(actual_return_type)` is implemented. /// - `Result>` converts each `T` into `JSONReturnType`. /// - Controls how the actual return value is converted for JSON serialization in `func_json` and `func_cmd`. /// * `try_from = JSONReturnType` /// - Use it like `but_api(try_from = JSONReturnType)` where `JSONReturnType::try_from(actual_return_type)?` is implemented. /// - `Result>` converts each `T` into `JSONReturnType`. /// - Controls how the actual return value is fallibly converted for JSON serialization in `func_json` and `func_cmd`. /// /// * `refreshes` /// - Use it on a mutation like `but_api(refreshes)` whose effects other GitButler processes /// can't observe in the repository: forge, app, and config writes. /// - A successful call writes the project's refresh sentinel, so the watchers of other /// processes re-read the workspace. A failed call writes nothing. /// - The function must take a context, which locates the project's storage directory. /// /// # Parameter Attributes /// /// Function parameters may use `#[but_api(TransportType)]` to keep the implementation /// signature on a pure Rust type while generated wrappers accept a transport-oriented serde type. /// This is currently limited to owned parameters and requires `impl From for ParamType`. /// /// Functions may also take an explicit repository permission token which stays internal to generated /// wrappers: /// * `perm: &mut RepoExclusive` acquires exclusive access from the generated wrapper. /// * `perm: &RepoShared` acquires shared access from the generated wrapper. /// /// These permission-bearing variants are more composable for Rust callers because they reuse a /// caller-held lock instead of acquiring one themselves. /// /// These can be combined with commas, e.g. `#[but_api(try_from = json::CommitDetails, refreshes)]`. /// /// # Generated Functions /// /// This paragraph explains what it generates. /// /// * `func` - the original item, unchanged /// * `func_json` for calls from the frontend, taking `(#(json_params*),)` and returning `Result` /// - This is also annotated with the `tauri` macro when the feature is enabled in the `but-api` crate. /// - **Parameter Transformation** /// - It supports `but_ctx::Context`, `&Context`, `&mut Context` or `ThreadSafeContext` as parameter, /// which will be translated to `project_id`: /// - `but_ctx::ProjectHandleOrLegacyProjectId` /// - `gix::ObjectId` will be translated into `json::HexHash`. /// - `Option` will be translated into `Option`. /// - `Vec` will be translated into `Vec`. /// - `&gix::refs::FullNameRef` will be translated into owned `gix::refs::FullName`. /// - `&mut RepoExclusive` and `&RepoShared` stay internal and are derived from the context /// parameter by acquiring the matching lock in the generated wrapper. /// * `func_cmd` for calls from the `but-server`, taking `(params: Params) ` and returning `Result`. /// - It performs all **Parameter Transformations** of `func_json`. #[proc_macro_attribute] pub fn but_api(attr: TokenStream, item: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(item as ItemFn); let sanitized_input_fn = strip_param_but_api_attrs(&input_fn); let vis = &input_fn.vis; let sig = &input_fn.sig; let fn_name = &sig.ident; let asyncness = &sig.asyncness; let input = &sig.inputs; let output = &sig.output; let result_container = result_container(match output { syn::ReturnType::Type(_, ty) => ty.as_ref(), syn::ReturnType::Default => panic!("function must return a type"), }); let opts = if attr.is_empty() { Options::default() } else { match syn::parse::Parser::parse( |input: syn::parse::ParseStream| parse_options(input, result_container), attr, ) { Ok(opts) => opts, Err(err) => return err.into_compile_error().into(), } }; let sanitized_input_fn = match opts.refreshes { Some(span) => match refresh_on_success(sanitized_input_fn, span) { Ok(input_fn) => input_fn, Err(err) => return err.into_compile_error().into(), }, None => sanitized_input_fn, }; let wrapper_params = match build_wrapper_params(input.iter()) { Ok(info) => info, Err(err) => return err.into_compile_error().into(), }; let struct_fields_with_json_types = &wrapper_params.struct_fields_with_json_types; let param_field_names = &wrapper_params.param_field_names; let json_fn_input_params = &wrapper_params.json_fn_input_params; let param_conversions = &wrapper_params.param_conversions; let call_arg_idents = &wrapper_params.call_arg_idents; let call_fn_args = if asyncness.is_some() { quote! {{ let __call_result = ::futures::FutureExt::catch_unwind(::std::panic::AssertUnwindSafe(async { #fn_name(#(#call_arg_idents),*).await })) .await .map_err(|__panic_payload| { crate::panic_capture::panic_payload_to_anyhow(stringify!(#fn_name), __panic_payload) })?; __call_result }} } else { quote! {{ let __call_result = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { #fn_name(#(#call_arg_idents),*) })) .map_err(|__panic_payload| { crate::panic_capture::panic_payload_to_anyhow(stringify!(#fn_name), __panic_payload) })?; __call_result }} }; // Struct name: Params (PascalCase) let param_struct_name = format_ident!("{}Params", fn_name.to_string().to_case(Case::Pascal)); // Cmd function name: _cmd let fn_cmd_name = format_ident!("{}_cmd", fn_name); // Cmd function name: _json let fn_json_name = format_ident!("{}_json", fn_name); // Module name for tauri-renames, to keep the original function names. let tauri_mod_name = format_ident!("tauri_{}", fn_name); let tauri_cmd_name = format_ident!("__cmd__{}", fn_json_name); let tauri_orig_cmd_name = format_ident!("__cmd__{}", fn_name); let fn_name_str = fn_name.to_string(); let tauri_command_name_macro = format_ident!("__tauri_command_name_{}", fn_json_name); let tauri_orig_command_name_macro = format_ident!("__tauri_command_name_{}", fn_name); let (convert_to_json_result_type, json_ty) = if let Some(ResultConversion { mode, container, json_ty, json_ty_rval, }) = opts.result_conversion { let convert = match mode { FromMode::From => match container { ResultContainer::Value => quote! { let result: #json_ty = result.into(); }, ResultContainer::Option => quote! { let result: Option<#json_ty> = result.map(Into::into); }, ResultContainer::Vec => quote! { let result: Vec<#json_ty> = result.into_iter().map(Into::into).collect(); }, }, FromMode::TryFrom => match container { ResultContainer::Value => quote! { let result: #json_ty = result.try_into()?; }, ResultContainer::Option => quote! { let result: Option<#json_ty> = result.map(TryInto::try_into).transpose()?; }, ResultContainer::Vec => quote! { let result: Vec<#json_ty> = result .into_iter() .map(TryInto::try_into) .collect::>()?; }, }, }; (convert, json_ty_rval) } else { let return_type = match extract_ok_type(output) { Ok(ty_path) => ty_path, Err(err) => return err.to_compile_error().into(), }; (quote! {}, return_type) }; let legacy_cfg_if_json_mapping_is_used = if wrapper_params.requires_legacy { quote! { #[cfg(feature = "legacy")] } } else { quote! {} }; let expanded = quote! { // Generated struct #[cfg(feature = "legacy")] #[derive(::serde::Deserialize)] #[serde(rename_all = "camelCase")] struct #param_struct_name { #(#struct_fields_with_json_types,)* } // Original function stays #sanitized_input_fn const _: () = { #[expect(dead_code)] fn keep_json(_json: #json_ty) {} }; /// Cmd function - this is legacy while most of its functionality depends on legacy project integration. /// parameter struct input via json value, json output. #[cfg(feature = "legacy")] #vis #asyncness fn #fn_cmd_name( params: ::serde_json::Value, ) -> anyhow::Result<::serde_json::Value> { let #param_struct_name { #(#param_field_names),* } = ::serde_json::from_value(params)?; #(#param_conversions);* let result = #call_fn_args?; #convert_to_json_result_type Ok(::serde_json::to_value(result)?) } /// tauri function - json input, json output, by #fn_name #[cfg_attr(feature = "tauri", tauri::command(async, rename = #fn_name_str))] #legacy_cfg_if_json_mapping_is_used #vis #asyncness fn #fn_json_name( #(#json_fn_input_params),* ) -> std::result::Result<#json_ty, crate::json::Error> { #(#param_conversions);* let result = #call_fn_args?; #convert_to_json_result_type Ok(result) } /// A dummy module just to make generated tauri functions available *and* working. #[cfg(feature = "tauri")] pub mod #tauri_mod_name { pub use super::#fn_json_name as #fn_name; pub use super::#tauri_cmd_name as #tauri_orig_cmd_name; pub use super::#tauri_command_name_macro as #tauri_orig_command_name_macro; } }; expanded.into() } /// Wrap the body so a successful call writes the project's refresh sentinel, /// for the watchers of other processes. The storage directory comes from the /// context parameter, so an endpoint without one is an error at `span`. fn refresh_on_success(mut input_fn: ItemFn, span: proc_macro2::Span) -> syn::Result { let Some(ctx_ident) = input_fn.sig.inputs.iter().find_map(|arg| { let FnArg::Typed(pat_ty) = arg else { return None; }; context_param_kind(&pat_ty.ty)?; match &*pat_ty.pat { Pat::Ident(pat) => Some(pat.ident.clone()), _ => None, } }) else { return Err(syn::Error::new( span, "`refreshes` needs a context parameter to find the project's storage directory", )); }; let syn::ReturnType::Type(_, output_ty) = &input_fn.sig.output else { return Ok(input_fn); }; let body = &input_fn.block; // A closure or async block keeps `?` and `return` inside the body meaning // what they did, with the fn's own return type. let run_body = if input_fn.sig.asyncness.is_some() { quote! { let __but_api_result: #output_ty = async move #body.await; } } else { quote! { #[allow(clippy::redundant_closure_call)] let __but_api_result = (move || -> #output_ty #body)(); } }; let block: syn::Block = syn::parse_quote! {{ let __but_api_project_data_dir = #ctx_ident.project_data_dir.clone(); #run_body if __but_api_result.is_ok() { ::but_project_handle::write_refresh_sentinel(&__but_api_project_data_dir); } __but_api_result }}; input_fn.block = Box::new(block); Ok(input_fn) } struct WrapperParamsInfo { struct_fields_with_json_types: Vec, param_field_names: Vec, json_fn_input_params: Vec, param_conversions: Vec, call_arg_idents: Vec, requires_legacy: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ContextParamKind { Context, ThreadSafeContext, } struct ContextParamBinding { ident: syn::Ident, kind: ContextParamKind, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PermissionParamKind { Exclusive, Shared, } struct PermissionParamBinding { ty: syn::Type, kind: PermissionParamKind, guard_ident: syn::Ident, } struct WrapperParameterMapping { transport_ty: syn::Type, binding_ty: syn::Type, json_ident: Option, conversion_kind: WrapperConversionKind, call_arg_kind: WrapperCallArgKind, requires_legacy: bool, } enum WrapperConversionKind { From, TryFrom, OptionFrom, VecFrom, Identity, } enum WrapperCallArgKind { Same, Ref { mutable: bool }, AsRef, } /// A custom wrapper-surface transport type declared on a function parameter via /// `#[but_api(TransportType)]`. struct ParamTransportMapping { transport_ty: syn::Type, } fn build_wrapper_params<'a>( input: impl IntoIterator, ) -> Result { let mut struct_fields_with_json_types = Vec::new(); let mut param_field_names = Vec::new(); let mut json_fn_input_params = Vec::new(); let mut param_conversions = Vec::new(); let mut call_arg_idents = Vec::new(); let mut requires_legacy = false; let mut context_bindings = Vec::new(); let mut permission_bindings = Vec::new(); for arg in input { let FnArg::Typed(pat_ty) = arg else { return Err(syn::Error::new_spanned( arg, "Cannot handle &self, &mut self or self", )); }; let Pat::Ident(pat_ident) = &*pat_ty.pat else { return Err(syn::Error::new_spanned( pat_ty, "Cannot handle this identifier", )); }; let ident = &pat_ident.ident; if let Some(context_kind) = context_param_kind(&pat_ty.ty) { context_bindings.push(ContextParamBinding { ident: ident.clone(), kind: context_kind, }); } if let Some(custom_transport) = parse_param_transport_mapping(pat_ty)? { let transport_ty = &custom_transport.transport_ty; let binding_ty = &pat_ty.ty; param_field_names.push(ident.clone()); struct_fields_with_json_types.push(quote! { pub #ident: #transport_ty }); json_fn_input_params.push(syn::parse_quote! { #ident: #transport_ty }); param_conversions.push(quote! { let mut #ident: #binding_ty = <#binding_ty>::from(#ident); }); call_arg_idents.push(quote! { #ident }); continue; } if let Some(permission_kind) = permission_param_kind(&pat_ty.ty)? { let guard_ident = permission_guard_ident(ident); permission_bindings.push(PermissionParamBinding { ty: (*pat_ty.ty).clone(), kind: permission_kind, guard_ident: guard_ident.clone(), }); call_arg_idents.push(match permission_kind { PermissionParamKind::Exclusive => quote! { #guard_ident.write_permission() }, PermissionParamKind::Shared => quote! { #guard_ident.read_permission() }, }); continue; } if let Some(mapping) = build_wrapper_parameter_mapping(&pat_ty.ty)? { let json_ident = mapping.json_ident.unwrap_or_else(|| ident.clone()); let transport_ty = &mapping.transport_ty; let binding_ty = &mapping.binding_ty; param_field_names.push(json_ident.clone()); struct_fields_with_json_types.push(quote! { pub #json_ident: #transport_ty }); json_fn_input_params.push(syn::parse_quote! { #json_ident: #transport_ty }); param_conversions.push(match mapping.conversion_kind { WrapperConversionKind::From => quote! { let mut #ident: #binding_ty = <#binding_ty>::from(#json_ident); }, WrapperConversionKind::TryFrom => quote! { let mut #ident: #binding_ty = <#binding_ty>::try_from(#json_ident)?; }, WrapperConversionKind::OptionFrom => quote! { let mut #ident: #binding_ty = #json_ident.map(Into::into); }, WrapperConversionKind::VecFrom => quote! { let mut #ident: #binding_ty = #json_ident.into_iter().map(Into::into).collect(); }, WrapperConversionKind::Identity => quote! { let mut #ident: #binding_ty = #json_ident; }, }); call_arg_idents.push(match mapping.call_arg_kind { WrapperCallArgKind::Same => quote! { #ident }, WrapperCallArgKind::Ref { mutable: true } => quote! { &mut #ident }, WrapperCallArgKind::Ref { mutable: false } => quote! { &#ident }, WrapperCallArgKind::AsRef => quote! { #ident.as_ref() }, }); requires_legacy |= mapping.requires_legacy; } else { param_field_names.push(ident.clone()); let ty = &pat_ty.ty; struct_fields_with_json_types.push(quote! { pub #ident: #ty }); json_fn_input_params.push(arg.clone()); call_arg_idents.push(quote! { #ident }); } } if let Some(permission) = permission_bindings.first() { if permission_bindings.len() > 1 { return Err(syn::Error::new_spanned( &permission.ty, "Only one explicit repository permission parameter is supported", )); } let context_binding = match context_bindings.as_slice() { [] => { return Err(syn::Error::new_spanned( &permission.ty, "Explicit repository permission parameters require a `Context`, `&Context`, or `&mut Context` parameter", )); } [binding] => binding, _ => { return Err(syn::Error::new_spanned( &permission.ty, "Explicit repository permission parameters require exactly one context parameter", )); } }; if context_binding.kind == ContextParamKind::ThreadSafeContext { return Err(syn::Error::new_spanned( &permission.ty, "Explicit repository permission parameters are not supported with `ThreadSafeContext`; convert it to `Context` before locking", )); } let context_ident = &context_binding.ident; let guard_ident = &permission.guard_ident; param_conversions.push(match permission.kind { PermissionParamKind::Exclusive => quote! { let mut #guard_ident = #context_ident.exclusive_worktree_access(); }, PermissionParamKind::Shared => quote! { let #guard_ident = #context_ident.shared_worktree_access(); }, }); } Ok(WrapperParamsInfo { struct_fields_with_json_types, param_field_names, json_fn_input_params, param_conversions, call_arg_idents, requires_legacy, }) } /// Remove parameter-level `#[but_api(...)]` attributes from the original function item before it /// is emitted again, as only the proc-macro implementation should interpret them. fn strip_param_but_api_attrs(input_fn: &ItemFn) -> ItemFn { let mut stripped = input_fn.clone(); for arg in &mut stripped.sig.inputs { let FnArg::Typed(pat_ty) = arg else { continue; }; pat_ty.attrs.retain(|attr| !attr.path().is_ident("but_api")); } stripped } /// Parse an optional parameter-local transport override from `#[but_api(TransportType)]`. /// /// This mirrors the top-level `#[but_api(Type)]` shorthand by treating the provided type as the /// wrapper-facing transport type and assuming `impl From for ParamType`. /// The override is intentionally limited to owned parameters and cannot overlap with built-in /// remappings like `Context` or `gix::ObjectId`. fn parse_param_transport_mapping( pat_ty: &syn::PatType, ) -> Result, syn::Error> { let mut transport_ty = None; for attr in &pat_ty.attrs { if !attr.path().is_ident("but_api") { continue; } let parsed: syn::Type = attr.parse_args_with(|input: syn::parse::ParseStream| { if input.peek(syn::Ident) && input.peek2(syn::Token![=]) { let ident: syn::Ident = input.parse()?; return Err(syn::Error::new_spanned( ident, "Expected `Type`; use `#[but_api(Type)]` instead of `#[but_api(transport = Type)]` for parameter attributes", )); } let ty: syn::Type = input.parse()?; if !input.is_empty() { return Err(input.error("Unexpected tokens after parameter transport type")); } Ok(ty) })?; if transport_ty.is_some() { return Err(syn::Error::new_spanned( attr, "Only one `#[but_api(Type)]` attribute may be specified per parameter", )); } if matches!(&*pat_ty.ty, syn::Type::Reference(_)) { return Err(syn::Error::new_spanned( &pat_ty.ty, "`#[but_api(Type)]` only supports owned parameters", )); } if build_wrapper_parameter_mapping(&pat_ty.ty)?.is_some() { return Err(syn::Error::new_spanned( &pat_ty.ty, "`#[but_api(Type)]` cannot be combined with built-in parameter remapping", )); } transport_ty = Some(parsed); } Ok(transport_ty.map(|transport_ty| ParamTransportMapping { transport_ty })) } fn build_wrapper_parameter_mapping( ty: &syn::Type, ) -> Result, syn::Error> { match ty { syn::Type::Reference(reference) => { let syn::Type::Path(type_path) = &*reference.elem else { return Err(syn::Error::new_spanned( &reference.elem, "Expected a type path inside reference", )); }; let path = &type_path.path; if is_context_path(path) { let binding_ty: syn::Type = (*reference.elem).clone(); return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { but_ctx::ProjectHandleOrLegacyProjectId }, binding_ty, json_ident: Some(syn::parse_str("project_id")?), conversion_kind: WrapperConversionKind::TryFrom, call_arg_kind: WrapperCallArgKind::Ref { mutable: reference.mutability.is_some(), }, requires_legacy: true, })); } if is_full_name_ref_path(path) { return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { gix::refs::FullName }, binding_ty: syn::parse_quote! { gix::refs::FullName }, json_ident: None, conversion_kind: WrapperConversionKind::Identity, call_arg_kind: WrapperCallArgKind::AsRef, requires_legacy: false, })); } Err(syn::Error::new_spanned( ty, "Only `&Context`, `&but_ctx::Context`, `&ThreadSafeContext`, or `&gix::refs::FullNameRef` may be references", )) } syn::Type::Path(type_path) => { let path = &type_path.path; if is_context_path(path) { return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { but_ctx::ProjectHandleOrLegacyProjectId }, binding_ty: ty.clone(), json_ident: Some(syn::parse_str("project_id")?), conversion_kind: WrapperConversionKind::TryFrom, call_arg_kind: WrapperCallArgKind::Same, requires_legacy: true, })); } if is_object_id_path(path) { return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { crate::json::HexHash }, binding_ty: ty.clone(), json_ident: None, conversion_kind: WrapperConversionKind::From, call_arg_kind: WrapperCallArgKind::Same, requires_legacy: false, })); } if let Some(inner_ty) = single_generic_type_arg(path, "Option") && is_object_id_type(inner_ty) { return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { Option }, binding_ty: ty.clone(), json_ident: None, conversion_kind: WrapperConversionKind::OptionFrom, call_arg_kind: WrapperCallArgKind::Same, requires_legacy: false, })); } if let Some(inner_ty) = single_generic_type_arg(path, "Vec") && is_object_id_type(inner_ty) { return Ok(Some(WrapperParameterMapping { transport_ty: syn::parse_quote! { Vec }, binding_ty: ty.clone(), json_ident: None, conversion_kind: WrapperConversionKind::VecFrom, call_arg_kind: WrapperCallArgKind::Same, requires_legacy: false, })); } Ok(None) } _ => Ok(None), } } fn context_param_kind(ty: &syn::Type) -> Option { let path = match ty { syn::Type::Reference(reference) => match &*reference.elem { syn::Type::Path(type_path) => &type_path.path, _ => return None, }, syn::Type::Path(type_path) => &type_path.path, _ => return None, }; if !is_context_path(path) { return None; } path.segments.last().map(|segment| { if segment.ident == "ThreadSafeContext" { ContextParamKind::ThreadSafeContext } else { ContextParamKind::Context } }) } fn permission_param_kind(ty: &syn::Type) -> Result, syn::Error> { let syn::Type::Reference(reference) = ty else { return Ok(None); }; let syn::Type::Path(type_path) = &*reference.elem else { return Ok(None); }; let path = &type_path.path; let Some(last) = path.segments.last() else { return Ok(None); }; let kind = match last.ident.to_string().as_str() { "RepoExclusive" => PermissionParamKind::Exclusive, "RepoShared" => PermissionParamKind::Shared, _ => return Ok(None), }; let supported_root = path.segments.len() == 1 || path .segments .first() .is_some_and(|first| first.ident == "but_core" || first.ident == "but_ctx"); if !supported_root { return Ok(None); } match kind { PermissionParamKind::Exclusive if reference.mutability.is_some() => Ok(Some(kind)), PermissionParamKind::Shared if reference.mutability.is_none() => Ok(Some(kind)), PermissionParamKind::Exclusive => Err(syn::Error::new_spanned( ty, "Explicit repository permission parameters must use `&mut RepoExclusive` for exclusive access", )), PermissionParamKind::Shared => Err(syn::Error::new_spanned( ty, "Explicit repository permission parameters must use `&RepoShared` for shared access", )), } } fn permission_guard_ident(ident: &syn::Ident) -> syn::Ident { let ident = ident.to_string(); let ident = ident.trim_start_matches('_'); let suffix = if ident.is_empty() { "perm" } else { ident }; format_ident!("__but_api_{}_guard", suffix) } fn is_context_path(path: &syn::Path) -> bool { path.segments .last() .is_some_and(|last| last.ident == "Context" || last.ident == "ThreadSafeContext") && (path.segments.len() == 1 || path.segments[0].ident == "but_ctx") } fn is_object_id_path(path: &syn::Path) -> bool { path.segments .last() .is_some_and(|last| last.ident == "ObjectId") && (path.segments.len() == 1 || path.segments[0].ident == "gix") } fn is_object_id_type(ty: &syn::Type) -> bool { matches!(ty, syn::Type::Path(type_path) if is_object_id_path(&type_path.path)) } fn is_full_name_ref_path(path: &syn::Path) -> bool { path.segments .last() .is_some_and(|last| last.ident == "FullNameRef") && (path.segments.len() == 1 || (path.segments.len() >= 3 && path.segments[0].ident == "gix" && path.segments[1].ident == "refs")) } fn single_generic_type_arg<'a>(path: &'a syn::Path, expected: &str) -> Option<&'a syn::Type> { let last = path.segments.last()?; if last.ident != expected { return None; } let syn::PathArguments::AngleBracketed(args) = &last.arguments else { return None; }; match args.args.first()? { syn::GenericArgument::Type(ty) => Some(ty), _ => None, } } fn extract_ok_type(output: &syn::ReturnType) -> syn::Result { let ty = match output { syn::ReturnType::Type(_, ty) => ty.as_ref(), _ => { return Err(syn::Error::new_spanned( output, "function must return a type", )); } }; let syn::Type::Path(tp) = ty else { return Err(syn::Error::new_spanned(ty, "expected a type path")); }; let last = tp .path .segments .last() .ok_or_else(|| syn::Error::new_spanned(tp, "unexpected empty type path"))?; if last.ident != "Result" { return Err(syn::Error::new_spanned( last, "expected Result or Result", )); } let syn::PathArguments::AngleBracketed(args) = &last.arguments else { return Err(syn::Error::new_spanned( last, "expected Result or Result", )); }; if args.args.is_empty() { return Err(syn::Error::new_spanned( args, "Result must have at least one generic parameter", )); } match args.args.first().unwrap() { syn::GenericArgument::Type(t) => Ok(t.clone()), other => Err(syn::Error::new_spanned( other, "expected a type as first generic parameter", )), } } /// How to convert a result value/outcome to its serialised version. #[derive(Debug)] enum FromMode { From, TryFrom, } #[derive(Default)] struct Options { /// It's `None` if the result type converts to JSON naturally. /// Otherwise, we convert to it. result_conversion: Option, /// Where `refreshes` was written, if it was: a successful call writes the /// refresh sentinel. refreshes: Option, } struct ResultConversion { /// If the result type conversion is fallbile. mode: FromMode, /// Container around the converted value in the function's `Result`. container: ResultContainer, /// The type to convert *to* for json. json_ty: syn::Type, /// The resulting JSON type after applying container wrapping. json_ty_rval: syn::Type, } fn parse_options( input: syn::parse::ParseStream, container: ResultContainer, ) -> syn::Result { let mut conversion_path: Option<(FromMode, syn::Path)> = None; let mut refreshes: Option = None; while !input.is_empty() { let is_refreshes = input.peek(syn::Ident) && (input.peek2(syn::Token![,]) || input.peek2(syn::parse::End)) && input.fork().parse::()? == "refreshes"; if is_refreshes { let ident: syn::Ident = input.parse()?; if refreshes.is_some() { return Err(syn::Error::new_spanned( &ident, "`refreshes` may be specified only once", )); } refreshes = Some(ident.span()); } else if input.peek(syn::Ident) && input.peek2(syn::Token![=]) { // try_from = Path let ident: syn::Ident = input.parse()?; if ident != "try_from" { return Err(syn::Error::new_spanned( ident, "Expected `try_from = Type` or `refreshes`", )); } input.parse::()?; let path: syn::Path = input.parse()?; if conversion_path.is_some() { return Err(syn::Error::new_spanned( path, "Only one conversion type may be specified", )); } conversion_path = Some((FromMode::TryFrom, path)); } else { let path: syn::Path = input.parse()?; if conversion_path.is_some() { return Err(syn::Error::new_spanned( path, "Only one conversion type may be specified", )); } conversion_path = Some((FromMode::From, path)); } if !input.is_empty() { input.parse::()?; } } let result_conversion = conversion_path.map(|(mode, p)| { let base_ty = syn::Type::Path(syn::TypePath { qself: None, path: p, }); let json_ty_rval = match container { ResultContainer::Value => base_ty.clone(), ResultContainer::Option => syn::parse_quote! { Option<#base_ty> }, ResultContainer::Vec => syn::parse_quote! { Vec<#base_ty> }, }; ResultConversion { mode, container, json_ty: base_ty, json_ty_rval, } }); Ok(Options { result_conversion, refreshes, }) } #[derive(Clone, Copy)] enum ResultContainer { Value, Option, Vec, } /// Detect the supported container directly inside `Result`. fn result_container(ty: &syn::Type) -> ResultContainer { if let syn::Type::Path(tp) = ty && let Some(seg) = tp.path.segments.last() && seg.ident == "Result" && let syn::PathArguments::AngleBracketed(args) = &seg.arguments && let Some(syn::GenericArgument::Type(inner)) = args.args.first() && let syn::Type::Path(tp) = inner && let Some(first) = tp.path.segments.last() { match first.ident.to_string().as_str() { "Option" => ResultContainer::Option, "Vec" => ResultContainer::Vec, _ => ResultContainer::Value, } } else { ResultContainer::Value } } #[cfg(test)] mod tests { use quote::quote; use syn::{FnArg, parse_quote}; use super::{ ContextParamKind, PermissionParamKind, WrapperCallArgKind, WrapperConversionKind, build_wrapper_parameter_mapping, build_wrapper_params, context_param_kind, permission_param_kind, }; #[test] fn maps_object_id_to_hex_hash_transport() { let ty = parse_quote!(gix::ObjectId); let mapping = build_wrapper_parameter_mapping(&ty).unwrap().unwrap(); let transport_ty = &mapping.transport_ty; assert_eq!( quote!(#transport_ty).to_string(), quote!(crate::json::HexHash).to_string() ); assert!( matches!(mapping.conversion_kind, WrapperConversionKind::From), "gix::ObjectId should use a direct From-based transport conversion" ); assert!( matches!(mapping.call_arg_kind, WrapperCallArgKind::Same), "gix::ObjectId wrapper parameters should be passed through without call-site remapping" ); } #[test] fn maps_option_and_vec_of_object_id_to_hex_hash_containers() { let option_ty = parse_quote!(Option); let option_mapping = build_wrapper_parameter_mapping(&option_ty) .unwrap() .unwrap(); let option_transport_ty = &option_mapping.transport_ty; assert_eq!( quote!(#option_transport_ty).to_string(), quote!(Option).to_string() ); assert!( matches!( option_mapping.conversion_kind, WrapperConversionKind::OptionFrom ), "Option should map with OptionFrom conversion" ); let vec_ty = parse_quote!(Vec); let vec_mapping = build_wrapper_parameter_mapping(&vec_ty).unwrap().unwrap(); let vec_transport_ty = &vec_mapping.transport_ty; assert_eq!( quote!(#vec_transport_ty).to_string(), quote!(Vec).to_string() ); assert!(matches!( vec_mapping.conversion_kind, WrapperConversionKind::VecFrom ),); } #[test] fn maps_context_to_project_id() { let arg: FnArg = parse_quote!(ctx: &mut but_ctx::Context); let params = build_wrapper_params([&arg]).unwrap(); let struct_fields = ¶ms.struct_fields_with_json_types; let json_inputs = ¶ms.json_fn_input_params; let call_args = ¶ms.call_arg_idents; assert_eq!( quote!(#(#struct_fields),*).to_string(), quote!(pub project_id: but_ctx::ProjectHandleOrLegacyProjectId).to_string() ); assert_eq!( quote!(#(#json_inputs),*).to_string(), quote!(project_id: but_ctx::ProjectHandleOrLegacyProjectId).to_string() ); assert_eq!( quote!(#(#call_args),*).to_string(), quote!(&mut ctx).to_string() ); assert!( params.requires_legacy, "context parameters should require the legacy project-id wrapper setup" ); } #[test] fn detects_supported_permission_tokens() { let exclusive_ty = parse_quote!(&mut but_ctx::access::RepoExclusive); let shared_ty = parse_quote!(&but_core::sync::RepoShared); assert_eq!( permission_param_kind(&exclusive_ty).unwrap(), Some(PermissionParamKind::Exclusive) ); assert_eq!( permission_param_kind(&shared_ty).unwrap(), Some(PermissionParamKind::Shared) ); } #[test] fn rejects_invalid_permission_token_forms() { let exclusive_ty = parse_quote!(&but_ctx::access::RepoExclusive); let shared_ty = parse_quote!(&mut but_ctx::access::RepoShared); assert!(permission_param_kind(&exclusive_ty).is_err()); assert!(permission_param_kind(&shared_ty).is_err()); } #[test] fn detects_thread_safe_context_bindings() { let thread_safe_ctx: syn::Type = parse_quote!(but_ctx::ThreadSafeContext); let borrowed_ctx: syn::Type = parse_quote!(&mut but_ctx::Context); assert_eq!( context_param_kind(&thread_safe_ctx), Some(ContextParamKind::ThreadSafeContext) ); assert_eq!( context_param_kind(&borrowed_ctx), Some(ContextParamKind::Context) ); } #[test] /// Verifies that `&FullNameRef` parameters are transported as owned `FullName` values. fn maps_full_name_ref_to_owned_full_name_transport() { let arg: FnArg = parse_quote!(existing_branch: &gix::refs::FullNameRef); let params = build_wrapper_params([&arg]).unwrap(); let struct_fields = ¶ms.struct_fields_with_json_types; let json_inputs = ¶ms.json_fn_input_params; let call_args = ¶ms.call_arg_idents; assert_eq!( quote!(#(#struct_fields),*).to_string(), quote!(pub existing_branch: gix::refs::FullName).to_string() ); assert_eq!( quote!(#(#json_inputs),*).to_string(), quote!(existing_branch: gix::refs::FullName).to_string() ); assert_eq!( quote!(#(#call_args),*).to_string(), quote!(existing_branch.as_ref()).to_string() ); assert!( !params.requires_legacy, "full-name references should not force legacy wrapper generation" ); } }