csv_schema_validator_derive/
lib.rs

1// csv-schema-validator-derive/src/lib.rs
2extern crate proc_macro;
3
4use proc_macro::TokenStream;
5use quote::quote;
6use syn::{
7    parse_macro_input, Data, DeriveInput, Expr, ExprLit, Fields, GenericArgument, Ident, Lit, Meta,
8    PathArguments, Type,
9};
10
11// Armazena validações por campo
12struct FieldValidation {
13    field_name: Ident,
14    is_option: bool, // [FIX] passamos a carregar se o campo é Option<T>
15    validations: Vec<Validation>,
16}
17
18// Tipos de validações suportadas
19enum Validation {
20    Range { min: f64, max: f64 },
21    Regex { regex: String },
22    Required,
23    Custom { path: syn::Path },
24    Length { min: usize, max: usize },
25}
26
27impl Validation {
28    /// Faz o parse de #[validate(...)] em uma lista de Validation
29    fn parse_validations(input: syn::parse::ParseStream) -> syn::Result<Vec<Self>> {
30        let mut validations = Vec::new();
31        let meta_items =
32            syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated(input)?;
33
34        for meta in meta_items {
35            match meta {
36                Meta::Path(path) => {
37                    if path.is_ident("required") {
38                        validations.push(Validation::Required);
39                    }
40                }
41                Meta::NameValue(mnv) => {
42                    if mnv.path.is_ident("regex") {
43                        if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = mnv.value {
44                            validations.push(Validation::Regex { regex: s.value() });
45                        } else {
46                            return Err(syn::Error::new_spanned(
47                                mnv.value,
48                                "Expected string literal for `regex`",
49                            ));
50                        }
51                    } else if mnv.path.is_ident("custom") {
52                        if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = mnv.value {
53                            let path: syn::Path =
54                                syn::parse_str(&s.value()).map_err(|e| syn::Error::new_spanned(s, e))?;
55                            validations.push(Validation::Custom { path });
56                        } else {
57                            return Err(syn::Error::new_spanned(
58                                mnv.value,
59                                "Expected string literal for `custom` (e.g., custom = \"path::to::fn\")",
60                            ));
61                        }
62                    }
63                }
64                Meta::List(meta_list) => {
65                    if meta_list.path.is_ident("length") {
66                        let mut min: Option<usize> = None;
67                        let mut max: Option<usize> = None;
68
69                        let items: syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]> =
70                            meta_list.parse_args_with(
71                                syn::punctuated::Punctuated::parse_terminated,
72                            )?;
73
74                        for kv in items {
75                            if kv.path.is_ident("min") {
76                                if let Expr::Lit(ExprLit { lit: Lit::Int(i), .. }) = kv.value {
77                                    min = Some(i.base10_parse::<usize>()?);
78                                } else {
79                                    return Err(syn::Error::new_spanned(
80                                        kv.value,
81                                        "`min` for `length` must be an integer literal",
82                                    ));
83                                }
84                            } else if kv.path.is_ident("max") {
85                                if let Expr::Lit(ExprLit { lit: Lit::Int(i), .. }) = kv.value {
86                                    max = Some(i.base10_parse::<usize>()?);
87                                } else {
88                                    return Err(syn::Error::new_spanned(
89                                        kv.value,
90                                        "`max` for `length` must be an integer literal",
91                                    ));
92                                }
93                            }
94                        }
95
96                        if min.is_none() && max.is_none() {
97                            return Err(syn::Error::new_spanned(
98                                meta_list,
99                                "`length` requires at least one of `min` or `max`",
100                            ));
101                        }
102                        if let Some(mx) = max {
103                            if mx == 0 {
104                                return Err(syn::Error::new_spanned(
105                                    meta_list,
106                                    "`max` for `length` cannot be zero",
107                                ));
108                            }
109                        }
110                        if let (Some(a), Some(b)) = (min, max) {
111                            if a > b {
112                                return Err(syn::Error::new_spanned(
113                                    meta_list,
114                                    "`min` must be <= `max` for `length`",
115                                ));
116                            }
117                        }
118
119                        validations.push(Validation::Length {
120                            min: min.unwrap_or(0),
121                            max: max.unwrap_or(usize::MAX),
122                        });
123                    } else if meta_list.path.is_ident("range") {
124                        let mut min: Option<f64> = None;
125                        let mut max: Option<f64> = None;
126
127                        let items: syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]> =
128                            meta_list.parse_args_with(
129                                syn::punctuated::Punctuated::parse_terminated,
130                            )?;
131
132                        for kv in items {
133                            if kv.path.is_ident("min") {
134                                if let Expr::Lit(ExprLit { lit: Lit::Float(f), .. }) = kv.value {
135                                    min = Some(f.base10_parse::<f64>()?);
136                                } else {
137                                    return Err(syn::Error::new_spanned(
138                                        kv.value,
139                                        "`min` for `range` must be a float literal",
140                                    ));
141                                }
142                            } else if kv.path.is_ident("max") {
143                                if let Expr::Lit(ExprLit { lit: Lit::Float(f), .. }) = kv.value {
144                                    max = Some(f.base10_parse::<f64>()?);
145                                } else {
146                                    return Err(syn::Error::new_spanned(
147                                        kv.value,
148                                        "`max` for `range` must be a float literal",
149                                    ));
150                                }
151                            }
152                        }
153
154                        if min.is_none() && max.is_none() {
155                            return Err(syn::Error::new_spanned(
156                                meta_list,
157                                "`range` requires at least one of `min` or `max`",
158                            ));
159                        }
160
161                        validations.push(Validation::Range {
162                            min: min.unwrap_or(f64::NEG_INFINITY),
163                            max: max.unwrap_or(f64::INFINITY),
164                        });
165                    }
166                }
167            }
168        }
169
170        Ok(validations)
171    }
172}
173
174// [FIX] helper para detectar Option<T>
175fn option_inner_type(ty: &Type) -> Option<&Type> {
176    if let Type::Path(tp) = ty {
177        if let Some(seg) = tp.path.segments.last() {
178            if seg.ident == "Option" {
179                if let PathArguments::AngleBracketed(args) = &seg.arguments {
180                    if let Some(GenericArgument::Type(inner_ty)) = args.args.first() {
181                        return Some(inner_ty);
182                    }
183                }
184            }
185        }
186    }
187    None
188}
189
190#[proc_macro_derive(ValidateCsv, attributes(validate))]
191pub fn validate_csv_derive(input: TokenStream) -> TokenStream {
192    let input = parse_macro_input!(input as DeriveInput);
193    let name = &input.ident;
194
195    let fields = match &input.data {
196        Data::Struct(data) => match &data.fields {
197            Fields::Named(f) => &f.named,
198            _ => {
199                return syn::Error::new_spanned(
200                    &data.fields,
201                    "only structs with named fields are supported",
202                )
203                .to_compile_error()
204                .into();
205            }
206        },
207        _ => {
208            return syn::Error::new_spanned(&input, "only structs are supported")
209                .to_compile_error()
210                .into();
211        }
212    };
213
214    let mut field_validations = Vec::new();
215
216    for field in fields {
217        let field_name = field.ident.as_ref().unwrap().clone();
218        let is_option = option_inner_type(&field.ty).is_some(); // [FIX] capturamos se é Option<T>
219        let mut validations = Vec::new();
220
221        for attr in &field.attrs {
222            if attr.path().is_ident("validate") {
223                match attr.parse_args_with(Validation::parse_validations) {
224                    Ok(mut v) => validations.append(&mut v),
225                    Err(e) => return e.to_compile_error().into(),
226                }
227            }
228        }
229
230        if !validations.is_empty() {
231            field_validations.push(FieldValidation {
232                field_name,
233                is_option,
234                validations,
235            });
236        }
237    }
238
239    let validation_arms = field_validations.into_iter().map(|fv| {
240        let field_name_str = fv.field_name.to_string();
241        let field_name_ident = fv.field_name;
242        let fv_is_option = fv.is_option;
243
244        let checks = fv.validations.into_iter().map(|validation| match validation {
245            Validation::Required => {
246                // mantém comportamento: se None => erro
247                quote! {
248                    if (&self.#field_name_ident).is_none() {
249                        errors.push(::csv_schema_validator::ValidationError {
250                            field: #field_name_str.to_string(),
251                            message: "mandatory field".to_string(),
252                        });
253                    }
254                }
255            }
256            Validation::Range { min, max } => {
257                // [FIX] valida range apenas quando Some(v) em Option<f64>
258                if fv_is_option {
259                    quote! {
260                        if let Some(value) = &self.#field_name_ident {
261                            if !(#min <= *value && *value <= #max) {
262                                errors.push(::csv_schema_validator::ValidationError {
263                                    field: #field_name_str.to_string(),
264                                    message: format!("value out of expected range: {} to {}", #min, #max),
265                                });
266                            }
267                        }
268                    }
269                } else {
270                    quote! {
271                        let value = &self.#field_name_ident;
272                        if !(#min <= *value && *value <= #max) {
273                            errors.push(::csv_schema_validator::ValidationError {
274                                field: #field_name_str.to_string(),
275                                message: format!("value out of expected range: {} to {}", #min, #max),
276                            });
277                        }
278                    }
279                }
280            }
281            Validation::Length { min, max } => {
282                // [FIX] trata Option<String>: valida apenas quando Some(s)
283                if fv_is_option {
284                    quote! {
285                        if let Some(value) = &self.#field_name_ident {
286                            let len = value.len();
287                            if len < #min || len > #max {
288                                errors.push(::csv_schema_validator::ValidationError {
289                                    field: #field_name_str.to_string(),
290                                    message: format!("length out of expected range: {} to {}", #min, #max),
291                                });
292                            }
293                        }
294                    }
295                } else {
296                    quote! {
297                        let value = &self.#field_name_ident;
298                        let len = value.len();
299                        if len < #min || len > #max {
300                            errors.push(::csv_schema_validator::ValidationError {
301                                field: #field_name_str.to_string(),
302                                message: format!("length out of expected range: {} to {}", #min, #max),
303                            });
304                        }
305                    }
306                }
307            }
308            Validation::Regex { regex } => {
309                // [FIX] reutiliza corpo mas injeta binding 'value' diferente para Option<String>
310                let regex_body = quote! {
311                    use ::csv_schema_validator::__private::once_cell::sync::Lazy;
312                    use ::csv_schema_validator::__private::regex;
313                    static RE: Lazy<Result<regex::Regex, regex::Error>> = Lazy::new(|| regex::Regex::new(#regex));
314
315                    match RE.as_ref() {
316                        Ok(compiled_regex) => {
317                            if !compiled_regex.is_match(value) {
318                                errors.push(::csv_schema_validator::ValidationError {
319                                    field: #field_name_str.to_string(),
320                                    message: "does not match the expected pattern".to_string(),
321                                });
322                            }
323                        }
324                        Err(e) => {
325                            errors.push(::csv_schema_validator::ValidationError {
326                                field: #field_name_str.to_string(),
327                                message: format!("invalid regex '{}': {}", #regex, e),
328                            });
329                        }
330                    }
331                };
332
333                if fv_is_option {
334                    quote! {
335                        if let Some(value) = &self.#field_name_ident {
336                            #regex_body
337                        }
338                    }
339                } else {
340                    quote! {
341                        let value = &self.#field_name_ident;
342                        #regex_body
343                    }
344                }
345            }
346            Validation::Custom { path } => {
347                // [FIX] chama função apenas quando Some(v) para Option<T>
348                if fv_is_option {
349                    quote! {
350                        if let Some(value) = &self.#field_name_ident {
351                            match #path(value) {
352                                Err(err) => {
353                                    errors.push(::csv_schema_validator::ValidationError {
354                                        field: #field_name_str.to_string(),
355                                        message: format!("{}", err),
356                                    });
357                                }
358                                Ok(()) => {}
359                            }
360                        }
361                    }
362                } else {
363                    quote! {
364                        match #path(&self.#field_name_ident) {
365                            Err(err) => {
366                                errors.push(::csv_schema_validator::ValidationError {
367                                    field: #field_name_str.to_string(),
368                                    message: format!("{}", err),
369                                });
370                            }
371                            Ok(()) => {}
372                        }
373                    }
374                }
375            }
376        });
377
378        quote! {
379            #(#checks)*
380        }
381    });
382
383    let expanded = quote! {
384        impl #name {
385            pub fn validate_csv(&self) -> ::core::result::Result<(), ::std::vec::Vec<::csv_schema_validator::ValidationError>> {
386                let mut errors = ::std::vec::Vec::new();
387                #(#validation_arms)*
388                if errors.is_empty() {
389                    Ok(())
390                } else {
391                    Err(errors)
392                }
393            }
394        }
395    };
396
397    TokenStream::from(expanded)
398}