Skip to main content

openpit_derive/
lib.rs

1// Copyright The Pit Project Owners. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16// Please see https://openpit.dev and the OWNERS file for details.
17//! Procedural macros for the `openpit` SDK.
18//!
19//! This crate provides derive macros that generate request-field capability implementations
20//! expected by `openpit` policies.
21//!
22//! # `RequestFields`
23//!
24//! Derive for wrapper structs with named fields.
25//!
26//! Field-level `#[openpit(...)]` items:
27//!
28//! - `inner`: marks the field used for passthrough delegation.
29//! - `TraitPath(method -> ReturnType)`: generate direct impl for the field.
30//! - `TraitPath(-> ReturnType)`: same as above, method inferred from `Has*` trait name.
31//!
32//! On a field marked with `inner`, trait items generate passthrough impls with
33//! `where InnerType: TraitPath`.
34//!
35//! Old syntax `#[request_fields(...)]` is rejected with a compile-time error that points to
36//! `#[openpit(...)]`.
37
38use 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}