A better Rust ATProto crate
1//! # Derive macros for jacquard lexicon types 2//! 3//! This crate provides attribute and derive macros for working with Jacquard types. 4//! The code generator uses `#[lexicon]` and `#[open_union]` to add lexicon-specific behavior. 5//! You'll use `#[derive(IntoStatic)]` frequently, and `#[derive(XrpcRequest)]` when defining 6//! custom XRPC endpoints. 7//! 8//! ## Macros 9//! 10//! ### `#[lexicon]` 11//! 12//! Adds an `extra_data` field to structs to capture unknown fields during deserialization. 13//! This makes objects "open" - they'll accept and preserve fields not defined in the schema. 14//! 15//! ```ignore 16//! #[lexicon] 17//! struct Post<'s> { 18//! text: &'s str, 19//! } 20//! // Expands to add: 21//! // #[serde(flatten)] 22//! // pub extra_data: BTreeMap<SmolStr, Data<'s>> 23//! ``` 24//! 25//! ### `#[open_union]` 26//! 27//! Adds an `Unknown(Data)` variant to enums to make them extensible unions. This lets 28//! enums accept variants not defined in your code, storing them as loosely typed atproto `Data`. 29//! 30//! ```ignore 31//! #[open_union] 32//! enum RecordEmbed<'s> { 33//! #[serde(rename = "app.bsky.embed.images")] 34//! Images(Images), 35//! } 36//! // Expands to add: 37//! // #[serde(untagged)] 38//! // Unknown(Data<'s>) 39//! ``` 40//! 41//! ### `#[derive(IntoStatic)]` 42//! 43//! Derives conversion from borrowed (`'a`) to owned (`'static`) types by recursively calling 44//! `.into_static()` on all fields. Works with structs and enums. 45//! 46//! ```ignore 47//! #[derive(IntoStatic)] 48//! struct Post<'a> { 49//! text: CowStr<'a>, 50//! } 51//! // Generates: 52//! // impl IntoStatic for Post<'_> { 53//! // type Output = Post<'static>; 54//! // fn into_static(self) -> Self::Output { ... } 55//! // } 56//! ``` 57//! 58//! ### `#[derive(XrpcRequest)]` 59//! 60//! Derives XRPC request traits for custom endpoints. Generates the response marker struct 61//! and implements `XrpcRequest` (and optionally `XrpcEndpoint` for server-side). 62//! 63//! ```ignore 64//! #[derive(Serialize, Deserialize, XrpcRequest)] 65//! #[xrpc( 66//! nsid = "com.example.getThing", 67//! method = Query, 68//! output = GetThingOutput, 69//! )] 70//! struct GetThing<'a> { 71//! #[serde(borrow)] 72//! pub id: CowStr<'a>, 73//! } 74//! // Generates: 75//! // - GetThingResponse struct 76//! // - impl XrpcResp for GetThingResponse 77//! // - impl XrpcRequest for GetThing 78//! ``` 79 80use proc_macro::TokenStream; 81use quote::{format_ident, quote}; 82use syn::{Attribute, Data, DeriveInput, Fields, GenericParam, Ident, LitStr, parse_macro_input}; 83 84/// Helper function to check if a struct derives bon::Builder or Builder 85fn has_derive_builder(attrs: &[Attribute]) -> bool { 86 attrs.iter().any(|attr| { 87 if !attr.path().is_ident("derive") { 88 return false; 89 } 90 91 // Parse the derive attribute to check its contents 92 if let Ok(list) = attr.parse_args_with( 93 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated, 94 ) { 95 list.iter().any(|path| { 96 // Check for "Builder" or "bon::Builder" 97 path.segments 98 .last() 99 .map(|seg| seg.ident == "Builder") 100 .unwrap_or(false) 101 }) 102 } else { 103 false 104 } 105 }) 106} 107 108/// Check if struct name conflicts with types referenced by bon::Builder macro. 109/// bon::Builder generates code that uses unqualified `Option` and `Result`, 110/// so structs with these names cause compilation errors. 111fn conflicts_with_builder_macro(ident: &Ident) -> bool { 112 matches!(ident.to_string().as_str(), "Option" | "Result") 113} 114 115/// Attribute macro that adds an `extra_data` field to structs to capture unknown fields 116/// during deserialization. 117/// 118/// # Example 119/// ```ignore 120/// #[lexicon] 121/// struct Post<'s> { 122/// text: &'s str, 123/// } 124/// // Expands to: 125/// // struct Post<'s> { 126/// // text: &'s str, 127/// // #[serde(flatten)] 128/// // pub extra_data: BTreeMap<SmolStr, Data<'s>>, 129/// // } 130/// ``` 131#[proc_macro_attribute] 132pub fn lexicon(_attr: TokenStream, item: TokenStream) -> TokenStream { 133 let mut input = parse_macro_input!(item as DeriveInput); 134 135 match &mut input.data { 136 Data::Struct(data_struct) => { 137 if let Fields::Named(fields) = &mut data_struct.fields { 138 // Check if extra_data field already exists 139 let has_extra_data = fields 140 .named 141 .iter() 142 .any(|f| f.ident.as_ref().map(|i| i == "extra_data").unwrap_or(false)); 143 144 if !has_extra_data { 145 // Check if the struct derives bon::Builder and doesn't conflict with builder macro 146 let has_bon_builder = has_derive_builder(&input.attrs) 147 && !conflicts_with_builder_macro(&input.ident); 148 149 // Determine the lifetime parameter to use 150 let lifetime = if let Some(lt) = input.generics.lifetimes().next() { 151 quote! { #lt } 152 } else { 153 quote! { 'static } 154 }; 155 156 // Add the extra_data field with serde(borrow) if there's a lifetime 157 let new_field: syn::Field = if input.generics.lifetimes().next().is_some() { 158 if has_bon_builder { 159 syn::parse_quote! { 160 #[serde(flatten)] 161 #[serde(borrow)] 162 #[builder(default)] 163 pub extra_data: ::std::collections::BTreeMap< 164 ::jacquard_common::smol_str::SmolStr, 165 ::jacquard_common::types::value::Data<#lifetime> 166 > 167 } 168 } else { 169 syn::parse_quote! { 170 #[serde(flatten)] 171 #[serde(borrow)] 172 pub extra_data: ::std::collections::BTreeMap< 173 ::jacquard_common::smol_str::SmolStr, 174 ::jacquard_common::types::value::Data<#lifetime> 175 > 176 } 177 } 178 } else { 179 // For types without lifetimes, make it optional to avoid lifetime conflicts 180 if has_bon_builder { 181 syn::parse_quote! { 182 #[serde(flatten)] 183 #[serde(skip_serializing_if = "std::option::Option::is_none")] 184 #[serde(default)] 185 #[builder(default)] 186 pub extra_data: Option<::std::collections::BTreeMap< 187 ::jacquard_common::smol_str::SmolStr, 188 ::jacquard_common::types::value::Data<'static> 189 >> 190 } 191 } else { 192 syn::parse_quote! { 193 #[serde(flatten)] 194 #[serde(skip_serializing_if = "std::option::Option::is_none")] 195 #[serde(default)] 196 pub extra_data:Option<::std::collections::BTreeMap< 197 ::jacquard_common::smol_str::SmolStr, 198 ::jacquard_common::types::value::Data<'static> 199 >> 200 } 201 } 202 }; 203 fields.named.push(new_field); 204 } 205 } else { 206 return syn::Error::new_spanned( 207 input, 208 "lexicon attribute can only be used on structs with named fields", 209 ) 210 .to_compile_error() 211 .into(); 212 } 213 214 quote! { #input }.into() 215 } 216 _ => syn::Error::new_spanned(input, "lexicon attribute can only be used on structs") 217 .to_compile_error() 218 .into(), 219 } 220} 221 222/// Attribute macro that adds an `Other(Data)` variant to enums to make them open unions. 223/// 224/// # Example 225/// ```ignore 226/// #[open_union] 227/// enum RecordEmbed<'s> { 228/// #[serde(rename = "app.bsky.embed.images")] 229/// Images(Images), 230/// } 231/// // Expands to: 232/// // enum RecordEmbed<'s> { 233/// // #[serde(rename = "app.bsky.embed.images")] 234/// // Images(Images), 235/// // #[serde(untagged)] 236/// // Unknown(Data<'s>), 237/// // } 238/// ``` 239#[proc_macro_attribute] 240pub fn open_union(_attr: TokenStream, item: TokenStream) -> TokenStream { 241 let mut input = parse_macro_input!(item as DeriveInput); 242 243 match &mut input.data { 244 Data::Enum(data_enum) => { 245 // Check if Unknown variant already exists 246 let has_other = data_enum.variants.iter().any(|v| v.ident == "Unknown"); 247 248 if !has_other { 249 // Determine the lifetime parameter to use 250 let lifetime = if let Some(lt) = input.generics.lifetimes().next() { 251 quote! { #lt } 252 } else { 253 quote! { 'static } 254 }; 255 256 // Add the Unknown variant 257 let new_variant: syn::Variant = syn::parse_quote! { 258 #[serde(untagged)] 259 Unknown(::jacquard_common::types::value::Data<#lifetime>) 260 }; 261 data_enum.variants.push(new_variant); 262 } 263 264 quote! { #input }.into() 265 } 266 _ => syn::Error::new_spanned(input, "open_union attribute can only be used on enums") 267 .to_compile_error() 268 .into(), 269 } 270} 271 272/// Derive macro for `IntoStatic` trait. 273/// 274/// Automatically implements conversion from borrowed to owned ('static) types. 275/// Works with structs and enums that have lifetime parameters. 276/// 277/// # Example 278/// ```ignore 279/// #[derive(IntoStatic)] 280/// struct Post<'a> { 281/// text: CowStr<'a>, 282/// } 283/// // Generates: 284/// // impl IntoStatic for Post<'_> { 285/// // type Output = Post<'static>; 286/// // fn into_static(self) -> Self::Output { 287/// // Post { text: self.text.into_static() } 288/// // } 289/// // } 290/// ``` 291#[proc_macro_derive(IntoStatic)] 292pub fn derive_into_static(input: TokenStream) -> TokenStream { 293 let input = parse_macro_input!(input as DeriveInput); 294 295 let name = &input.ident; 296 let generics = &input.generics; 297 298 // Build impl generics and where clause 299 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); 300 301 // Build the Output type with all lifetimes replaced by 'static 302 let output_generics = generics.params.iter().map(|param| match param { 303 GenericParam::Lifetime(_) => quote! { 'static }, 304 GenericParam::Type(ty) => { 305 let ident = &ty.ident; 306 quote! { #ident } 307 } 308 GenericParam::Const(c) => { 309 let ident = &c.ident; 310 quote! { #ident } 311 } 312 }); 313 314 let output_type = if generics.params.is_empty() { 315 quote! { #name } 316 } else { 317 quote! { #name<#(#output_generics),*> } 318 }; 319 320 // Generate the conversion body based on struct/enum 321 let conversion = match &input.data { 322 Data::Struct(data_struct) => generate_struct_conversion(name, &data_struct.fields), 323 Data::Enum(data_enum) => generate_enum_conversion(name, data_enum), 324 Data::Union(_) => { 325 return syn::Error::new_spanned(input, "IntoStatic cannot be derived for unions") 326 .to_compile_error() 327 .into(); 328 } 329 }; 330 331 let expanded = quote! { 332 impl #impl_generics ::jacquard_common::IntoStatic for #name #ty_generics #where_clause { 333 type Output = #output_type; 334 335 fn into_static(self) -> Self::Output { 336 #conversion 337 } 338 } 339 }; 340 341 expanded.into() 342} 343 344fn generate_struct_conversion(name: &syn::Ident, fields: &Fields) -> proc_macro2::TokenStream { 345 match fields { 346 Fields::Named(fields) => { 347 let field_conversions = fields.named.iter().map(|f| { 348 let field_name = &f.ident; 349 quote! { #field_name: self.#field_name.into_static() } 350 }); 351 quote! { 352 #name { 353 #(#field_conversions),* 354 } 355 } 356 } 357 Fields::Unnamed(fields) => { 358 let field_conversions = fields.unnamed.iter().enumerate().map(|(i, _)| { 359 let index = syn::Index::from(i); 360 quote! { self.#index.into_static() } 361 }); 362 quote! { 363 #name(#(#field_conversions),*) 364 } 365 } 366 Fields::Unit => { 367 quote! { #name } 368 } 369 } 370} 371 372fn generate_enum_conversion( 373 name: &syn::Ident, 374 data_enum: &syn::DataEnum, 375) -> proc_macro2::TokenStream { 376 let variants = data_enum.variants.iter().map(|variant| { 377 let variant_name = &variant.ident; 378 match &variant.fields { 379 Fields::Named(fields) => { 380 let field_names: Vec<_> = fields.named.iter().map(|f| &f.ident).collect(); 381 let field_conversions = field_names.iter().map(|field_name| { 382 quote! { #field_name: #field_name.into_static() } 383 }); 384 quote! { 385 #name::#variant_name { #(#field_names),* } => { 386 #name::#variant_name { 387 #(#field_conversions),* 388 } 389 } 390 } 391 } 392 Fields::Unnamed(fields) => { 393 let field_bindings: Vec<_> = (0..fields.unnamed.len()) 394 .map(|i| { 395 syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site()) 396 }) 397 .collect(); 398 let field_conversions = field_bindings.iter().map(|binding| { 399 quote! { #binding.into_static() } 400 }); 401 quote! { 402 #name::#variant_name(#(#field_bindings),*) => { 403 #name::#variant_name(#(#field_conversions),*) 404 } 405 } 406 } 407 Fields::Unit => { 408 quote! { 409 #name::#variant_name => #name::#variant_name 410 } 411 } 412 } 413 }); 414 415 quote! { 416 match self { 417 #(#variants),* 418 } 419 } 420} 421 422/// Derive macro for `XrpcRequest` trait. 423/// 424/// Automatically generates the response marker struct, `XrpcResp` impl, and `XrpcRequest` impl 425/// for an XRPC endpoint. Optionally generates `XrpcEndpoint` impl for server-side usage. 426/// 427/// # Attributes 428/// 429/// - `nsid`: Required. The NSID string (e.g., "com.example.myMethod") 430/// - `method`: Required. Either `Query` or `Procedure` 431/// - `output`: Required. The output type (must support lifetime param if request does) 432/// - `error`: Optional. Error type (defaults to `GenericError`) 433/// - `server`: Optional flag. If present, generates `XrpcEndpoint` impl too 434/// 435/// # Example 436/// ```ignore 437/// #[derive(Serialize, Deserialize, XrpcRequest)] 438/// #[xrpc( 439/// nsid = "com.example.getThing", 440/// method = Query, 441/// output = GetThingOutput, 442/// )] 443/// struct GetThing<'a> { 444/// #[serde(borrow)] 445/// pub id: CowStr<'a>, 446/// } 447/// ``` 448/// 449/// This generates: 450/// - `GetThingResponse` struct implementing `XrpcResp` 451/// - `XrpcRequest` impl for `GetThing` 452/// - Optionally: `GetThingEndpoint` struct implementing `XrpcEndpoint` (if `server` flag present) 453#[proc_macro_derive(XrpcRequest, attributes(xrpc))] 454pub fn derive_xrpc_request(input: TokenStream) -> TokenStream { 455 let input = parse_macro_input!(input as DeriveInput); 456 457 match xrpc_request_impl(&input) { 458 Ok(tokens) => tokens.into(), 459 Err(e) => e.to_compile_error().into(), 460 } 461} 462 463fn xrpc_request_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> { 464 // Parse attributes 465 let attrs = parse_xrpc_attrs(&input.attrs)?; 466 467 let name = &input.ident; 468 let generics = &input.generics; 469 470 // Detect if type has lifetime parameter 471 let has_lifetime = generics.lifetimes().next().is_some(); 472 let lifetime = if has_lifetime { 473 quote! { <'_> } 474 } else { 475 quote! {} 476 }; 477 478 let nsid = &attrs.nsid; 479 let method = method_expr(&attrs.method); 480 let output_ty = &attrs.output; 481 let error_ty = attrs 482 .error 483 .as_ref() 484 .map(|e| quote! { #e }) 485 .unwrap_or_else(|| quote! { ::jacquard_common::xrpc::GenericError }); 486 487 // Generate response marker struct name 488 let response_name = format_ident!("{}Response", name); 489 490 // Build the impls 491 let mut output = quote! { 492 /// Response marker for #name 493 pub struct #response_name; 494 495 impl ::jacquard_common::xrpc::XrpcResp for #response_name { 496 const NSID: &'static str = #nsid; 497 const ENCODING: &'static str = "application/json"; 498 type Output<'de> = #output_ty<'de>; 499 type Err<'de> = #error_ty<'de>; 500 } 501 502 impl #generics ::jacquard_common::xrpc::XrpcRequest for #name #lifetime { 503 const NSID: &'static str = #nsid; 504 const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 505 type Response = #response_name; 506 } 507 }; 508 509 // Optional server-side endpoint impl 510 if attrs.server { 511 let endpoint_name = format_ident!("{}Endpoint", name); 512 let path = format!("/xrpc/{}", nsid); 513 514 // Request type with or without lifetime 515 let request_type = if has_lifetime { 516 quote! { #name<'de> } 517 } else { 518 quote! { #name } 519 }; 520 521 output.extend(quote! { 522 /// Endpoint marker for #name (server-side) 523 pub struct #endpoint_name; 524 525 impl ::jacquard_common::xrpc::XrpcEndpoint for #endpoint_name { 526 const PATH: &'static str = #path; 527 const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 528 type Request<'de> = #request_type; 529 type Response = #response_name; 530 } 531 }); 532 } 533 534 Ok(output) 535} 536 537struct XrpcAttrs { 538 nsid: String, 539 method: XrpcMethod, 540 output: syn::Type, 541 error: Option<syn::Type>, 542 server: bool, 543} 544 545enum XrpcMethod { 546 Query, 547 Procedure, 548} 549 550fn parse_xrpc_attrs(attrs: &[Attribute]) -> syn::Result<XrpcAttrs> { 551 let mut nsid = None; 552 let mut method = None; 553 let mut output = None; 554 let mut error = None; 555 let mut server = false; 556 557 for attr in attrs { 558 if !attr.path().is_ident("xrpc") { 559 continue; 560 } 561 562 attr.parse_nested_meta(|meta| { 563 if meta.path.is_ident("nsid") { 564 let value = meta.value()?; 565 let s: LitStr = value.parse()?; 566 nsid = Some(s.value()); 567 Ok(()) 568 } else if meta.path.is_ident("method") { 569 // Parse "method = Query" or "method = Procedure" 570 let _eq = meta.input.parse::<syn::Token![=]>()?; 571 let ident: Ident = meta.input.parse()?; 572 match ident.to_string().as_str() { 573 "Query" => { 574 method = Some(XrpcMethod::Query); 575 Ok(()) 576 } 577 "Procedure" => { 578 // Always JSON, no custom encoding support 579 method = Some(XrpcMethod::Procedure); 580 Ok(()) 581 } 582 other => { 583 Err(meta 584 .error(format!("unknown method: {}, use Query or Procedure", other))) 585 } 586 } 587 } else if meta.path.is_ident("output") { 588 let value = meta.value()?; 589 output = Some(value.parse()?); 590 Ok(()) 591 } else if meta.path.is_ident("error") { 592 let value = meta.value()?; 593 error = Some(value.parse()?); 594 Ok(()) 595 } else if meta.path.is_ident("server") { 596 server = true; 597 Ok(()) 598 } else { 599 Err(meta.error("unknown xrpc attribute")) 600 } 601 })?; 602 } 603 604 let nsid = nsid.ok_or_else(|| { 605 syn::Error::new( 606 proc_macro2::Span::call_site(), 607 "missing required `nsid` attribute", 608 ) 609 })?; 610 let method = method.ok_or_else(|| { 611 syn::Error::new( 612 proc_macro2::Span::call_site(), 613 "missing required `method` attribute", 614 ) 615 })?; 616 let output = output.ok_or_else(|| { 617 syn::Error::new( 618 proc_macro2::Span::call_site(), 619 "missing required `output` attribute", 620 ) 621 })?; 622 623 Ok(XrpcAttrs { 624 nsid, 625 method, 626 output, 627 error, 628 server, 629 }) 630} 631 632fn method_expr(method: &XrpcMethod) -> proc_macro2::TokenStream { 633 match method { 634 XrpcMethod::Query => quote! { ::jacquard_common::xrpc::XrpcMethod::Query }, 635 XrpcMethod::Procedure => { 636 quote! { ::jacquard_common::xrpc::XrpcMethod::Procedure("application/json") } 637 } 638 } 639}