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}