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::{quote, format_ident}; 82use syn::{ 83 Data, DeriveInput, Fields, GenericParam, parse_macro_input, 84 Attribute, Ident, LitStr, 85}; 86 87/// Attribute macro that adds an `extra_data` field to structs to capture unknown fields 88/// during deserialization. 89/// 90/// # Example 91/// ```ignore 92/// #[lexicon] 93/// struct Post<'s> { 94/// text: &'s str, 95/// } 96/// // Expands to: 97/// // struct Post<'s> { 98/// // text: &'s str, 99/// // #[serde(flatten)] 100/// // pub extra_data: BTreeMap<SmolStr, Data<'s>>, 101/// // } 102/// ``` 103#[proc_macro_attribute] 104pub fn lexicon(_attr: TokenStream, item: TokenStream) -> TokenStream { 105 let mut input = parse_macro_input!(item as DeriveInput); 106 107 match &mut input.data { 108 Data::Struct(data_struct) => { 109 if let Fields::Named(fields) = &mut data_struct.fields { 110 // Check if extra_data field already exists 111 let has_extra_data = fields 112 .named 113 .iter() 114 .any(|f| f.ident.as_ref().map(|i| i == "extra_data").unwrap_or(false)); 115 116 if !has_extra_data { 117 // Determine the lifetime parameter to use 118 let lifetime = if let Some(lt) = input.generics.lifetimes().next() { 119 quote! { #lt } 120 } else { 121 quote! { 'static } 122 }; 123 124 // Add the extra_data field with serde(borrow) if there's a lifetime 125 let new_field: syn::Field = if input.generics.lifetimes().next().is_some() { 126 syn::parse_quote! { 127 #[serde(flatten)] 128 #[serde(borrow)] 129 pub extra_data: ::std::collections::BTreeMap< 130 ::jacquard_common::smol_str::SmolStr, 131 ::jacquard_common::types::value::Data<#lifetime> 132 > 133 } 134 } else { 135 // For types without lifetimes, make it optional to avoid lifetime conflicts 136 syn::parse_quote! { 137 #[serde(flatten)] 138 #[serde(skip_serializing_if = "std::option::Option::is_none")] 139 #[serde(default)] 140 pub extra_data: std::option::Option<::std::collections::BTreeMap< 141 ::jacquard_common::smol_str::SmolStr, 142 ::jacquard_common::types::value::Data<'static> 143 >> 144 } 145 }; 146 fields.named.push(new_field); 147 } 148 } else { 149 return syn::Error::new_spanned( 150 input, 151 "lexicon attribute can only be used on structs with named fields", 152 ) 153 .to_compile_error() 154 .into(); 155 } 156 157 quote! { #input }.into() 158 } 159 _ => syn::Error::new_spanned(input, "lexicon attribute can only be used on structs") 160 .to_compile_error() 161 .into(), 162 } 163} 164 165/// Attribute macro that adds an `Other(Data)` variant to enums to make them open unions. 166/// 167/// # Example 168/// ```ignore 169/// #[open_union] 170/// enum RecordEmbed<'s> { 171/// #[serde(rename = "app.bsky.embed.images")] 172/// Images(Images), 173/// } 174/// // Expands to: 175/// // enum RecordEmbed<'s> { 176/// // #[serde(rename = "app.bsky.embed.images")] 177/// // Images(Images), 178/// // #[serde(untagged)] 179/// // Unknown(Data<'s>), 180/// // } 181/// ``` 182#[proc_macro_attribute] 183pub fn open_union(_attr: TokenStream, item: TokenStream) -> TokenStream { 184 let mut input = parse_macro_input!(item as DeriveInput); 185 186 match &mut input.data { 187 Data::Enum(data_enum) => { 188 // Check if Unknown variant already exists 189 let has_other = data_enum.variants.iter().any(|v| v.ident == "Unknown"); 190 191 if !has_other { 192 // Determine the lifetime parameter to use 193 let lifetime = if let Some(lt) = input.generics.lifetimes().next() { 194 quote! { #lt } 195 } else { 196 quote! { 'static } 197 }; 198 199 // Add the Unknown variant 200 let new_variant: syn::Variant = syn::parse_quote! { 201 #[serde(untagged)] 202 Unknown(::jacquard_common::types::value::Data<#lifetime>) 203 }; 204 data_enum.variants.push(new_variant); 205 } 206 207 quote! { #input }.into() 208 } 209 _ => syn::Error::new_spanned(input, "open_union attribute can only be used on enums") 210 .to_compile_error() 211 .into(), 212 } 213} 214 215/// Derive macro for `IntoStatic` trait. 216/// 217/// Automatically implements conversion from borrowed to owned ('static) types. 218/// Works with structs and enums that have lifetime parameters. 219/// 220/// # Example 221/// ```ignore 222/// #[derive(IntoStatic)] 223/// struct Post<'a> { 224/// text: CowStr<'a>, 225/// } 226/// // Generates: 227/// // impl IntoStatic for Post<'_> { 228/// // type Output = Post<'static>; 229/// // fn into_static(self) -> Self::Output { 230/// // Post { text: self.text.into_static() } 231/// // } 232/// // } 233/// ``` 234#[proc_macro_derive(IntoStatic)] 235pub fn derive_into_static(input: TokenStream) -> TokenStream { 236 let input = parse_macro_input!(input as DeriveInput); 237 238 let name = &input.ident; 239 let generics = &input.generics; 240 241 // Build impl generics and where clause 242 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); 243 244 // Build the Output type with all lifetimes replaced by 'static 245 let output_generics = generics.params.iter().map(|param| match param { 246 GenericParam::Lifetime(_) => quote! { 'static }, 247 GenericParam::Type(ty) => { 248 let ident = &ty.ident; 249 quote! { #ident } 250 } 251 GenericParam::Const(c) => { 252 let ident = &c.ident; 253 quote! { #ident } 254 } 255 }); 256 257 let output_type = if generics.params.is_empty() { 258 quote! { #name } 259 } else { 260 quote! { #name<#(#output_generics),*> } 261 }; 262 263 // Generate the conversion body based on struct/enum 264 let conversion = match &input.data { 265 Data::Struct(data_struct) => generate_struct_conversion(name, &data_struct.fields), 266 Data::Enum(data_enum) => generate_enum_conversion(name, data_enum), 267 Data::Union(_) => { 268 return syn::Error::new_spanned(input, "IntoStatic cannot be derived for unions") 269 .to_compile_error() 270 .into(); 271 } 272 }; 273 274 let expanded = quote! { 275 impl #impl_generics ::jacquard_common::IntoStatic for #name #ty_generics #where_clause { 276 type Output = #output_type; 277 278 fn into_static(self) -> Self::Output { 279 #conversion 280 } 281 } 282 }; 283 284 expanded.into() 285} 286 287fn generate_struct_conversion(name: &syn::Ident, fields: &Fields) -> proc_macro2::TokenStream { 288 match fields { 289 Fields::Named(fields) => { 290 let field_conversions = fields.named.iter().map(|f| { 291 let field_name = &f.ident; 292 quote! { #field_name: self.#field_name.into_static() } 293 }); 294 quote! { 295 #name { 296 #(#field_conversions),* 297 } 298 } 299 } 300 Fields::Unnamed(fields) => { 301 let field_conversions = fields.unnamed.iter().enumerate().map(|(i, _)| { 302 let index = syn::Index::from(i); 303 quote! { self.#index.into_static() } 304 }); 305 quote! { 306 #name(#(#field_conversions),*) 307 } 308 } 309 Fields::Unit => { 310 quote! { #name } 311 } 312 } 313} 314 315fn generate_enum_conversion( 316 name: &syn::Ident, 317 data_enum: &syn::DataEnum, 318) -> proc_macro2::TokenStream { 319 let variants = data_enum.variants.iter().map(|variant| { 320 let variant_name = &variant.ident; 321 match &variant.fields { 322 Fields::Named(fields) => { 323 let field_names: Vec<_> = fields.named.iter().map(|f| &f.ident).collect(); 324 let field_conversions = field_names.iter().map(|field_name| { 325 quote! { #field_name: #field_name.into_static() } 326 }); 327 quote! { 328 #name::#variant_name { #(#field_names),* } => { 329 #name::#variant_name { 330 #(#field_conversions),* 331 } 332 } 333 } 334 } 335 Fields::Unnamed(fields) => { 336 let field_bindings: Vec<_> = (0..fields.unnamed.len()) 337 .map(|i| { 338 syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site()) 339 }) 340 .collect(); 341 let field_conversions = field_bindings.iter().map(|binding| { 342 quote! { #binding.into_static() } 343 }); 344 quote! { 345 #name::#variant_name(#(#field_bindings),*) => { 346 #name::#variant_name(#(#field_conversions),*) 347 } 348 } 349 } 350 Fields::Unit => { 351 quote! { 352 #name::#variant_name => #name::#variant_name 353 } 354 } 355 } 356 }); 357 358 quote! { 359 match self { 360 #(#variants),* 361 } 362 } 363} 364 365/// Derive macro for `XrpcRequest` trait. 366/// 367/// Automatically generates the response marker struct, `XrpcResp` impl, and `XrpcRequest` impl 368/// for an XRPC endpoint. Optionally generates `XrpcEndpoint` impl for server-side usage. 369/// 370/// # Attributes 371/// 372/// - `nsid`: Required. The NSID string (e.g., "com.example.myMethod") 373/// - `method`: Required. Either `Query` or `Procedure` 374/// - `output`: Required. The output type (must support lifetime param if request does) 375/// - `error`: Optional. Error type (defaults to `GenericError`) 376/// - `server`: Optional flag. If present, generates `XrpcEndpoint` impl too 377/// 378/// # Example 379/// ```ignore 380/// #[derive(Serialize, Deserialize, XrpcRequest)] 381/// #[xrpc( 382/// nsid = "com.example.getThing", 383/// method = Query, 384/// output = GetThingOutput, 385/// )] 386/// struct GetThing<'a> { 387/// #[serde(borrow)] 388/// pub id: CowStr<'a>, 389/// } 390/// ``` 391/// 392/// This generates: 393/// - `GetThingResponse` struct implementing `XrpcResp` 394/// - `XrpcRequest` impl for `GetThing` 395/// - Optionally: `GetThingEndpoint` struct implementing `XrpcEndpoint` (if `server` flag present) 396#[proc_macro_derive(XrpcRequest, attributes(xrpc))] 397pub fn derive_xrpc_request(input: TokenStream) -> TokenStream { 398 let input = parse_macro_input!(input as DeriveInput); 399 400 match xrpc_request_impl(&input) { 401 Ok(tokens) => tokens.into(), 402 Err(e) => e.to_compile_error().into(), 403 } 404} 405 406fn xrpc_request_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> { 407 // Parse attributes 408 let attrs = parse_xrpc_attrs(&input.attrs)?; 409 410 let name = &input.ident; 411 let generics = &input.generics; 412 413 // Detect if type has lifetime parameter 414 let has_lifetime = generics.lifetimes().next().is_some(); 415 let lifetime = if has_lifetime { 416 quote! { <'_> } 417 } else { 418 quote! {} 419 }; 420 421 let nsid = &attrs.nsid; 422 let method = method_expr(&attrs.method); 423 let output_ty = &attrs.output; 424 let error_ty = attrs.error.as_ref() 425 .map(|e| quote! { #e }) 426 .unwrap_or_else(|| quote! { ::jacquard_common::xrpc::GenericError }); 427 428 // Generate response marker struct name 429 let response_name = format_ident!("{}Response", name); 430 431 // Build the impls 432 let mut output = quote! { 433 /// Response marker for #name 434 pub struct #response_name; 435 436 impl ::jacquard_common::xrpc::XrpcResp for #response_name { 437 const NSID: &'static str = #nsid; 438 const ENCODING: &'static str = "application/json"; 439 type Output<'de> = #output_ty<'de>; 440 type Err<'de> = #error_ty<'de>; 441 } 442 443 impl #generics ::jacquard_common::xrpc::XrpcRequest for #name #lifetime { 444 const NSID: &'static str = #nsid; 445 const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 446 type Response = #response_name; 447 } 448 }; 449 450 // Optional server-side endpoint impl 451 if attrs.server { 452 let endpoint_name = format_ident!("{}Endpoint", name); 453 let path = format!("/xrpc/{}", nsid); 454 455 // Request type with or without lifetime 456 let request_type = if has_lifetime { 457 quote! { #name<'de> } 458 } else { 459 quote! { #name } 460 }; 461 462 output.extend(quote! { 463 /// Endpoint marker for #name (server-side) 464 pub struct #endpoint_name; 465 466 impl ::jacquard_common::xrpc::XrpcEndpoint for #endpoint_name { 467 const PATH: &'static str = #path; 468 const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 469 type Request<'de> = #request_type; 470 type Response = #response_name; 471 } 472 }); 473 } 474 475 Ok(output) 476} 477 478struct XrpcAttrs { 479 nsid: String, 480 method: XrpcMethod, 481 output: syn::Type, 482 error: Option<syn::Type>, 483 server: bool, 484} 485 486enum XrpcMethod { 487 Query, 488 Procedure, 489} 490 491fn parse_xrpc_attrs(attrs: &[Attribute]) -> syn::Result<XrpcAttrs> { 492 let mut nsid = None; 493 let mut method = None; 494 let mut output = None; 495 let mut error = None; 496 let mut server = false; 497 498 for attr in attrs { 499 if !attr.path().is_ident("xrpc") { 500 continue; 501 } 502 503 attr.parse_nested_meta(|meta| { 504 if meta.path.is_ident("nsid") { 505 let value = meta.value()?; 506 let s: LitStr = value.parse()?; 507 nsid = Some(s.value()); 508 Ok(()) 509 } else if meta.path.is_ident("method") { 510 // Parse "method = Query" or "method = Procedure" 511 let _eq = meta.input.parse::<syn::Token![=]>()?; 512 let ident: Ident = meta.input.parse()?; 513 match ident.to_string().as_str() { 514 "Query" => { 515 method = Some(XrpcMethod::Query); 516 Ok(()) 517 } 518 "Procedure" => { 519 // Always JSON, no custom encoding support 520 method = Some(XrpcMethod::Procedure); 521 Ok(()) 522 } 523 other => Err(meta.error(format!("unknown method: {}, use Query or Procedure", other))) 524 } 525 } else if meta.path.is_ident("output") { 526 let value = meta.value()?; 527 output = Some(value.parse()?); 528 Ok(()) 529 } else if meta.path.is_ident("error") { 530 let value = meta.value()?; 531 error = Some(value.parse()?); 532 Ok(()) 533 } else if meta.path.is_ident("server") { 534 server = true; 535 Ok(()) 536 } else { 537 Err(meta.error("unknown xrpc attribute")) 538 } 539 })?; 540 } 541 542 let nsid = nsid.ok_or_else(|| syn::Error::new( 543 proc_macro2::Span::call_site(), 544 "missing required `nsid` attribute" 545 ))?; 546 let method = method.ok_or_else(|| syn::Error::new( 547 proc_macro2::Span::call_site(), 548 "missing required `method` attribute" 549 ))?; 550 let output = output.ok_or_else(|| syn::Error::new( 551 proc_macro2::Span::call_site(), 552 "missing required `output` attribute" 553 ))?; 554 555 Ok(XrpcAttrs { 556 nsid, 557 method, 558 output, 559 error, 560 server, 561 }) 562} 563 564fn method_expr(method: &XrpcMethod) -> proc_macro2::TokenStream { 565 match method { 566 XrpcMethod::Query => quote! { ::jacquard_common::xrpc::XrpcMethod::Query }, 567 XrpcMethod::Procedure => quote! { ::jacquard_common::xrpc::XrpcMethod::Procedure("application/json") }, 568 } 569}