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}
26
27impl Validation {
28 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
174fn 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(); 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 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 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 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 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 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}