Skip to main content

lineark_derive/
lib.rs

1//! Derive macro for the `GraphQLFields` trait.
2//!
3//! Automatically generates `selection()` from a struct's field definitions.
4//! No manual GraphQL strings needed — the struct shape IS the query shape.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! use lineark_sdk::GraphQLFields;
10//! use lineark_sdk::generated::types::Issue;
11//!
12//! #[derive(GraphQLFields, Deserialize)]
13//! #[graphql(full_type = Issue)]
14//! #[serde(rename_all = "camelCase")]
15//! struct MyIssue {
16//!     id: Option<String>,
17//!     title: Option<String>,
18//!     #[graphql(nested)]
19//!     state: Option<StateRef>,
20//! }
21//! ```
22//!
23//! Generates: `"id title state { <StateRef::selection()> }"`
24//!
25//! With `full_type`, the macro also generates compile-time validation that
26//! each field exists on the full type with a compatible type.
27
28use heck::ToLowerCamelCase;
29use proc_macro::TokenStream;
30use quote::quote;
31use syn::{parse_macro_input, Data, DeriveInput, Fields, Type};
32
33/// Derive `GraphQLFields` for a struct.
34///
35/// Each field becomes a GraphQL selection entry:
36/// - Plain fields → `camelCaseName`
37/// - `#[graphql(nested)]` fields → `camelCaseName { <InnerType::selection()> }`
38///
39/// # Struct-level attributes
40///
41/// - `#[graphql(full_type = Path)]` — enables compile-time validation against
42///   the specified generated type. Without this, `FullType = Self` and no
43///   validation is performed (used by codegen for full types).
44#[proc_macro_derive(GraphQLFields, attributes(graphql))]
45pub fn derive_graphql_fields(input: TokenStream) -> TokenStream {
46    let input = parse_macro_input!(input as DeriveInput);
47    let name = &input.ident;
48
49    // Parse optional #[graphql(full_type = Path)] from struct-level attributes
50    let full_type_path = parse_full_type(&input.attrs);
51
52    let fields = match &input.data {
53        Data::Struct(data) => match &data.fields {
54            Fields::Named(named) => &named.named,
55            _ => {
56                return syn::Error::new_spanned(
57                    &input,
58                    "GraphQLFields can only be derived on structs with named fields",
59                )
60                .to_compile_error()
61                .into();
62            }
63        },
64        _ => {
65            return syn::Error::new_spanned(&input, "GraphQLFields can only be derived on structs")
66                .to_compile_error()
67                .into();
68        }
69    };
70
71    let mut selection_parts = Vec::new();
72    let mut validation_checks = Vec::new();
73
74    for field in fields {
75        let field_name = field.ident.as_ref().expect("named field should have ident");
76
77        // Convert Rust snake_case field name to GraphQL camelCase.
78        // Handle raw identifiers (r#type → type).
79        let rust_name = field_name.to_string();
80        let clean_name = rust_name.strip_prefix("r#").unwrap_or(&rust_name);
81        let gql_name = clean_name.to_lower_camel_case();
82
83        let is_nested = field.attrs.iter().any(|attr| {
84            if !attr.path().is_ident("graphql") {
85                return false;
86            }
87            let mut found = false;
88            let _ = attr.parse_nested_meta(|meta| {
89                if meta.path.is_ident("nested") {
90                    found = true;
91                }
92                Ok(())
93            });
94            found
95        });
96
97        if is_nested {
98            // Extract the inner type (unwrap Option<T>, Vec<T>, Box<T>).
99            let inner_ty = unwrap_type(&field.ty);
100            selection_parts.push(quote! {
101                {
102                    let nested = <#inner_ty as GraphQLFields>::selection();
103                    format!("{} {{ {} }}", #gql_name, nested)
104                }
105            });
106            // For nested fields, only validate field existence (not type compatibility,
107            // since the parent can't know the nested type's FullType).
108            if full_type_path.is_some() {
109                validation_checks.push(quote! {
110                    { let _ = &__v.#field_name; }
111                });
112            }
113        } else {
114            selection_parts.push(quote! {
115                #gql_name.to_string()
116            });
117            // For scalar fields, validate both field existence AND type compatibility.
118            if full_type_path.is_some() {
119                let field_ty = &field.ty;
120                validation_checks.push(quote! {
121                    {
122                        fn __check<__F: ::lineark_sdk::FieldCompatible<__C>, __C>(_: &__F) {}
123                        __check::<_, #field_ty>(&__v.#field_name);
124                    }
125                });
126            }
127        }
128    }
129
130    let full_type_assoc = if let Some(ref path) = full_type_path {
131        quote! { type FullType = #path; }
132    } else {
133        quote! { type FullType = Self; }
134    };
135
136    let validation_block = if !validation_checks.is_empty() {
137        let full_type = full_type_path.as_ref().unwrap();
138        quote! {
139            const _: () = {
140                #[allow(unused)]
141                fn __graphql_validate(__v: &#full_type) {
142                    #(#validation_checks)*
143                }
144            };
145        }
146    } else {
147        quote! {}
148    };
149
150    let expanded = quote! {
151        impl GraphQLFields for #name {
152            #full_type_assoc
153
154            fn selection() -> String {
155                let parts: Vec<String> = vec![
156                    #(#selection_parts),*
157                ];
158                parts.join(" ")
159            }
160        }
161
162        #validation_block
163    };
164
165    expanded.into()
166}
167
168/// Parse `#[graphql(full_type = Path)]` from struct-level attributes.
169fn parse_full_type(attrs: &[syn::Attribute]) -> Option<syn::Path> {
170    for attr in attrs {
171        if !attr.path().is_ident("graphql") {
172            continue;
173        }
174        let mut full_type = None;
175        let _ = attr.parse_nested_meta(|meta| {
176            if meta.path.is_ident("full_type") {
177                let value = meta.value()?;
178                full_type = Some(value.parse::<syn::Path>()?);
179            }
180            Ok(())
181        });
182        if full_type.is_some() {
183            return full_type;
184        }
185    }
186    None
187}
188
189/// Unwrap wrapper types to get the "leaf" type for nested selections.
190/// `Option<Box<Foo>>` → `Foo`, `Vec<Bar>` → `Bar`, `Option<Vec<Baz>>` → `Baz`, etc.
191fn unwrap_type(ty: &Type) -> &Type {
192    if let Type::Path(type_path) = ty {
193        if let Some(segment) = type_path.path.segments.last() {
194            let ident = segment.ident.to_string();
195            if ident == "Option" || ident == "Vec" || ident == "Box" {
196                if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
197                    if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
198                        return unwrap_type(inner);
199                    }
200                }
201            }
202        }
203    }
204    ty
205}