Skip to main content

codex_experimental_api_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::Span;
3use quote::quote;
4use syn::Attribute;
5use syn::Data;
6use syn::DataEnum;
7use syn::DataStruct;
8use syn::DeriveInput;
9use syn::Field;
10use syn::Fields;
11use syn::Ident;
12use syn::LitStr;
13use syn::Type;
14use syn::parse_macro_input;
15
16#[proc_macro_derive(ExperimentalApi, attributes(experimental))]
17pub fn derive_experimental_api(input: TokenStream) -> TokenStream {
18    let input = parse_macro_input!(input as DeriveInput);
19    match &input.data {
20        Data::Struct(data) => derive_for_struct(&input, data),
21        Data::Enum(data) => derive_for_enum(&input, data),
22        Data::Union(_) => {
23            syn::Error::new_spanned(&input.ident, "ExperimentalApi does not support unions")
24                .to_compile_error()
25                .into()
26        }
27    }
28}
29
30fn derive_for_struct(input: &DeriveInput, data: &DataStruct) -> TokenStream {
31    let name = &input.ident;
32    let type_name_lit = LitStr::new(&name.to_string(), Span::call_site());
33
34    let (checks, experimental_fields, registrations) = match &data.fields {
35        Fields::Named(named) => {
36            let mut checks = Vec::new();
37            let mut experimental_fields = Vec::new();
38            let mut registrations = Vec::new();
39            for field in &named.named {
40                if let Some(reason) = experimental_reason(&field.attrs) {
41                    let expr = experimental_presence_expr(field, /*tuple_struct*/ false);
42                    checks.push(quote! {
43                        if #expr {
44                            return Some(#reason);
45                        }
46                    });
47
48                    if let Some(field_name) = field_serialized_name(field) {
49                        let field_name_lit = LitStr::new(&field_name, Span::call_site());
50                        experimental_fields.push(quote! {
51                            crate::experimental_api::ExperimentalField {
52                                type_name: #type_name_lit,
53                                field_name: #field_name_lit,
54                                reason: #reason,
55                            }
56                        });
57                        registrations.push(quote! {
58                            ::inventory::submit! {
59                                crate::experimental_api::ExperimentalField {
60                                    type_name: #type_name_lit,
61                                    field_name: #field_name_lit,
62                                    reason: #reason,
63                                }
64                            }
65                        });
66                    }
67                } else if has_nested_experimental(field) {
68                    let Some(ident) = field.ident.as_ref() else {
69                        continue;
70                    };
71                    checks.push(quote! {
72                        if let Some(reason) =
73                            crate::experimental_api::ExperimentalApi::experimental_reason(&self.#ident)
74                        {
75                            return Some(reason);
76                        }
77                    });
78                }
79            }
80            (checks, experimental_fields, registrations)
81        }
82        Fields::Unnamed(unnamed) => {
83            let mut checks = Vec::new();
84            let mut experimental_fields = Vec::new();
85            let mut registrations = Vec::new();
86            for (index, field) in unnamed.unnamed.iter().enumerate() {
87                if let Some(reason) = experimental_reason(&field.attrs) {
88                    let expr = index_presence_expr(index, &field.ty);
89                    checks.push(quote! {
90                        if #expr {
91                            return Some(#reason);
92                        }
93                    });
94
95                    let field_name_lit = LitStr::new(&index.to_string(), Span::call_site());
96                    experimental_fields.push(quote! {
97                        crate::experimental_api::ExperimentalField {
98                            type_name: #type_name_lit,
99                            field_name: #field_name_lit,
100                            reason: #reason,
101                        }
102                    });
103                    registrations.push(quote! {
104                        ::inventory::submit! {
105                            crate::experimental_api::ExperimentalField {
106                                type_name: #type_name_lit,
107                                field_name: #field_name_lit,
108                                reason: #reason,
109                            }
110                        }
111                    });
112                } else if has_nested_experimental(field) {
113                    let index = syn::Index::from(index);
114                    checks.push(quote! {
115                        if let Some(reason) =
116                            crate::experimental_api::ExperimentalApi::experimental_reason(&self.#index)
117                        {
118                            return Some(reason);
119                        }
120                    });
121                }
122            }
123            (checks, experimental_fields, registrations)
124        }
125        Fields::Unit => (Vec::new(), Vec::new(), Vec::new()),
126    };
127
128    let checks = if checks.is_empty() {
129        quote! { None }
130    } else {
131        quote! {
132            #(#checks)*
133            None
134        }
135    };
136
137    let experimental_fields = if experimental_fields.is_empty() {
138        quote! { &[] }
139    } else {
140        quote! { &[ #(#experimental_fields,)* ] }
141    };
142
143    let expanded = quote! {
144        #(#registrations)*
145
146        impl #name {
147            pub(crate) const EXPERIMENTAL_FIELDS: &'static [crate::experimental_api::ExperimentalField] =
148                #experimental_fields;
149        }
150
151        impl crate::experimental_api::ExperimentalApi for #name {
152            fn experimental_reason(&self) -> Option<&'static str> {
153                #checks
154            }
155        }
156    };
157    expanded.into()
158}
159
160fn derive_for_enum(input: &DeriveInput, data: &DataEnum) -> TokenStream {
161    let name = &input.ident;
162    let mut match_arms = Vec::new();
163
164    for variant in &data.variants {
165        let variant_name = &variant.ident;
166        let pattern = match &variant.fields {
167            Fields::Named(_) => quote!(Self::#variant_name { .. }),
168            Fields::Unnamed(_) => quote!(Self::#variant_name ( .. )),
169            Fields::Unit => quote!(Self::#variant_name),
170        };
171        let reason = experimental_reason(&variant.attrs);
172        if let Some(reason) = reason {
173            match_arms.push(quote! {
174                #pattern => Some(#reason),
175            });
176        } else {
177            match_arms.push(quote! {
178                #pattern => None,
179            });
180        }
181    }
182
183    let expanded = quote! {
184        impl crate::experimental_api::ExperimentalApi for #name {
185            fn experimental_reason(&self) -> Option<&'static str> {
186                match self {
187                    #(#match_arms)*
188                }
189            }
190        }
191    };
192    expanded.into()
193}
194
195fn experimental_reason(attrs: &[Attribute]) -> Option<LitStr> {
196    attrs.iter().find_map(experimental_reason_attr)
197}
198
199fn experimental_reason_attr(attr: &Attribute) -> Option<LitStr> {
200    if !attr.path().is_ident("experimental") {
201        return None;
202    }
203
204    attr.parse_args::<LitStr>().ok()
205}
206
207fn has_nested_experimental(field: &Field) -> bool {
208    field.attrs.iter().any(experimental_nested_attr)
209}
210
211fn experimental_nested_attr(attr: &Attribute) -> bool {
212    if !attr.path().is_ident("experimental") {
213        return false;
214    }
215
216    attr.parse_args::<Ident>()
217        .is_ok_and(|ident| ident == "nested")
218}
219
220fn field_serialized_name(field: &Field) -> Option<String> {
221    let ident = field.ident.as_ref()?;
222    let name = ident.to_string();
223    Some(snake_to_camel(&name))
224}
225
226fn snake_to_camel(s: &str) -> String {
227    let mut out = String::with_capacity(s.len());
228    let mut upper = false;
229    for ch in s.chars() {
230        if ch == '_' {
231            upper = true;
232            continue;
233        }
234        if upper {
235            out.push(ch.to_ascii_uppercase());
236            upper = false;
237        } else {
238            out.push(ch);
239        }
240    }
241    out
242}
243
244fn experimental_presence_expr(
245    field: &Field,
246    tuple_struct: bool,
247) -> Option<proc_macro2::TokenStream> {
248    if tuple_struct {
249        return None;
250    }
251    let ident = field.ident.as_ref()?;
252    Some(presence_expr_for_access(quote!(self.#ident), &field.ty))
253}
254
255fn index_presence_expr(index: usize, ty: &Type) -> proc_macro2::TokenStream {
256    let index = syn::Index::from(index);
257    presence_expr_for_access(quote!(self.#index), ty)
258}
259
260fn presence_expr_for_access(
261    access: proc_macro2::TokenStream,
262    ty: &Type,
263) -> proc_macro2::TokenStream {
264    if option_inner(ty).is_some() {
265        return quote! { #access.is_some() };
266    }
267    if is_vec_like(ty) || is_map_like(ty) {
268        return quote! { !#access.is_empty() };
269    }
270    if is_bool(ty) {
271        return quote! { #access };
272    }
273    quote! { true }
274}
275
276fn option_inner(ty: &Type) -> Option<&Type> {
277    let Type::Path(type_path) = ty else {
278        return None;
279    };
280    let segment = type_path.path.segments.last()?;
281    if segment.ident != "Option" {
282        return None;
283    }
284    let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
285        return None;
286    };
287    args.args.iter().find_map(|arg| match arg {
288        syn::GenericArgument::Type(inner) => Some(inner),
289        _ => None,
290    })
291}
292
293fn is_vec_like(ty: &Type) -> bool {
294    type_last_ident(ty).is_some_and(|ident| ident == "Vec")
295}
296
297fn is_map_like(ty: &Type) -> bool {
298    type_last_ident(ty).is_some_and(|ident| ident == "HashMap" || ident == "BTreeMap")
299}
300
301fn is_bool(ty: &Type) -> bool {
302    type_last_ident(ty).is_some_and(|ident| ident == "bool")
303}
304
305fn type_last_ident(ty: &Type) -> Option<Ident> {
306    let Type::Path(type_path) = ty else {
307        return None;
308    };
309    type_path.path.segments.last().map(|seg| seg.ident.clone())
310}