1use proc_macro::TokenStream;
7use quote::quote;
8use syn::{
9 Data, DeriveInput, Fields, GenericArgument, PathArguments, Type, TypePath, parse_macro_input,
10};
11
12struct 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 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 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
58fn 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
82fn 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#[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 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 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}