From a4f284e9b24a616e44ac378eee03d0be5003bcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Fri, 21 Nov 2025 14:41:26 +0100 Subject: [PATCH] refactor: separate parsing and token generation --- Cargo.toml | 7 + example/Cargo.toml | 3 + packages/fortifier-macros/Cargo.toml | 3 + packages/fortifier-macros/src/lib.rs | 11 +- packages/fortifier-macros/src/validate.rs | 36 ++- .../fortifier-macros/src/validate/enum.rs | 17 +- .../fortifier-macros/src/validate/field.rs | 68 +++++ .../fortifier-macros/src/validate/struct.rs | 257 +++++++++++------- .../fortifier-macros/src/validate/union.rs | 17 +- .../fortifier-macros/src/validations/email.rs | 19 +- .../src/validations/length.rs | 93 +++---- packages/fortifier/Cargo.toml | 3 + packages/fortifier/src/lib.rs | 4 + packages/fortifier/src/validate.rs | 6 + packages/fortifier/src/validations/email.rs | 5 + packages/fortifier/src/validations/length.rs | 31 ++- 16 files changed, 406 insertions(+), 174 deletions(-) create mode 100644 packages/fortifier-macros/src/validate/field.rs diff --git a/Cargo.toml b/Cargo.toml index de53bea..7ca6768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,10 @@ version = "0.0.1" [workspace.dependencies] fortifier = { path = "./packages/fortifier", version = "0.0.1" } fortifier-macros = { path = "./packages/fortifier-macros", version = "0.0.1" } + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +panic = "deny" +unwrap_used = "deny" diff --git a/example/Cargo.toml b/example/Cargo.toml index 3a804b1..a33ae5a 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -10,3 +10,6 @@ version.workspace = true [dependencies] fortifier.workspace = true + +[lints] +workspace = true diff --git a/packages/fortifier-macros/Cargo.toml b/packages/fortifier-macros/Cargo.toml index e90d686..560c46a 100644 --- a/packages/fortifier-macros/Cargo.toml +++ b/packages/fortifier-macros/Cargo.toml @@ -20,3 +20,6 @@ syn = "2.0.110" [dev-dependencies] fortifier.workspace = true trybuild = "1.0.114" + +[lints] +workspace = true diff --git a/packages/fortifier-macros/src/lib.rs b/packages/fortifier-macros/src/lib.rs index 200ca3e..263faec 100644 --- a/packages/fortifier-macros/src/lib.rs +++ b/packages/fortifier-macros/src/lib.rs @@ -1,16 +1,23 @@ +#![warn(missing_docs)] + +//! Fortifier macros. + mod validate; mod validations; use proc_macro::TokenStream; +use quote::ToTokens; use syn::{DeriveInput, Error, parse_macro_input}; -use crate::validate::validate_tokens; +use crate::validate::Validate; +/// Validate derive macro. #[proc_macro_derive(Validate, attributes(validate))] pub fn derive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - validate_tokens(input) + Validate::parse(input) + .map(|validate| validate.to_token_stream()) .unwrap_or_else(Error::into_compile_error) .into() } diff --git a/packages/fortifier-macros/src/validate.rs b/packages/fortifier-macros/src/validate.rs index 9012709..5272580 100644 --- a/packages/fortifier-macros/src/validate.rs +++ b/packages/fortifier-macros/src/validate.rs @@ -1,22 +1,36 @@ mod r#enum; +mod field; mod r#struct; mod r#union; use proc_macro2::TokenStream; -use quote::format_ident; +use quote::ToTokens; use syn::{Data, DeriveInput, Result}; -use crate::validate::{ - r#enum::validate_enum, r#struct::validate_struct_tokens, union::validate_union, -}; +use crate::validate::{r#enum::ValidateEnum, r#struct::ValidateStruct, union::ValidateUnion}; -pub fn validate_tokens(input: DeriveInput) -> Result { - let ident = input.ident; - let error_ident = format_ident!("{ident}ValidationError"); +pub enum Validate { + Struct(ValidateStruct), + Enum(ValidateEnum), + Union(ValidateUnion), +} + +impl Validate { + pub fn parse(input: DeriveInput) -> Result { + Ok(match &input.data { + Data::Struct(data) => Self::Struct(ValidateStruct::parse(&input, data)?), + Data::Enum(data) => Self::Enum(ValidateEnum::parse(&input, data)?), + Data::Union(data) => Self::Union(ValidateUnion::parse(&input, data)?), + }) + } +} - match input.data { - Data::Struct(data) => validate_struct_tokens(ident, error_ident, data), - Data::Enum(data) => validate_enum(ident, error_ident, data), - Data::Union(data) => validate_union(ident, error_ident, data), +impl ToTokens for Validate { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Validate::Struct(r#struct) => r#struct.to_tokens(tokens), + Validate::Enum(r#enum) => r#enum.to_tokens(tokens), + Validate::Union(r#union) => r#union.to_tokens(tokens), + } } } diff --git a/packages/fortifier-macros/src/validate/enum.rs b/packages/fortifier-macros/src/validate/enum.rs index 908c36a..3d549f5 100644 --- a/packages/fortifier-macros/src/validate/enum.rs +++ b/packages/fortifier-macros/src/validate/enum.rs @@ -1,6 +1,17 @@ use proc_macro2::TokenStream; -use syn::{DataEnum, Ident, Result}; +use quote::ToTokens; +use syn::{DataEnum, DeriveInput, Result}; -pub fn validate_enum(_ident: Ident, _error_ident: Ident, _data: DataEnum) -> Result { - todo!("enum") +pub struct ValidateEnum {} + +impl ValidateEnum { + pub fn parse(_input: &DeriveInput, _data: &DataEnum) -> Result { + todo!("enum") + } +} + +impl ToTokens for ValidateEnum { + fn to_tokens(&self, _tokens: &mut TokenStream) { + // TODO + } } diff --git a/packages/fortifier-macros/src/validate/field.rs b/packages/fortifier-macros/src/validate/field.rs new file mode 100644 index 0000000..f020073 --- /dev/null +++ b/packages/fortifier-macros/src/validate/field.rs @@ -0,0 +1,68 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Field, Ident, Result}; + +use crate::validations::{Email, Length}; + +pub struct ValidateField { + ident: Ident, + // TODO: Consider using a trait for validations. + email: Option, + length: Option, +} + +impl ValidateField { + pub fn parse(ident: Ident, field: &Field) -> Result { + let mut result = Self { + ident, + email: None, + length: None, + }; + + for attr in &field.attrs { + if attr.path().is_ident("validate") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("email") { + result.email = Some(Email::parse(&meta)?); + + Ok(()) + } else if meta.path.is_ident("length") { + result.length = Some(Length::parse(&meta)?); + + Ok(()) + } else { + Err(meta.error("unknown validate parameter")) + } + })?; + } + } + + Ok(result) + } + + pub fn error_type(&self) -> TokenStream { + // TODO: Merge error types + + if self.email.is_some() { + quote!(EmailError) + } else if self.length.is_some() { + quote!(LengthError) + } else { + quote!(()) + } + } + + pub fn sync_validations(&self) -> Vec { + let email = self.email.as_ref().map(|email| email.tokens(&self.ident)); + let length = self + .length + .as_ref() + .map(|length| length.tokens(&self.ident)); + + [email, length].into_iter().flatten().collect() + } + + pub fn async_validations(&self) -> Vec { + vec![] + } +} diff --git a/packages/fortifier-macros/src/validate/struct.rs b/packages/fortifier-macros/src/validate/struct.rs index b3c2e57..e1f2097 100644 --- a/packages/fortifier-macros/src/validate/struct.rs +++ b/packages/fortifier-macros/src/validate/struct.rs @@ -1,130 +1,205 @@ +use std::collections::HashMap; + use convert_case::{Case, Casing}; use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{DataStruct, Field, Fields, Ident, Result}; +use quote::{ToTokens, TokenStreamExt, format_ident, quote}; +use syn::{DataStruct, DeriveInput, Fields, FieldsNamed, FieldsUnnamed, Ident, Result}; -use crate::validations::{email_tokens, length_tokens, parse_email, parse_length}; +use crate::validate::field::ValidateField; -pub fn validate_struct_tokens( - ident: Ident, - error_ident: Ident, - data: DataStruct, -) -> Result { - match data.fields { - Fields::Named(fields_named) => { - validate_named_struct_tokens(ident, error_ident, fields_named.named.into_iter()) +pub enum ValidateStruct { + Named(ValidateNamedStruct), + Unnamed(ValidateUnnamedStruct), + Unit(ValidateUnitStruct), +} + +impl ValidateStruct { + pub fn parse(input: &DeriveInput, data: &DataStruct) -> Result { + Ok(match &data.fields { + Fields::Named(fields) => Self::Named(ValidateNamedStruct::parse(input, data, fields)?), + Fields::Unnamed(fields) => { + Self::Unnamed(ValidateUnnamedStruct::parse(input, data, fields)?) + } + Fields::Unit => Self::Unit(ValidateUnitStruct::parse(input)?), + }) + } +} + +impl ToTokens for ValidateStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + ValidateStruct::Named(named) => named.to_tokens(tokens), + ValidateStruct::Unnamed(unnamed) => unnamed.to_tokens(tokens), + ValidateStruct::Unit(unit) => unit.to_tokens(tokens), } - Fields::Unnamed(_fields_unnamed) => todo!("fields unamed"), - Fields::Unit => todo!("fields unit"), } } -fn validate_named_struct_tokens( +pub struct ValidateNamedStruct { ident: Ident, error_ident: Ident, - fields: impl Iterator, -) -> Result { - let mut field_names = vec![]; - let mut field_types = vec![]; - let mut sync_validations = vec![]; - // let async_validations = vec![]; - - for field in fields { - let Some(field_ident) = field.ident else { - continue; - }; - - let field_error_ident = - format_ident!("{}", &field_ident.to_string().to_case(Case::UpperCamel)); - - for attr in field.attrs { - if attr.path().is_ident("validate") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("email") { - let email = parse_email(&meta)?; + fields: HashMap, +} - field_names.push(field_error_ident.clone()); - field_types.push(quote!(::fortifier::EmailError)); +impl ValidateNamedStruct { + fn parse(input: &DeriveInput, _data: &DataStruct, fields: &FieldsNamed) -> Result { + let mut result = Self { + ident: input.ident.clone(), + error_ident: format_ident!("{}ValidationError", input.ident), + fields: HashMap::default(), + }; - sync_validations.push(email_tokens( - email, - &error_ident, - &field_ident, - &field_error_ident, - )); + for field in &fields.named { + let Some(field_ident) = &field.ident else { + continue; + }; - Ok(()) - } else if meta.path.is_ident("length") { - let length = parse_length(&meta)?; + result.fields.insert( + field_ident.clone(), + ValidateField::parse(field_ident.clone(), field)?, + ); + } - field_names.push(field_error_ident.clone()); - field_types.push(quote!(::fortifier::LengthError)); + Ok(result) + } +} - sync_validations.push(length_tokens( - length, - &error_ident, - &field_ident, - &field_error_ident, - )); +impl ToTokens for ValidateNamedStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + let error_ident = &self.error_ident; + let mut error_field_idents = vec![]; + let mut error_field_types = vec![]; + let mut sync_validations = vec![]; + let mut async_validations = vec![]; + + for (field_ident, field) in &self.fields { + let field_error_ident = + format_ident!("{}", &field_ident.to_string().to_case(Case::UpperCamel)); + + error_field_idents.push(field_error_ident.clone()); + error_field_types.push(field.error_type()); + + for validation in field.sync_validations() { + sync_validations.push(quote! { + if let Err(err) = #validation { + errors.push(#error_ident::#field_error_ident(err)); + } + }); + } - Ok(()) - } else { - Err(meta.error("unknown validate parameter")) + for validation in field.async_validations() { + async_validations.push(quote! { + if let Err(err) = #validation { + errors.push(#error_ident::#field_error_ident(err)); } - })?; + }); } } - } - Ok(quote! { - use fortifier::*; + tokens.append_all(quote! { + use fortifier::*; - #[derive(Debug)] - enum #error_ident { - #( #field_names(#field_types) ),* - } + #[derive(Debug)] + enum #error_ident { + #( #error_field_idents(#error_field_types) ),* + } - impl ::std::fmt::Display for #error_ident { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - write!(f, "{self:#?}") + impl ::std::fmt::Display for #error_ident { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + write!(f, "{self:#?}") + } } - } - impl ::std::error::Error for #error_ident {} + impl ::std::error::Error for #error_ident {} - impl Validate for #ident { - type Error = #error_ident; + impl Validate for #ident { + type Error = #error_ident; - fn validate_sync(&self) -> Result<(), ValidationErrors> { - use ::fortifier::*; + fn validate_sync(&self) -> Result<(), ValidationErrors> { + let mut errors = vec![]; - let mut errors = vec![]; + #(#sync_validations)* - #(#sync_validations)* + if !errors.is_empty() { + Err(errors.into()) + } else { + Ok(()) + } + } - if !errors.is_empty() { - Err(errors.into()) - } else { - Ok(()) + fn validate_async(&self) -> ::std::pin::Pin>>>> { + Box::pin(async { + let mut errors = vec![]; + + #(#async_validations)* + + if !errors.is_empty() { + Err(errors.into()) + } else { + Ok(()) + } + }) } } + }) + } +} - fn validate_async(&self) -> ::std::pin::Pin>>>> { - use ::fortifier::*; +pub struct ValidateUnnamedStruct { + #[allow(unused)] + ident: Ident, + #[allow(unused)] + fields: Vec, +} - Box::pin(async { +impl ValidateUnnamedStruct { + fn parse(input: &DeriveInput, _data: &DataStruct, _fields: &FieldsUnnamed) -> Result { + let result = Self { + ident: input.ident.clone(), + fields: Vec::default(), + }; - let mut errors = vec![]; + Ok(result) + } +} - // #(#async_validations)* +impl ToTokens for ValidateUnnamedStruct { + fn to_tokens(&self, _tokens: &mut TokenStream) { + // TODO + } +} - if !errors.is_empty() { - Err(errors.into()) - } else { +pub struct ValidateUnitStruct { + ident: Ident, +} + +impl ValidateUnitStruct { + fn parse(input: &DeriveInput) -> Result { + Ok(Self { + ident: input.ident.clone(), + }) + } +} + +impl ToTokens for ValidateUnitStruct { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.ident; + + tokens.append_all(quote! { + impl Validate for #ident { + type Error = Infallible; + + fn validate_sync(&self) -> Result<(), ValidationErrors> { + Ok(()) + } + + fn validate_async(&self) -> ::std::pin::Pin>>>> { + Box::pin(async { Ok(()) - } - }) + }) + } } - } - }) + }); + } } diff --git a/packages/fortifier-macros/src/validate/union.rs b/packages/fortifier-macros/src/validate/union.rs index 53ef7c1..e7e4d35 100644 --- a/packages/fortifier-macros/src/validate/union.rs +++ b/packages/fortifier-macros/src/validate/union.rs @@ -1,6 +1,17 @@ use proc_macro2::TokenStream; -use syn::{DataUnion, Ident, Result}; +use quote::ToTokens; +use syn::{DataUnion, DeriveInput, Result}; -pub fn validate_union(_ident: Ident, _error_ident: Ident, _data: DataUnion) -> Result { - todo!("union") +pub struct ValidateUnion {} + +impl ValidateUnion { + pub fn parse(_input: &DeriveInput, _data: &DataUnion) -> Result { + todo!("union") + } +} + +impl ToTokens for ValidateUnion { + fn to_tokens(&self, _tokens: &mut TokenStream) { + // TODO + } } diff --git a/packages/fortifier-macros/src/validations/email.rs b/packages/fortifier-macros/src/validations/email.rs index 09369f9..c475a3e 100644 --- a/packages/fortifier-macros/src/validations/email.rs +++ b/packages/fortifier-macros/src/validations/email.rs @@ -5,19 +5,14 @@ use syn::{Ident, Result, meta::ParseNestedMeta}; #[derive(Default)] pub struct Email {} -pub fn parse_email(_meta: &ParseNestedMeta<'_>) -> Result { - Ok(Email::default()) -} +impl Email { + pub fn parse(_meta: &ParseNestedMeta<'_>) -> Result { + Ok(Email::default()) + } -pub fn email_tokens( - _email: Email, - error_ident: &Ident, - field_ident: &Ident, - field_error_ident: &Ident, -) -> TokenStream { - quote! { - if let Err(err) = self.#field_ident.validate_email() { - errors.push(#error_ident::#field_error_ident(err)); + pub fn tokens(&self, ident: &Ident) -> TokenStream { + quote! { + self.#ident.validate_email() } } } diff --git a/packages/fortifier-macros/src/validations/length.rs b/packages/fortifier-macros/src/validations/length.rs index 9632224..38ee0d0 100644 --- a/packages/fortifier-macros/src/validations/length.rs +++ b/packages/fortifier-macros/src/validations/length.rs @@ -9,58 +9,53 @@ pub struct Length { pub max: Option, } -pub fn parse_length(meta: &ParseNestedMeta<'_>) -> Result { - let mut length = Length::default(); - - meta.parse_nested_meta(|meta| { - if meta.path.is_ident("equal") { - let expr: Expr = meta.value()?.parse()?; - length.equal = Some(expr); - - Ok(()) - } else if meta.path.is_ident("min") { - let expr: Expr = meta.value()?.parse()?; - length.min = Some(expr); - - Ok(()) - } else if meta.path.is_ident("max") { - let expr: Expr = meta.value()?.parse()?; - length.max = Some(expr); +impl Length { + pub fn parse(meta: &ParseNestedMeta<'_>) -> Result { + let mut result = Length::default(); + + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("equal") { + let expr: Expr = meta.value()?.parse()?; + result.equal = Some(expr); + + Ok(()) + } else if meta.path.is_ident("min") { + let expr: Expr = meta.value()?.parse()?; + result.min = Some(expr); + + Ok(()) + } else if meta.path.is_ident("max") { + let expr: Expr = meta.value()?.parse()?; + result.max = Some(expr); + + Ok(()) + } else { + Err(meta.error("unknown length parameter")) + } + })?; + + Ok(result) + } - Ok(()) + pub fn tokens(&self, ident: &Ident) -> TokenStream { + let equal = if let Some(equal) = &self.equal { + quote!(Some(#equal)) } else { - Err(meta.error("unknown length parameter")) - } - })?; - - Ok(length) -} - -pub fn length_tokens( - length: Length, - error_ident: &Ident, - field_ident: &Ident, - field_error_ident: &Ident, -) -> TokenStream { - let equal = if let Some(equal) = length.equal { - quote!(Some(#equal)) - } else { - quote!(None) - }; - let min = if let Some(min) = length.min { - quote!(Some(#min)) - } else { - quote!(None) - }; - let max = if let Some(max) = length.max { - quote!(Some(#max)) - } else { - quote!(None) - }; + quote!(None) + }; + let min = if let Some(min) = &self.min { + quote!(Some(#min)) + } else { + quote!(None) + }; + let max = if let Some(max) = &self.max { + quote!(Some(#max)) + } else { + quote!(None) + }; - quote! { - if let Err(err) = self.#field_ident.validate_length(#equal, #min, #max) { - errors.push(#error_ident::#field_error_ident(err)); + quote! { + self.#ident.validate_length(#equal, #min, #max) } } } diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index c6d8e72..7eeb995 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -16,3 +16,6 @@ macros = ["dep:fortifier-macros"] [dependencies] fortifier-macros = { workspace = true, optional = true } indexmap = { version = "2.12.0", optional = true } + +[lints] +workspace = true diff --git a/packages/fortifier/src/lib.rs b/packages/fortifier/src/lib.rs index f0b25f9..89b06b0 100644 --- a/packages/fortifier/src/lib.rs +++ b/packages/fortifier/src/lib.rs @@ -1,3 +1,7 @@ +#![warn(missing_docs)] + +//! Fortifier. + mod validate; mod validations; diff --git a/packages/fortifier/src/validate.rs b/packages/fortifier/src/validate.rs index aafe8ba..1ebaa41 100644 --- a/packages/fortifier/src/validate.rs +++ b/packages/fortifier/src/validate.rs @@ -4,6 +4,7 @@ use std::{ pin::Pin, }; +/// Validation errors. #[derive(Debug)] pub struct ValidationErrors(Vec); @@ -21,9 +22,12 @@ impl From> for ValidationErrors { } } +/// Validate a schema. pub trait Validate { + /// Validation error. type Error: Error; + /// Validate schema using all validators. fn validate( &self, ) -> Pin>> + Send>> @@ -36,8 +40,10 @@ pub trait Validate { }) } + /// Validate schema using only synchronous validators. fn validate_sync(&self) -> Result<(), ValidationErrors>; + /// Validate schema using only asynchronous validators. fn validate_async( &self, ) -> Pin>> + Send>>; diff --git a/packages/fortifier/src/validations/email.rs b/packages/fortifier/src/validations/email.rs index a1c0d1d..079860f 100644 --- a/packages/fortifier/src/validations/email.rs +++ b/packages/fortifier/src/validations/email.rs @@ -5,14 +5,19 @@ use std::{ sync::Arc, }; +/// Email validation error. #[derive(Debug)] pub enum EmailError { + /// Invalid email address. Invalid, } +/// Validate an email address. pub trait ValidateEmail { + /// The email address. fn email(&self) -> Option>; + /// Validate email address. fn validate_email(&self) -> Result<(), EmailError> { let Some(email) = self.email() else { return Ok(()); diff --git a/packages/fortifier/src/validations/length.rs b/packages/fortifier/src/validations/length.rs index eb75251..a860472 100644 --- a/packages/fortifier/src/validations/length.rs +++ b/packages/fortifier/src/validations/length.rs @@ -9,19 +9,44 @@ use std::{ #[cfg(feature = "indexmap")] use indexmap::{IndexMap, IndexSet}; +/// Length validation error. #[derive(Debug)] pub enum LengthError { - Equal { equal: T, length: T }, - Min { min: T, length: T }, - Max { max: T, length: T }, + /// Length is not equal to the required length. + Equal { + /// The required length. + equal: T, + + /// The actual length. + length: T, + }, + /// Length is less than the minimum length. + Min { + /// The minimum length. + min: T, + + /// The actual length. + length: T, + }, + /// Length is more than the maximum length. + Max { + /// The maximum length. + max: T, + + /// The length. + length: T, + }, } +/// Validate a length. pub trait ValidateLength where T: PartialEq + PartialOrd, { + /// The length. fn length(&self) -> Option; + /// Validate length. fn validate_length( &self, equal: Option,