Skip to main content

stygian_extract_derive/
lib.rs

1//! Proc-macro crate for `#[derive(Extract)]`.
2//!
3//! Used by `stygian-browser` behind the `extract` feature flag.
4//! Do not depend on this crate directly — use `stygian_browser::extract::Extract`.
5
6use proc_macro::TokenStream;
7use quote::quote;
8use syn::{
9    Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, TypePath, parse_macro_input,
10};
11
12// ─── SelectorArgs ─────────────────────────────────────────────────────────────
13
14/// Arguments parsed from `#[selector("css")]`, `#[selector("css", attr = "name")]`,
15/// or `#[selector("css", nested)]`.
16struct SelectorArgs {
17    css: String,
18    attr: Option<String>,
19    nested: bool,
20}
21
22impl syn::parse::Parse for SelectorArgs {
23    fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
24        // First positional argument must be the CSS selector string.
25        let css: syn::LitStr = input.parse()?;
26        let mut attr: Option<String> = None;
27        let mut nested = false;
28
29        while input.peek(syn::Token![,]) {
30            let _: syn::Token![,] = input.parse()?;
31            // Allow a trailing comma with nothing after it.
32            if input.is_empty() {
33                break;
34            }
35            let kw: syn::Ident = input.parse()?;
36            if kw == "attr" {
37                let _: syn::Token![=] = input.parse()?;
38                let s: syn::LitStr = input.parse()?;
39                attr = Some(s.value());
40            } else if kw == "nested" {
41                nested = true;
42            } else {
43                return Err(syn::Error::new_spanned(
44                    kw,
45                    "unknown selector option; expected `attr = \"...\"` or `nested`",
46                ));
47            }
48        }
49
50        Ok(Self {
51            css: css.value(),
52            attr,
53            nested,
54        })
55    }
56}
57
58// ─── Helper: detect Option<T> ─────────────────────────────────────────────────
59
60/// If `ty` is `Option<Inner>`, return `Some(&Inner)`.  Otherwise `None`.
61fn unwrap_option(ty: &Type) -> Option<&Type> {
62    let Type::Path(TypePath {
63        qself: None, path, ..
64    }) = ty
65    else {
66        return None;
67    };
68    let seg = path.segments.last()?;
69    if seg.ident != "Option" {
70        return None;
71    }
72    let PathArguments::AngleBracketed(ref args) = seg.arguments else {
73        return None;
74    };
75    if let Some(GenericArgument::Type(inner)) = args.args.first() {
76        Some(inner)
77    } else {
78        None
79    }
80}
81
82// ─── Per-field code generators ────────────────────────────────────────────────
83
84fn generate_text_extraction(
85    field_name: &syn::Ident,
86    field_name_str: &str,
87    css: &str,
88    is_optional: bool,
89) -> proc_macro2::TokenStream {
90    if is_optional {
91        quote! {
92            let #field_name = {
93                let __children = node.children_matching(#css).await
94                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
95                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
96                    })?;
97                match __children.into_iter().next() {
98                    None => None,
99                    Some(ref __node) => Some(__node.text_content().await
100                        .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
101                            field: #field_name_str, source: ::std::boxed::Box::new(__e),
102                        })?),
103                }
104            };
105        }
106    } else {
107        quote! {
108            let #field_name = {
109                let __children = node.children_matching(#css).await
110                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
111                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
112                    })?;
113                let __first = __children.into_iter().next().ok_or(
114                    ::stygian_browser::extract::ExtractionError::Missing {
115                        field: #field_name_str, selector: #css,
116                    })?;
117                __first.text_content().await
118                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
119                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
120                    })?
121            };
122        }
123    }
124}
125
126fn generate_attr_extraction(
127    field_name: &syn::Ident,
128    field_name_str: &str,
129    css: &str,
130    attr: &str,
131    is_optional: bool,
132) -> proc_macro2::TokenStream {
133    if is_optional {
134        quote! {
135            let #field_name = {
136                let __children = node.children_matching(#css).await
137                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
138                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
139                    })?;
140                match __children.into_iter().next() {
141                    None => None,
142                    Some(ref __node) => __node.attr(#attr).await
143                        .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
144                            field: #field_name_str, source: ::std::boxed::Box::new(__e),
145                        })?,
146                }
147            };
148        }
149    } else {
150        quote! {
151            let #field_name = {
152                let __children = node.children_matching(#css).await
153                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
154                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
155                    })?;
156                let __first = __children.into_iter().next().ok_or(
157                    ::stygian_browser::extract::ExtractionError::Missing {
158                        field: #field_name_str, selector: #css,
159                    })?;
160                __first.attr(#attr).await
161                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
162                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
163                    })?
164                    .unwrap_or_default()
165            };
166        }
167    }
168}
169
170fn generate_nested_extraction(
171    field_name: &syn::Ident,
172    field_name_str: &str,
173    css: &str,
174    inner_ty: &Type,
175    is_optional: bool,
176) -> proc_macro2::TokenStream {
177    if is_optional {
178        quote! {
179            let #field_name = {
180                let __children = node.children_matching(#css).await
181                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
182                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
183                    })?;
184                match __children.into_iter().next() {
185                    None => None,
186                    Some(ref __node) => Some(
187                        <#inner_ty as ::stygian_browser::extract::Extractable>::extract_from(__node).await
188                            .map_err(|__e| ::stygian_browser::extract::ExtractionError::Nested {
189                                field: #field_name_str, source: ::std::boxed::Box::new(__e),
190                            })?),
191                }
192            };
193        }
194    } else {
195        quote! {
196            let #field_name = {
197                let __children = node.children_matching(#css).await
198                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::CdpFailed {
199                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
200                    })?;
201                let __first = __children.into_iter().next().ok_or(
202                    ::stygian_browser::extract::ExtractionError::Missing {
203                        field: #field_name_str, selector: #css,
204                    })?;
205                <#inner_ty as ::stygian_browser::extract::Extractable>::extract_from(&__first).await
206                    .map_err(|__e| ::stygian_browser::extract::ExtractionError::Nested {
207                        field: #field_name_str, source: ::std::boxed::Box::new(__e),
208                    })?
209            };
210        }
211    }
212}
213
214fn generate_field(field: &syn::Field) -> syn::Result<(proc_macro2::TokenStream, syn::Ident)> {
215    let field_name = field
216        .ident
217        .as_ref()
218        .ok_or_else(|| syn::Error::new_spanned(field, "expected a named field"))?
219        .clone();
220    let field_name_str = field_name.to_string();
221
222    let selector_attr = field
223        .attrs
224        .iter()
225        .find(|a| a.path().is_ident("selector"))
226        .ok_or_else(|| {
227            syn::Error::new_spanned(
228                field,
229                format!("field `{field_name_str}` is missing a #[selector(\"...\")] attribute"),
230            )
231        })?;
232
233    let args: SelectorArgs = selector_attr.parse_args()?;
234    let css = &args.css;
235    let is_optional = unwrap_option(&field.ty).is_some();
236
237    let ts = if args.nested {
238        let inner_ty = unwrap_option(&field.ty).unwrap_or(&field.ty);
239        generate_nested_extraction(&field_name, &field_name_str, css, inner_ty, is_optional)
240    } else if let Some(ref attr) = args.attr {
241        generate_attr_extraction(&field_name, &field_name_str, css, attr, is_optional)
242    } else {
243        generate_text_extraction(&field_name, &field_name_str, css, is_optional)
244    };
245
246    Ok((ts, field_name))
247}
248
249// ─── #[derive(Extract)] ───────────────────────────────────────────────────────
250
251/// Derive `stygian_browser::extract::Extractable` for a struct.
252///
253/// Each field must carry `#[selector("css")]`, `#[selector("css", attr = "name")]`,
254/// or `#[selector("css", nested)]`.  Wrapping the field type in `Option<T>` makes
255/// a missing element produce `None` instead of
256/// `ExtractionError::Missing`.
257#[proc_macro_derive(Extract, attributes(selector))]
258pub fn derive_extract(input: TokenStream) -> TokenStream {
259    let input = parse_macro_input!(input as DeriveInput);
260    match expand(&input) {
261        Ok(ts) => ts.into(),
262        Err(e) => e.to_compile_error().into(),
263    }
264}
265
266fn expand(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
267    let name = &input.ident;
268
269    // Reject non-struct inputs with a clean compile_error.
270    let Data::Struct(ref data_struct) = input.data else {
271        return Err(syn::Error::new_spanned(
272            &input.ident,
273            "#[derive(Extract)] can only be applied to structs",
274        ));
275    };
276
277    // Reject tuple / unit structs.
278    let Fields::Named(ref named_fields) = data_struct.fields else {
279        return Err(syn::Error::new_spanned(
280            &input.ident,
281            "#[derive(Extract)] requires a struct with named fields",
282        ));
283    };
284
285    let pairs: Vec<_> = named_fields
286        .named
287        .iter()
288        .map(generate_field)
289        .collect::<syn::Result<_>>()?;
290
291    let (field_assignments, field_idents): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
292    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
293
294    Ok(quote! {
295        impl #impl_generics ::stygian_browser::extract::Extractable for #name #ty_generics
296        #where_clause
297        {
298            async fn extract_from(
299                node: &::stygian_browser::page::NodeHandle,
300            ) -> ::std::result::Result<Self, ::stygian_browser::extract::ExtractionError> {
301                #(#field_assignments)*
302                Ok(Self { #(#field_idents),* })
303            }
304        }
305    })
306}