1use proc_macro::TokenStream;
39use quote::quote;
40use syn::{
41 parenthesized, parse::Parse, parse::ParseStream, parse_macro_input, parse_quote,
42 punctuated::Punctuated, Data, DeriveInput, Field, Fields, Generics, Ident, Path, Token, Type,
43};
44
45#[proc_macro_derive(RequestFields, attributes(openpit, request_fields))]
46pub fn derive_request_fields(input: TokenStream) -> TokenStream {
47 let input = parse_macro_input!(input as DeriveInput);
48
49 match derive_request_fields_impl(input) {
50 Ok(tokens) => tokens.into(),
51 Err(err) => err.to_compile_error().into(),
52 }
53}
54
55fn derive_request_fields_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
56 let name = input.ident;
57 let generics = input.generics;
58
59 let data = match input.data {
60 Data::Struct(data) => data,
61 _ => {
62 return Err(syn::Error::new_spanned(
63 name,
64 "RequestFields can only be derived for structs",
65 ));
66 }
67 };
68
69 let fields = match data.fields {
70 Fields::Named(fields) => fields.named,
71 _ => {
72 return Err(syn::Error::new_spanned(
73 name,
74 "RequestFields requires named fields",
75 ));
76 }
77 };
78
79 let mut generated = Vec::new();
80 let mut seen_traits = std::collections::BTreeSet::new();
81 let mut explicit_inner: Option<&Field> = None;
82
83 for field in &fields {
84 let Some(field_ident) = &field.ident else {
85 continue;
86 };
87
88 reject_legacy_request_fields(field)?;
89
90 let parsed = parse_openpit_items(field)?;
91 if !parsed.inner {
92 for capability in parsed.capabilities {
93 register_trait_once(&mut seen_traits, &capability, field)?;
94 generated.push(impl_direct_trait(
95 &name,
96 &generics,
97 field_ident,
98 &capability,
99 ));
100 }
101 continue;
102 }
103
104 if explicit_inner.is_some() {
105 return Err(syn::Error::new_spanned(
106 field,
107 "only one #[openpit(inner)] field is allowed",
108 ));
109 }
110 explicit_inner = Some(field);
111
112 for capability in parsed.capabilities {
113 register_trait_once(&mut seen_traits, &capability, field)?;
114 generated.push(impl_passthrough_trait(
115 &name,
116 &generics,
117 field_ident,
118 &field.ty,
119 &capability,
120 ));
121 }
122 }
123
124 Ok(quote! {
125 #(#generated)*
126 })
127}
128
129fn register_trait_once(
130 seen_traits: &mut std::collections::BTreeSet<String>,
131 capability: &CapabilitySpec,
132 span: &impl quote::ToTokens,
133) -> syn::Result<()> {
134 let key = quote!(#capability).to_string();
135 if !seen_traits.insert(key.clone()) {
136 return Err(syn::Error::new_spanned(
137 span,
138 format!("duplicate trait mapping for {key}"),
139 ));
140 }
141 Ok(())
142}
143
144fn reject_legacy_request_fields(field: &Field) -> syn::Result<()> {
145 for attr in &field.attrs {
146 if attr.path().is_ident("request_fields") {
147 return Err(syn::Error::new_spanned(
148 attr,
149 "legacy #[request_fields(...)] is not supported; use #[openpit(...)]",
150 ));
151 }
152 }
153 Ok(())
154}
155
156fn parse_openpit_items(field: &Field) -> syn::Result<FieldOpenpitItems> {
157 let mut result = FieldOpenpitItems {
158 inner: false,
159 capabilities: Vec::new(),
160 };
161
162 for attr in &field.attrs {
163 if !attr.path().is_ident("openpit") {
164 continue;
165 }
166
167 let items =
168 attr.parse_args_with(Punctuated::<OpenpitAttrItem, Token![,]>::parse_terminated)?;
169 if items.is_empty() {
170 return Err(syn::Error::new_spanned(
171 attr,
172 "empty #[openpit(...)] is not allowed",
173 ));
174 }
175
176 for item in items {
177 match item {
178 OpenpitAttrItem::Inner(span) => {
179 if result.inner {
180 return Err(syn::Error::new_spanned(
181 span,
182 "duplicate `inner` marker in #[openpit(...)]",
183 ));
184 }
185 result.inner = true;
186 }
187 OpenpitAttrItem::Capability(spec) => result.capabilities.push(*spec),
188 }
189 }
190 }
191
192 Ok(result)
193}
194
195struct FieldOpenpitItems {
196 inner: bool,
197 capabilities: Vec<CapabilitySpec>,
198}
199
200enum OpenpitAttrItem {
201 Inner(Ident),
202 Capability(Box<CapabilitySpec>),
203}
204
205impl Parse for OpenpitAttrItem {
206 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
207 let path = input.parse::<Path>()?;
208 if let Some(ident) = path.get_ident().filter(|ident| *ident == "inner") {
209 if !input.is_empty() && !input.peek(Token![,]) {
210 return Err(input.error("`inner` must not have arguments"));
211 }
212 return Ok(OpenpitAttrItem::Inner(ident.clone()));
213 }
214
215 if !input.peek(syn::token::Paren) {
216 return Err(syn::Error::new_spanned(
217 path,
218 "invalid #[openpit(...)] item; expected `Trait(method -> ReturnType)` or `Trait(-> ReturnType)`",
219 ));
220 }
221
222 let content;
223 parenthesized!(content in input);
224
225 let method_ident = if content.peek(Token![->]) {
226 content.parse::<Token![->]>()?;
227 infer_method_from_trait_path(&path)?
228 } else {
229 let method = content.parse::<Ident>()?;
230 content.parse::<Token![->]>()?;
231 method
232 };
233 let return_ty = content.parse::<Type>()?;
234
235 if !content.is_empty() {
236 return Err(content.error("unexpected tokens in trait signature"));
237 }
238
239 Ok(OpenpitAttrItem::Capability(Box::new(CapabilitySpec {
240 trait_path: path,
241 method_ident,
242 return_ty,
243 })))
244 }
245}
246
247#[derive(Clone)]
248struct CapabilitySpec {
249 trait_path: Path,
250 method_ident: Ident,
251 return_ty: Type,
252}
253
254impl quote::ToTokens for CapabilitySpec {
255 fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
256 let trait_path = &self.trait_path;
257 trait_path.to_tokens(tokens);
258 }
259}
260
261fn infer_method_from_trait_path(path: &Path) -> syn::Result<Ident> {
262 let Some(segment) = path.segments.last() else {
263 return Err(syn::Error::new_spanned(
264 path,
265 "trait path must have at least one segment",
266 ));
267 };
268
269 let trait_name = segment.ident.to_string();
270 let Some(stripped) = trait_name.strip_prefix("Has") else {
271 return Err(syn::Error::new_spanned(
272 &segment.ident,
273 "method inference requires a `Has*` trait name",
274 ));
275 };
276 if stripped.is_empty() {
277 return Err(syn::Error::new_spanned(
278 &segment.ident,
279 "trait name `Has` does not contain a method stem",
280 ));
281 }
282
283 let mut snake = String::new();
284 for (idx, ch) in stripped.chars().enumerate() {
285 if ch.is_uppercase() {
286 if idx > 0 {
287 snake.push('_');
288 }
289 for lower in ch.to_lowercase() {
290 snake.push(lower);
291 }
292 } else {
293 snake.push(ch);
294 }
295 }
296
297 Ok(Ident::new(&snake, segment.ident.span()))
298}
299
300fn impl_direct_trait(
301 name: &Ident,
302 generics: &Generics,
303 field_ident: &Ident,
304 capability: &CapabilitySpec,
305) -> proc_macro2::TokenStream {
306 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
307 let trait_path = &capability.trait_path;
308 let method_ident = &capability.method_ident;
309 let return_ty = &capability.return_ty;
310
311 quote! {
312 impl #impl_generics #trait_path for #name #ty_generics #where_clause {
313 fn #method_ident(&self) -> #return_ty {
314 self.#field_ident.#method_ident()
315 }
316 }
317 }
318}
319
320fn impl_passthrough_trait(
321 name: &Ident,
322 generics: &Generics,
323 inner_field_ident: &Ident,
324 inner_ty: &Type,
325 capability: &CapabilitySpec,
326) -> proc_macro2::TokenStream {
327 let trait_path = &capability.trait_path;
328 let method_ident = &capability.method_ident;
329 let return_ty = &capability.return_ty;
330
331 let mut impl_generics = generics.clone();
332 impl_generics
333 .make_where_clause()
334 .predicates
335 .push(parse_quote!(#inner_ty: #trait_path));
336 let (impl_generics, ty_generics, where_clause) = impl_generics.split_for_impl();
337
338 quote! {
339 impl #impl_generics #trait_path for #name #ty_generics #where_clause {
340 fn #method_ident(&self) -> #return_ty {
341 <#inner_ty as #trait_path>::#method_ident(&self.#inner_field_ident)
342 }
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use quote::quote;
350 use syn::punctuated::Punctuated;
351 use syn::{parse_quote, parse_str, Data, DeriveInput, Field, Fields, Path};
352
353 use super::{
354 derive_request_fields_impl, infer_method_from_trait_path, parse_openpit_items,
355 register_trait_once, CapabilitySpec, OpenpitAttrItem,
356 };
357
358 fn clear_first_named_field_ident(input: &mut DeriveInput) -> bool {
359 match &mut input.data {
360 Data::Struct(data) => match &mut data.fields {
361 Fields::Named(fields) => {
362 fields.named[0].ident = None;
363 true
364 }
365 _ => false,
366 },
367 _ => false,
368 }
369 }
370
371 #[test]
372 fn infer_method_from_has_trait_converts_to_snake_case() {
373 let path: Path = parse_quote!(crate::HasOrderPrice);
374 let method = infer_method_from_trait_path(&path).expect("inference must succeed");
375 assert_eq!(method.to_string(), "order_price");
376 }
377
378 #[test]
379 fn infer_method_from_trait_rejects_non_has_prefix() {
380 let path: Path = parse_quote!(crate::TraitWithoutPrefix);
381 let err = infer_method_from_trait_path(&path).expect_err("must reject trait without Has");
382 assert_eq!(
383 err.to_string(),
384 "method inference requires a `Has*` trait name"
385 );
386 }
387
388 #[test]
389 fn infer_method_from_has_rejects_empty_stem() {
390 let path: Path = parse_quote!(Has);
391 let err = infer_method_from_trait_path(&path).expect_err("empty method stem must reject");
392 assert_eq!(
393 err.to_string(),
394 "trait name `Has` does not contain a method stem"
395 );
396 }
397
398 #[test]
399 fn infer_method_from_empty_path_rejects() {
400 let path = Path {
401 leading_colon: None,
402 segments: Punctuated::new(),
403 };
404 let err = infer_method_from_trait_path(&path).expect_err("empty path must reject");
405 assert_eq!(err.to_string(), "trait path must have at least one segment");
406 }
407
408 #[test]
409 fn parse_openpit_items_rejects_empty_attribute() {
410 let field: Field = parse_quote!(
411 #[openpit()]
412 operation: Operation
413 );
414 let err = parse_openpit_items(&field)
415 .err()
416 .expect("empty attribute must reject");
417 assert_eq!(err.to_string(), "empty #[openpit(...)] is not allowed");
418 }
419
420 #[test]
421 fn parse_openpit_items_rejects_duplicate_inner_marker() {
422 let field: Field = parse_quote!(
423 #[openpit(inner, inner)]
424 operation: Operation
425 );
426 let err = parse_openpit_items(&field)
427 .err()
428 .expect("duplicate inner must reject");
429 assert_eq!(
430 err.to_string(),
431 "duplicate `inner` marker in #[openpit(...)]"
432 );
433 }
434
435 #[test]
436 fn parse_openpit_items_parses_inner_and_capabilities() {
437 let field: Field = parse_quote!(
438 #[openpit(inner, crate::HasPnl(-> Result<Pnl, RequestFieldAccessError>))]
439 operation: Operation
440 );
441 let parsed = parse_openpit_items(&field).expect("must parse valid attribute");
442 assert!(parsed.inner);
443 assert_eq!(parsed.capabilities.len(), 1);
444 let capability = &parsed.capabilities[0];
445 let trait_path = &capability.trait_path;
446 assert_eq!(quote!(#trait_path).to_string(), "crate :: HasPnl");
447 assert_eq!(capability.method_ident.to_string(), "pnl");
448 }
449
450 #[test]
451 fn parse_openpit_items_ignores_non_openpit_attributes() {
452 let field: Field = parse_quote!(
453 #[serde(default)]
454 operation: Operation
455 );
456 let parsed = parse_openpit_items(&field).expect("must ignore non-openpit attributes");
457 assert!(!parsed.inner);
458 assert!(parsed.capabilities.is_empty());
459 }
460
461 #[test]
462 fn register_trait_once_rejects_duplicates() {
463 let mut seen = std::collections::BTreeSet::new();
464 let capability = CapabilitySpec {
465 trait_path: parse_quote!(crate::HasInstrument),
466 method_ident: parse_quote!(instrument),
467 return_ty: parse_quote!(Result<&Instrument, RequestFieldAccessError>),
468 };
469 register_trait_once(&mut seen, &capability, &capability)
470 .expect("first mapping must register");
471 let err = register_trait_once(&mut seen, &capability, &capability)
472 .expect_err("duplicate mapping must reject");
473 assert_eq!(
474 err.to_string(),
475 "duplicate trait mapping for crate :: HasInstrument"
476 );
477 }
478
479 #[test]
480 fn derive_skips_field_without_ident_when_ast_is_malformed() {
481 let mut input: DeriveInput = parse_quote!(
482 struct Wrapper {
483 operation: Operation,
484 }
485 );
486 assert!(clear_first_named_field_ident(&mut input));
487
488 let generated =
489 derive_request_fields_impl(input).expect("malformed field without ident is skipped");
490 assert!(generated.is_empty());
491 }
492
493 #[test]
494 fn clear_first_named_field_ident_returns_false_for_non_struct() {
495 let mut input: DeriveInput = parse_quote!(
496 enum Wrapper {
497 A,
498 }
499 );
500 assert!(!clear_first_named_field_ident(&mut input));
501 }
502
503 #[test]
504 fn clear_first_named_field_ident_returns_false_for_unnamed_struct() {
505 let mut input: DeriveInput = parse_quote!(
506 struct Wrapper(u64);
507 );
508 assert!(!clear_first_named_field_ident(&mut input));
509 }
510
511 #[test]
512 fn parse_openpit_attr_item_parses_inferred_method_signature() {
513 let item: OpenpitAttrItem = parse_str("HasPnl(-> Result<Pnl, RequestFieldAccessError>)")
514 .expect("must parse inferred signature");
515 assert_eq!(capability_method_name(item).as_deref(), Some("pnl"));
516 }
517
518 #[test]
519 fn parse_openpit_attr_item_parses_explicit_method_signature() {
520 let item: OpenpitAttrItem =
521 parse_str("HasInstrument(instrument -> Result<&Instrument, RequestFieldAccessError>)")
522 .expect("must parse explicit signature");
523 assert_eq!(capability_method_name(item).as_deref(), Some("instrument"));
524 }
525
526 #[test]
527 fn parse_openpit_attr_item_parses_inner_marker() {
528 let item: OpenpitAttrItem = parse_str("inner").expect("must parse inner marker");
529 assert_eq!(capability_method_name(item), None);
530 }
531
532 #[test]
533 fn derive_request_fields_impl_generates_passthrough_for_inner_capability() {
534 let input: DeriveInput = parse_quote!(
535 struct Wrapper<T> {
536 #[openpit(inner, HasPnl(-> Result<Pnl, RequestFieldAccessError>))]
537 inner: T,
538 }
539 );
540
541 let generated = derive_request_fields_impl(input).expect("derive generation must succeed");
542 let generated_src = generated.to_string();
543 assert!(generated_src.contains("impl < T > HasPnl for Wrapper < T > where T : HasPnl"));
544 assert!(generated_src.contains("< T as HasPnl > :: pnl"));
545 assert!(generated_src.contains("& self . inner"));
546 }
547
548 fn capability_method_name(item: OpenpitAttrItem) -> Option<String> {
549 match item {
550 OpenpitAttrItem::Capability(spec) => Some(spec.method_ident.to_string()),
551 OpenpitAttrItem::Inner(_) => None,
552 }
553 }
554}