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    NotBlank,
26    OneOf { values: Vec<String> },
27    NotIn { values: Vec<String> },    
28}
29
30impl Validation {
31    /// Faz o parse de #[validate(...)] em uma lista de Validation
32    fn parse_validations(input: syn::parse::ParseStream) -> syn::Result<Vec<Self>> {
33        let mut validations = Vec::new();
34        let meta_items =
35            syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated(input)?;
36
37        for meta in meta_items {
38            match meta {
39                Meta::Path(path) => {
40                    if path.is_ident("required") {
41                        validations.push(Validation::Required);
42                    } else if path.is_ident("not_blank") {
43                               validations.push(Validation::NotBlank);
44                    }
45                }
46                Meta::NameValue(mnv) => {
47                    if mnv.path.is_ident("regex") {
48                        if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = mnv.value {
49                            validations.push(Validation::Regex { regex: s.value() });
50                        } else {
51                            return Err(syn::Error::new_spanned(
52                                mnv.value,
53                                "Expected string literal for `regex`",
54                            ));
55                        }
56                    } else if mnv.path.is_ident("custom") {
57                        if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = mnv.value {
58                            let path: syn::Path =
59                                syn::parse_str(&s.value()).map_err(|e| syn::Error::new_spanned(s, e))?;
60                            validations.push(Validation::Custom { path });
61                        } else {
62                            return Err(syn::Error::new_spanned(
63                                mnv.value,
64                                "Expected string literal for `custom` (e.g., custom = \"path::to::fn\")",
65                            ));
66                        }
67                    }
68                }
69                Meta::List(meta_list) => {
70                    if meta_list.path.is_ident("length") {
71                        let mut min: Option<usize> = None;
72                        let mut max: Option<usize> = None;
73
74                        let items: syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]> =
75                            meta_list.parse_args_with(
76                                syn::punctuated::Punctuated::parse_terminated,
77                            )?;
78
79                        for kv in items {
80                            if kv.path.is_ident("min") {
81                                if let Expr::Lit(ExprLit { lit: Lit::Int(i), .. }) = kv.value {
82                                    min = Some(i.base10_parse::<usize>()?);
83                                } else {
84                                    return Err(syn::Error::new_spanned(
85                                        kv.value,
86                                        "`min` for `length` must be an integer literal",
87                                    ));
88                                }
89                            } else if kv.path.is_ident("max") {
90                                if let Expr::Lit(ExprLit { lit: Lit::Int(i), .. }) = kv.value {
91                                    max = Some(i.base10_parse::<usize>()?);
92                                } else {
93                                    return Err(syn::Error::new_spanned(
94                                        kv.value,
95                                        "`max` for `length` must be an integer literal",
96                                    ));
97                                }
98                            }
99                        }
100
101                        if min.is_none() && max.is_none() {
102                            return Err(syn::Error::new_spanned(
103                                meta_list,
104                                "`length` requires at least one of `min` or `max`",
105                            ));
106                        }
107                        if let Some(mx) = max {
108                            if mx == 0 {
109                                return Err(syn::Error::new_spanned(
110                                    meta_list,
111                                    "`max` for `length` cannot be zero",
112                                ));
113                            }
114                        }
115                        if let (Some(a), Some(b)) = (min, max) {
116                            if a > b {
117                                return Err(syn::Error::new_spanned(
118                                    meta_list,
119                                    "`min` must be <= `max` for `length`",
120                                ));
121                            }
122                        }
123
124                        validations.push(Validation::Length {
125                            min: min.unwrap_or(0),
126                            max: max.unwrap_or(usize::MAX),
127                        });
128                    } else if meta_list.path.is_ident("range") {
129                        let mut min: Option<f64> = None;
130                        let mut max: Option<f64> = None;
131
132                        let items: syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]> =
133                            meta_list.parse_args_with(
134                                syn::punctuated::Punctuated::parse_terminated,
135                            )?;
136
137                        for kv in items {
138                            if kv.path.is_ident("min") {
139                                if let Expr::Lit(ExprLit { lit: Lit::Float(f), .. }) = kv.value {
140                                    min = Some(f.base10_parse::<f64>()?);
141                                } else {
142                                    return Err(syn::Error::new_spanned(
143                                        kv.value,
144                                        "`min` for `range` must be a float literal",
145                                    ));
146                                }
147                            } else if kv.path.is_ident("max") {
148                                if let Expr::Lit(ExprLit { lit: Lit::Float(f), .. }) = kv.value {
149                                    max = Some(f.base10_parse::<f64>()?);
150                                } else {
151                                    return Err(syn::Error::new_spanned(
152                                        kv.value,
153                                        "`max` for `range` must be a float literal",
154                                    ));
155                                }
156                            }
157                        }
158
159                        if min.is_none() && max.is_none() {
160                            return Err(syn::Error::new_spanned(
161                                meta_list,
162                                "`range` requires at least one of `min` or `max`",
163                            ));
164                        }
165
166                        validations.push(Validation::Range {
167                            min: min.unwrap_or(f64::NEG_INFINITY),
168                            max: max.unwrap_or(f64::INFINITY),
169                        });
170                    } else  if meta_list.path.is_ident("one_of") {
171                        // aceita lista de literais string
172                        let items: syn::punctuated::Punctuated<syn::Expr, syn::Token![,]> =
173                            meta_list.parse_args_with(
174                                syn::punctuated::Punctuated::parse_terminated
175                            )?;
176                        let mut values = Vec::new();
177                        for expr in items {
178                            if let syn::Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = expr {
179                                values.push(s.value());
180                            } else {
181                                return Err(syn::Error::new_spanned(
182                                    expr, "`one_of` only accepts string literals"
183                                ));
184                            }
185                        }
186                        if values.is_empty() {
187                            return Err(syn::Error::new_spanned(
188                                meta_list, "`one_of` requires at least one value"
189                            ));
190                        }
191                        validations.push(Validation::OneOf { values });
192                    }
193                    // not_in("x","y")
194                    else if meta_list.path.is_ident("not_in") {
195                        let items: syn::punctuated::Punctuated<syn::Expr, syn::Token![,]> =
196                            meta_list.parse_args_with(
197                                syn::punctuated::Punctuated::parse_terminated
198                            )?;
199                        let mut values = Vec::new();
200                        for expr in items {
201                            if let syn::Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = expr {
202                                values.push(s.value());
203                            } else {
204                                return Err(syn::Error::new_spanned(
205                                    expr, "`not_in` only accepts string literals"
206                                ));
207                            }
208                        }
209                        if values.is_empty() {
210                            return Err(syn::Error::new_spanned(
211                                meta_list, "`not_in` requires at least one value"
212                            ));
213                        }
214                        validations.push(Validation::NotIn { values });
215                    }
216
217                }
218            }
219        }
220
221        Ok(validations)
222    }
223}
224
225// [FIX] helper para detectar Option<T>
226fn option_inner_type(ty: &Type) -> Option<&Type> {
227    if let Type::Path(tp) = ty {
228        if let Some(seg) = tp.path.segments.last() {
229            if seg.ident == "Option" {
230                if let PathArguments::AngleBracketed(args) = &seg.arguments {
231                    if let Some(GenericArgument::Type(inner_ty)) = args.args.first() {
232                        return Some(inner_ty);
233                    }
234                }
235            }
236        }
237    }
238    None
239}
240
241#[proc_macro_derive(ValidateCsv, attributes(validate))]
242pub fn validate_csv_derive(input: TokenStream) -> TokenStream {
243    let input = parse_macro_input!(input as DeriveInput);
244    let name = &input.ident;
245
246    let fields = match &input.data {
247        Data::Struct(data) => match &data.fields {
248            Fields::Named(f) => &f.named,
249            _ => {
250                return syn::Error::new_spanned(
251                    &data.fields,
252                    "only structs with named fields are supported",
253                )
254                .to_compile_error()
255                .into();
256            }
257        },
258        _ => {
259            return syn::Error::new_spanned(&input, "only structs are supported")
260                .to_compile_error()
261                .into();
262        }
263    };
264
265    let mut field_validations = Vec::new();
266
267    for field in fields {
268        let field_name = field.ident.as_ref().unwrap().clone();
269        let is_option = option_inner_type(&field.ty).is_some(); // [FIX] capturamos se é Option<T>
270        let mut validations = Vec::new();
271
272        for attr in &field.attrs {
273            if attr.path().is_ident("validate") {
274                match attr.parse_args_with(Validation::parse_validations) {
275                    Ok(mut v) => validations.append(&mut v),
276                    Err(e) => return e.to_compile_error().into(),
277                }
278            }
279        }
280
281        if !validations.is_empty() {
282            field_validations.push(FieldValidation {
283                field_name,
284                is_option,
285                validations,
286            });
287        }
288    }
289
290    let validation_arms = field_validations.into_iter().map(|fv| {
291        let field_name_str = fv.field_name.to_string();
292        let field_name_ident = fv.field_name;
293        let fv_is_option = fv.is_option;
294
295        let checks = fv.validations.into_iter().map(|validation| match validation {
296            Validation::Required => {
297                // mantém comportamento: se None => erro
298                quote! {
299                    if (&self.#field_name_ident).is_none() {
300                        errors.push(::csv_schema_validator::ValidationError {
301                            field: #field_name_str.to_string(),
302                            message: "mandatory field".to_string(),
303                        });
304                    }
305                }
306            }
307            Validation::NotBlank => {
308                if fv_is_option {
309                    quote! {
310                        if let Some(value) = &self.#field_name_ident {
311                            if value.trim().is_empty() {
312                                errors.push(::csv_schema_validator::ValidationError {
313                                    field: #field_name_str.to_string(),
314                                    message: "must not be blank or contain only whitespace".to_string(),
315                                });
316                            }
317                        }
318                    }
319                } else {
320                    quote! {
321                        let value = &self.#field_name_ident;
322                        if value.trim().is_empty() {
323                            errors.push(::csv_schema_validator::ValidationError {
324                                field: #field_name_str.to_string(),
325                                message: "must not be blank or contain only whitespace".to_string(),
326                            });
327                        }
328                    }
329                }
330            }
331            Validation::Range { min, max } => {
332                if fv_is_option {
333                    quote! {
334                        if let Some(value) = &self.#field_name_ident {
335                            if !(#min <= *value && *value <= #max) {
336                                errors.push(::csv_schema_validator::ValidationError {
337                                    field: #field_name_str.to_string(),
338                                    message: format!("value out of expected range: {} to {}", #min, #max),
339                                });
340                            }
341                        }
342                    }
343                } else {
344                    quote! {
345                        let value = &self.#field_name_ident;
346                        if !(#min <= *value && *value <= #max) {
347                            errors.push(::csv_schema_validator::ValidationError {
348                                field: #field_name_str.to_string(),
349                                message: format!("value out of expected range: {} to {}", #min, #max),
350                            });
351                        }
352                    }
353                }
354            }
355            Validation::Length { min, max } => {
356                // [FIX] trata Option<String>: valida apenas quando Some(s)
357                if fv_is_option {
358                    quote! {
359                        if let Some(value) = &self.#field_name_ident {
360                            let len = value.len();
361                            if len < #min || len > #max {
362                                errors.push(::csv_schema_validator::ValidationError {
363                                    field: #field_name_str.to_string(),
364                                    message: format!("length out of expected range: {} to {}", #min, #max),
365                                });
366                            }
367                        }
368                    }
369                } else {
370                    quote! {
371                        let value = &self.#field_name_ident;
372                        let len = value.len();
373                        if len < #min || len > #max {
374                            errors.push(::csv_schema_validator::ValidationError {
375                                field: #field_name_str.to_string(),
376                                message: format!("length out of expected range: {} to {}", #min, #max),
377                            });
378                        }
379                    }
380                }
381            }
382            Validation::Regex { regex } => {
383                // [FIX] reutiliza corpo mas injeta binding 'value' diferente para Option<String>
384                let regex_body = quote! {
385                    use ::csv_schema_validator::__private::once_cell::sync::Lazy;
386                    use ::csv_schema_validator::__private::regex;
387                    static RE: Lazy<Result<regex::Regex, regex::Error>> = Lazy::new(|| regex::Regex::new(#regex));
388
389                    match RE.as_ref() {
390                        Ok(compiled_regex) => {
391                            if !compiled_regex.is_match(value) {
392                                errors.push(::csv_schema_validator::ValidationError {
393                                    field: #field_name_str.to_string(),
394                                    message: "does not match the expected pattern".to_string(),
395                                });
396                            }
397                        }
398                        Err(e) => {
399                            errors.push(::csv_schema_validator::ValidationError {
400                                field: #field_name_str.to_string(),
401                                message: format!("invalid regex '{}': {}", #regex, e),
402                            });
403                        }
404                    }
405                };
406
407                if fv_is_option {
408                    quote! {
409                        if let Some(value) = &self.#field_name_ident {
410                            #regex_body
411                        }
412                    }
413                } else {
414                    quote! {
415                        let value = &self.#field_name_ident;
416                        #regex_body
417                    }
418                }
419            }
420            Validation::OneOf { values } => {
421                let arr = values.clone();
422                if fv_is_option {
423                    quote! {
424                        if let Some(value) = &self.#field_name_ident {
425                            const __ALLOWED: &[&str] = &[#(#arr),*];
426                            if !__ALLOWED.contains(&value.as_str()) {
427                                errors.push(::csv_schema_validator::ValidationError {
428                                    field: #field_name_str.to_string(),
429                                    message: format!("invalid value"),
430                                });
431                            }
432                        }
433                    }
434                } else {
435                    quote! {
436                        let value = &self.#field_name_ident;
437                        const __ALLOWED: &[&str] = &[#(#arr),*];
438                        if !__ALLOWED.contains(&value.as_str()) {
439                            errors.push(::csv_schema_validator::ValidationError {
440                                field: #field_name_str.to_string(),
441                                message: format!("invalid value"),
442                            });
443                        }
444                    }
445                }
446            }
447
448            Validation::NotIn { values } => {
449                let arr = values.clone();
450                if fv_is_option {
451                    quote! {
452                        if let Some(value) = &self.#field_name_ident {
453                            const __FORBIDDEN: &[&str] = &[#(#arr),*];
454                            if __FORBIDDEN.contains(&value.as_str()) {
455                                errors.push(::csv_schema_validator::ValidationError {
456                                    field: #field_name_str.to_string(),
457                                    message: format!("value not allowed"),
458                                });
459                            }
460                        }
461                    }
462                } else {
463                    quote! {
464                        let value = &self.#field_name_ident;
465                        const __FORBIDDEN: &[&str] = &[#(#arr),*];
466                        if __FORBIDDEN.contains(&value.as_str()) {
467                            errors.push(::csv_schema_validator::ValidationError {
468                                field: #field_name_str.to_string(),
469                                message: format!("value not allowed"),
470                            });
471                        }
472                    }
473                }
474            }            
475            Validation::Custom { path } => {
476                if fv_is_option {
477                    quote! {
478                        if let Some(value) = &self.#field_name_ident {
479                            match #path(value) {
480                                Err(err) => {
481                                    errors.push(::csv_schema_validator::ValidationError {
482                                        field: #field_name_str.to_string(),
483                                        message: format!("{}", err),
484                                    });
485                                }
486                                Ok(()) => {}
487                            }
488                        }
489                    }
490                } else {
491                    quote! {
492                        match #path(&self.#field_name_ident) {
493                            Err(err) => {
494                                errors.push(::csv_schema_validator::ValidationError {
495                                    field: #field_name_str.to_string(),
496                                    message: format!("{}", err),
497                                });
498                            }
499                            Ok(()) => {}
500                        }
501                    }
502                }
503            }
504        });
505
506        quote! {
507            #(#checks)*
508        }
509    });
510
511    let expanded = quote! {
512        impl #name {
513            pub fn validate_csv(&self) -> ::core::result::Result<(), ::std::vec::Vec<::csv_schema_validator::ValidationError>> {
514                let mut errors = ::std::vec::Vec::new();
515                #(#validation_arms)*
516                if errors.is_empty() {
517                    Ok(())
518                } else {
519                    Err(errors)
520                }
521            }
522        }
523    };
524
525    TokenStream::from(expanded)
526}