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}