csv_schema_validator_derive/
lib.rs1extern 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
11struct FieldValidation {
13 field_name: Ident,
14 is_option: bool, validations: Vec<Validation>,
16}
17
18enum 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 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 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 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
225fn 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(); 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 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 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 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}