diff --git a/README.md b/README.md index e0c92a9..6cc95a4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # substruct + ![[crates.io](https://crates.io/crate/substruct)](https://img.shields.io/crates/v/substruct) ![[license](https://crates.io/crate/substruct)](https://img.shields.io/crates/l/substruct) ![[docs.rs](https://docs.rs/substruct)](https://img.shields.io/docsrs/substruct) @@ -8,6 +9,7 @@ Substruct is a proc-macro wich allows you to easily declare strucs which are subsets of another struct. ## Simple Example + A basic use of substruct looks like this ```rust @@ -27,6 +29,7 @@ pub struct QueryParams { ``` which expands out to produce + ```rust #[derive(Clone, Debug, Eq, PartialEq)] pub struct QueryParams { @@ -43,6 +46,7 @@ pub struct LimitedQueryParams { ``` ## Complex Example + Substruct also supports copying attributes or adding attributes specific to a subset of the child structs. @@ -85,11 +89,130 @@ pub struct QueryParams { } ``` +## Field Type Transformations + +Sometimes you may want a substruct to have a different type for a field than +the parent struct. For example, you might want an optional field in the parent +to be required in the substruct. This can be achieved by specifying transformations +inline with the struct name: + +```rust +use substruct::substruct; + +#[substruct(RequiredParams)] +#[derive(Clone, Debug)] +pub struct OptionalParams { + #[substruct(RequiredParams(unwrap))] + pub name: Option, + + #[substruct(RequiredParams)] + pub limit: usize, + + pub description: Option, +} +``` + +This generates: + +```rust,ignore +#[derive(Clone, Debug)] +pub struct RequiredParams { + pub name: String, // Note: no longer Option + pub limit: usize, +} + +// Error type for failed conversions +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OptionalParamsConversionError { + MissingRequiredField(&'static str), +} + +// TryFrom implementation instead of From +impl TryFrom for RequiredParams { + type Error = OptionalParamsConversionError; + + fn try_from(value: OptionalParams) -> Result { + // Implementation that unwraps Option fields + } +} + +// Method to convert back to parent struct +impl RequiredParams { + pub fn into_optional_params(self, description: Option) -> OptionalParams { + // Implementation + } +} +``` + +Another example using `try_into` to convert between numeric types: + +```rust +use substruct::substruct; + +#[substruct(ConvertedParams)] +#[derive(Clone, Debug)] +pub struct OriginalParams { + #[substruct(ConvertedParams(try_into = u64))] + pub id: u32, + + #[substruct(ConvertedParams)] + pub limit: usize, + + pub description: Option, +} +``` + +This generates: + +```rust,ignore +#[derive(Clone, Debug)] +pub struct ConvertedParams { + pub id: u64, // Note: converted from u32 to u64 + pub limit: usize, +} + +// TryFrom implementation for type conversion +impl TryFrom for ConvertedParams { + type Error = OriginalParamsConversionError; + + fn try_from(value: OriginalParams) -> Result { + Ok(Self { + id: value.id.try_into().map_err(|_| + OriginalParamsConversionError::ConversionFailed("id"))?, + limit: value.limit, + }) + } +} + +// Method to convert back using reverse conversion +impl ConvertedParams { + pub fn into_original_params(self, description: Option) -> OriginalParams { + OriginalParams { + id: self.id.try_into().expect("reverse conversion should not fail"), + limit: self.limit, + description, + } + } +} +``` + +When field transformations are used: + +- A `TryFrom` implementation is generated instead of `From` +- A conversion error type `{ParentStruct}ConversionError` is created with variants for different failure types +- An `into_{parent_struct}` method converts back to the parent + +Supported transformations are specified inline with the struct name: + +- `StructName(unwrap)`: Transforms `Option` to `T` in the substruct +- `StructName(try_into = TargetType)`: Transforms the field type using `TryInto` + ## Limitations + Substruct supports generics but will fail if the generic parameters are not used by all of the child structs. - # See Also + - The [subenum](https://crates.io/crates/subenum) crate offers the same thing - for enums. \ No newline at end of file + for enums. diff --git a/src/expr.rs b/src/expr.rs index d608bf8..c907a90 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -3,8 +3,10 @@ use quote::ToTokens; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; +#[derive(Clone)] pub(crate) enum Expr { Ident(syn::Ident), + IdentWithTransform(IdentWithTransformExpr), Not(NotExpr), All(AllExpr), Any(AnyExpr), @@ -14,29 +16,44 @@ impl Expr { pub fn evaluate(&self, ident: &syn::Ident) -> bool { match self { Self::Ident(lit) => ident == lit, + Self::IdentWithTransform(e) => e.evaluate(ident), Self::Not(e) => e.evaluate(ident), Self::Any(e) => e.evaluate(ident), Self::All(e) => e.evaluate(ident), } } + + pub fn get_transform(&self, ident: &syn::Ident) -> Option<&TransformType> { + match self { + Self::Ident(_) => None, + Self::IdentWithTransform(e) => e.get_transform(ident), + Self::Not(e) => e.get_transform(ident), + Self::Any(e) => e.get_transform(ident), + Self::All(e) => e.get_transform(ident), + } + } } impl Parse for Expr { fn parse(input: ParseStream) -> syn::Result { - if !input.peek2(syn::token::Paren) { - return Ok(Self::Ident(input.parse()?)); - } - - let ident: syn::Ident = input.fork().parse()?; - - match () { - _ if ident == "not" => input.parse().map(Self::Not), - _ if ident == "any" => input.parse().map(Self::Any), - _ if ident == "all" => input.parse().map(Self::All), - _ => Err(syn::Error::new( - ident.span(), - format!("unexpected operator `{ident}`, expected `not`, `any`, or `all`"), - )), + // If an identifier is followed by parentheses then it may be an operator + // like `not(...)` / `any(...)` / `all(...)` or a transform spec like + // `Name(unwrap)` / `Name(try_into = Type)`. Inspect without consuming + // by using a fork and then dispatch accordingly. + if input.peek(syn::Ident) && input.peek2(syn::token::Paren) { + let ident: syn::Ident = input.fork().parse()?; + match ident.to_string().as_str() { + "not" => input.parse().map(Self::Not), + "any" => input.parse().map(Self::Any), + "all" => input.parse().map(Self::All), + _ => { + // Parse as IdentWithTransform for struct names with transforms like A(unwrap) + input.parse().map(Self::IdentWithTransform) + } + } + } else { + // Plain identifier (no parentheses following) is just an Ident + Ok(Self::Ident(input.parse()?)) } } } @@ -45,6 +62,7 @@ impl ToTokens for Expr { fn to_tokens(&self, tokens: &mut TokenStream) { match self { Self::Ident(ident) => ident.to_tokens(tokens), + Self::IdentWithTransform(e) => e.to_tokens(tokens), Self::Not(e) => e.to_tokens(tokens), Self::All(e) => e.to_tokens(tokens), Self::Any(e) => e.to_tokens(tokens), @@ -52,16 +70,99 @@ impl ToTokens for Expr { } } +#[derive(Clone)] +pub(crate) struct IdentWithTransformExpr { + pub(crate) ident: syn::Ident, + pub(crate) paren: syn::token::Paren, + pub(crate) transform: TransformType, +} + +#[derive(Clone)] +pub(crate) enum TransformType { + Unwrap, + TryInto(Box), +} + +impl IdentWithTransformExpr { + pub fn evaluate(&self, ident: &syn::Ident) -> bool { + self.ident == *ident + } + + pub fn get_transform(&self, ident: &syn::Ident) -> Option<&TransformType> { + if self.ident == *ident { + Some(&self.transform) + } else { + None + } + } +} + +impl Parse for IdentWithTransformExpr { + fn parse(input: ParseStream) -> syn::Result { + let content; + let ident = input.parse()?; + let paren = syn::parenthesized!(content in input); + + let transform = if content.peek(syn::Ident) { + let transform_ident: syn::Ident = content.parse()?; + + if transform_ident == "unwrap" { + TransformType::Unwrap + } else if transform_ident == "try_into" { + // Parse "try_into = Type" + content.parse::()?; + let target_type: syn::Type = content.parse()?; + TransformType::TryInto(Box::new(target_type)) + } else { + return Err(syn::Error::new_spanned( + &transform_ident, + "unknown transformation type, expected 'unwrap' or 'try_into'", + )); + } + } else { + return Err(syn::Error::new( + content.span(), + "expected transformation type ('unwrap' or 'try_into = Type')", + )); + }; + + Ok(Self { + ident, + paren, + transform, + }) + } +} + +impl ToTokens for IdentWithTransformExpr { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.ident.to_tokens(tokens); + self.paren.surround(tokens, |tokens| match &self.transform { + TransformType::Unwrap => { + tokens.extend(quote::quote! { unwrap }); + } + TransformType::TryInto(ty) => { + tokens.extend(quote::quote! { try_into = #ty }); + } + }); + } +} + +#[derive(Clone)] pub(crate) struct NotExpr { - pub ident: syn::Ident, - pub paren: syn::token::Paren, - pub expr: Box, + pub(crate) ident: syn::Ident, + pub(crate) paren: syn::token::Paren, + pub(crate) expr: Box, } impl NotExpr { pub fn evaluate(&self, ident: &syn::Ident) -> bool { !self.expr.evaluate(ident) } + + pub fn get_transform(&self, ident: &syn::Ident) -> Option<&TransformType> { + self.expr.get_transform(ident) + } } impl Parse for NotExpr { @@ -84,16 +185,21 @@ impl ToTokens for NotExpr { } } +#[derive(Clone)] pub(crate) struct AnyExpr { - pub ident: syn::Ident, - pub paren: syn::token::Paren, - pub exprs: Punctuated, + pub(crate) ident: syn::Ident, + pub(crate) paren: syn::token::Paren, + pub(crate) exprs: Punctuated, } impl AnyExpr { pub fn evaluate(&self, ident: &syn::Ident) -> bool { self.exprs.iter().any(|e| e.evaluate(ident)) } + + pub fn get_transform(&self, ident: &syn::Ident) -> Option<&TransformType> { + self.exprs.iter().find_map(|e| e.get_transform(ident)) + } } impl Parse for AnyExpr { @@ -124,16 +230,21 @@ impl ToTokens for AnyExpr { } } +#[derive(Clone)] pub(crate) struct AllExpr { - pub ident: syn::Ident, - pub paren: syn::token::Paren, - pub exprs: Punctuated, + pub(crate) ident: syn::Ident, + pub(crate) paren: syn::token::Paren, + pub(crate) exprs: Punctuated, } impl AllExpr { pub fn evaluate(&self, ident: &syn::Ident) -> bool { self.exprs.iter().all(|e| e.evaluate(ident)) } + + pub fn get_transform(&self, ident: &syn::Ident) -> Option<&TransformType> { + self.exprs.iter().find_map(|e| e.get_transform(ident)) + } } impl Parse for AllExpr { diff --git a/src/lib.rs b/src/lib.rs index dee66af..753a7bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ //! pub struct Vec4 { //! #[substruct(Vec2, Vec3)] //! pub x: T, -//! +//! //! #[substruct(Vec2, Vec3)] //! pub y: T, //! @@ -244,6 +244,60 @@ //! pub text: &'a str, //! } //! ``` +//! +//! # Field Type Transformations +//! Sometimes you may want a substruct to have a different type for a field than +//! the parent struct. For example, you might want an optional field in the +//! parent to be required in the substruct. This can be achieved by specifying +//! transformations inline with the struct name: +//! +//! ``` +//! # use substruct::substruct; +//! #[substruct(RequiredParams)] +//! #[derive(Clone, Debug)] +//! pub struct OptionalParams { +//! #[substruct(RequiredParams(unwrap))] +//! pub name: Option, +//! pub limit: usize, +//! } +//! +//! // The generated RequiredParams struct will have: +//! // pub struct RequiredParams { +//! // pub name: String, // Note: no longer Option +//! // } +//! +//! // This generates a TryFrom implementation instead of From: +//! let optional = OptionalParams { +//! name: Some("test".to_string()), +//! limit: 10, +//! }; +//! let required = RequiredParams::try_from(optional).unwrap(); +//! ``` +//! +//! When field transformations are used, the macro generates: +//! - A `TryFrom` implementation for the substruct +//! - A conversion error type `{ParentStruct}ConversionError` +//! - An `into_{parent_struct}` method that converts back to the parent +//! +//! The transformation is specified inline with the struct name: +//! - `StructName(unwrap)`: Transforms `Option` to `T` in the substruct +//! - `StructName(try_into = TargetType)`: Transforms the field type using +//! `TryInto` +//! +//! Example using `try_into`: +//! ``` +//! # use substruct::substruct; +//! #[substruct(ConvertedParams)] +//! #[derive(Clone)] +//! pub struct OriginalParams { +//! #[substruct(ConvertedParams(try_into = u64))] +//! pub id: u32, +//! } +//! +//! let original = OriginalParams { id: 42 }; +//! let converted = ConvertedParams::try_from(original).unwrap(); +//! assert_eq!(converted.id, 42u64); +//! ``` use proc_macro::TokenStream; diff --git a/src/substruct.rs b/src/substruct.rs index 6ebebab..c0933dd 100644 --- a/src/substruct.rs +++ b/src/substruct.rs @@ -7,7 +7,7 @@ use quote::ToTokens; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use crate::expr::Expr; +use crate::expr::{Expr, TransformType}; /// A single input argument to the `#[substruct]` attribute. /// @@ -22,6 +22,26 @@ struct SubstructInputArg { expr: Expr, } +/// Field transformation specification +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] +pub(crate) enum FieldTransform { + /// No transformation + None, + /// Transform Option to T (unwrap the Option) + Unwrap, + /// Transform T to U using `TryInto` + TryInto(Box), +} + +/// Field configuration for a specific substruct +#[derive(Clone)] +struct FieldConfig { + docs: Vec, + vis: syn::Visibility, + transform: FieldTransform, +} + impl Parse for SubstructInputArg { fn parse(input: ParseStream) -> syn::Result { let attrs = syn::Attribute::parse_outer(input)?; @@ -29,7 +49,7 @@ impl Parse for SubstructInputArg { for attr in &attrs { if !attr.path().is_ident("doc") { return Err(syn::Error::new_spanned( - &attr, + attr, "only #[doc] attributes are permitted within #[substruct] arguments", )); } @@ -90,6 +110,10 @@ struct Emitter<'a> { /// in the macro arguments. args: Rc>, + /// Track which substructs require `TryFrom` conversions due to field + /// transformations + requires_try_from: IndexMap, + errors: Vec, tokens: TokenStream, @@ -110,7 +134,14 @@ impl<'a> Emitter<'a> { .into_iter() .filter_map(|arg| match arg.expr { Expr::Ident(ident) => Some(( - ident.clone(), + ident, + TopLevelArg { + docs: arg.docs, + vis: arg.vis, + }, + )), + Expr::IdentWithTransform(ident_with_transform) => Some(( + ident_with_transform.ident, TopLevelArg { docs: arg.docs, vis: arg.vis, @@ -139,6 +170,7 @@ impl<'a> Emitter<'a> { Ok(Self { input, args: Rc::new(args), + requires_try_from: IndexMap::new(), errors, tokens: TokenStream::new(), }) @@ -151,7 +183,7 @@ impl<'a> Emitter<'a> { } for error in self.errors.drain(..) { - self.tokens.extend(error.into_compile_error()) + self.tokens.extend(error.into_compile_error()); } self.tokens @@ -193,7 +225,7 @@ impl<'a> Emitter<'a> { syn::Fields::Unit => (), }, syn::Data::Union(data) => self.filter_fields_named(&mut data.fields, name), - }; + } input .attrs @@ -206,6 +238,34 @@ impl<'a> Emitter<'a> { } } + fn emit_try_from_error(&mut self) { + let original = &self.input.ident; + let error_name = syn::Ident::new(&format!("{original}ConversionError"), original.span()); + + self.tokens.extend(quote::quote! { + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum #error_name { + MissingRequiredField(&'static str), + ConversionFailed(&'static str), + } + + impl std::fmt::Display for #error_name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingRequiredField(field) => { + write!(f, "Missing required field: {}", field) + } + Self::ConversionFailed(field) => { + write!(f, "Failed to convert field: {}", field) + } + } + } + } + + impl std::error::Error for #error_name {} + }); + } + fn emit_conversions(&mut self, substruct: &syn::DeriveInput) { if !self.errors.is_empty() { return; @@ -213,10 +273,10 @@ impl<'a> Emitter<'a> { let original = &self.input.ident; let name = &substruct.ident; - let (impl_generics, ty_generics, where_clause) = substruct.generics.split_for_impl(); + let (_impl_generics, _ty_generics, _where_clause) = substruct.generics.split_for_impl(); let mut attrs = Vec::::new(); - let method = syn::Ident::new( + let _method = syn::Ident::new( &format!("into_{}", self.input.ident.to_string().to_snake_case()), Span::call_site(), ); @@ -235,25 +295,62 @@ impl<'a> Emitter<'a> { let mut included = IndexMap::new(); let mut excluded = IndexMap::new(); + let mut field_configs = IndexMap::new(); for (index, mut field) in fields.iter().cloned().enumerate() { - let filter = self.filter_field(&mut field, &substruct.ident); + let (filter, config) = self.filter_field_with_config(&mut field, &substruct.ident); let id = match field.ident { Some(ident) => IdentOrIndex::Ident(ident), None => IdentOrIndex::Index(index), }; if filter { - included.insert(id, field.ty); + included.insert(id.clone(), field.ty); + field_configs.insert(id, config); } else { excluded.insert(id, field.ty); } } + let requires_try_from = field_configs.values().any(|config| { + matches!( + config.transform, + FieldTransform::Unwrap | FieldTransform::TryInto(_) + ) + }); + self.requires_try_from + .insert(name.clone(), requires_try_from); + + if requires_try_from { + self.emit_try_from_conversions(substruct, &included, &excluded, &field_configs); + } else { + self.emit_regular_conversions(substruct, &included, &excluded); + } + } + + fn emit_regular_conversions( + &mut self, + substruct: &syn::DeriveInput, + included: &IndexMap, + excluded: &IndexMap, + ) { + let original = &self.input.ident; + let name = &substruct.ident; + let (impl_generics, ty_generics, where_clause) = substruct.generics.split_for_impl(); + + let mut attrs = Vec::::new(); + let method = syn::Ident::new( + &format!("into_{}", self.input.ident.to_string().to_snake_case()), + Span::call_site(), + ); + attrs.push(syn::parse_quote!( + #[doc = concat!("Convert `self` into a [`", stringify!(#original), "`].")] + )); + let args: Vec<_> = excluded .keys() .cloned() - .map(|key| key.into_ident()) + .map(IdentOrIndex::into_ident) .collect(); let types: Vec<_> = excluded.values().collect(); @@ -270,7 +367,7 @@ impl<'a> Emitter<'a> { let exc: Vec<_> = excluded.keys().collect(); if args.len() > 5 { - attrs.push(syn::parse_quote!(#[allow(clippy::too_many_arguments)])) + attrs.push(syn::parse_quote!(#[allow(clippy::too_many_arguments)])); } self.tokens.extend(quote::quote! { @@ -308,7 +405,144 @@ impl<'a> Emitter<'a> { value.#method() } } - }) + }); + } + } + + #[allow(clippy::too_many_lines)] + fn emit_try_from_conversions( + &mut self, + substruct: &syn::DeriveInput, + included: &IndexMap, + excluded: &IndexMap, + field_configs: &IndexMap, + ) { + let original = &self.input.ident; + let name = &substruct.ident; + let (impl_generics, ty_generics, where_clause) = substruct.generics.split_for_impl(); + let error_name = syn::Ident::new(&format!("{original}ConversionError"), original.span()); + + // Emit the error type if this is the first substruct that needs it + if self.requires_try_from.values().filter(|&&x| x).count() == 1 { + self.emit_try_from_error(); + } + + let mut try_from_assignments = Vec::new(); + let mut regular_assignments = Vec::new(); + + for (id, _ty) in included { + let config = &field_configs[id]; + let inc_dst = id; + + match &config.transform { + FieldTransform::Unwrap => { + let field_name = match id { + IdentOrIndex::Ident(ident) => ident.to_string(), + IdentOrIndex::Index(idx) => format!("field_{idx}"), + }; + try_from_assignments.push(quote::quote! { + #inc_dst: value.#inc_dst.ok_or(#error_name::MissingRequiredField(#field_name))? + }); + } + FieldTransform::TryInto(_target_type) => { + let field_name = match id { + IdentOrIndex::Ident(ident) => ident.to_string(), + IdentOrIndex::Index(idx) => format!("field_{idx}"), + }; + try_from_assignments.push(quote::quote! { + #inc_dst: value.#inc_dst.try_into().map_err(|_| #error_name::ConversionFailed(#field_name))? + }); + } + FieldTransform::None => { + regular_assignments.push(quote::quote! { + #inc_dst: value.#inc_dst + }); + } + } + } + + self.tokens.extend(quote::quote! { + impl #impl_generics TryFrom<#original #ty_generics> for #name #ty_generics + #where_clause + { + type Error = #error_name; + + fn try_from(value: #original #ty_generics) -> Result { + Ok(Self { + #( #try_from_assignments, )* + #( #regular_assignments, )* + }) + } + } + }); + + // Still emit regular into_ method for building the original struct + let has_transformations = field_configs + .values() + .any(|config| !matches!(config.transform, FieldTransform::None)); + + if !excluded.is_empty() || has_transformations { + let args: Vec<_> = excluded + .keys() + .cloned() + .map(IdentOrIndex::into_ident) + .collect(); + let types: Vec<_> = excluded.values().collect(); + let exc: Vec<_> = excluded.keys().collect(); + + let inc_src: Vec<_> = included + .keys() + .enumerate() + .map(|(index, name)| match name { + IdentOrIndex::Ident(ident) => IdentOrIndex::Ident(ident.clone()), + IdentOrIndex::Index(_) => IdentOrIndex::Index(index), + }) + .collect(); + + let mut into_assignments = Vec::new(); + for (src, dst) in inc_src.iter().zip(included.keys()) { + let config = &field_configs[dst]; + match &config.transform { + FieldTransform::Unwrap => { + into_assignments.push(quote::quote! { + #dst: Some(self.#src) + }); + } + FieldTransform::TryInto(_target_type) => { + into_assignments.push(quote::quote! { + #dst: self.#src.try_into().expect(&format!( + "reverse conversion failed for field `{}` from source `{}`", + stringify!(#dst), + stringify!(#src) + )) + }); + } + FieldTransform::None => { + into_assignments.push(quote::quote! { + #dst: self.#src + }); + } + } + } + + let method = syn::Ident::new( + &format!("into_{}", self.input.ident.to_string().to_snake_case()), + Span::call_site(), + ); + + self.tokens.extend(quote::quote! { + impl #impl_generics #name #ty_generics + #where_clause + { + #[doc = concat!("Convert `self` into a [`", stringify!(#original), "`].")] + pub fn #method(self, #( #args: #types, )*) -> #original #ty_generics { + #original { + #( #into_assignments, )* + #( #exc: #args, )* + } + } + } + }); } } @@ -333,6 +567,15 @@ impl<'a> Emitter<'a> { } fn filter_field(&mut self, field: &mut syn::Field, name: &syn::Ident) -> bool { + let (included, _config) = self.filter_field_with_config(field, name); + included + } + + fn filter_field_with_config( + &mut self, + field: &mut syn::Field, + name: &syn::Ident, + ) -> (bool, FieldConfig) { let substruct: Vec<_> = field .attrs .iter() @@ -369,21 +612,101 @@ impl<'a> Emitter<'a> { let arg = match substruct.matching(name) { Some(arg) => arg, - None => return false, + None => { + return ( + false, + FieldConfig { + docs: Vec::new(), + vis: syn::Visibility::Inherited, + transform: FieldTransform::None, + }, + ) + } }; + // Check for transformation in the matching expression + let mut transform = FieldTransform::None; + if let Some(transform_type) = arg.expr.get_transform(name) { + match transform_type { + TransformType::Unwrap => { + transform = FieldTransform::Unwrap; + + // Transform the field type from Option to T + // If the field type isn't an Option<...> provide a helpful diagnostic. + if let syn::Type::Path(type_path) = &field.ty { + if let Some(segment) = type_path.path.segments.last() { + if segment.ident == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + { + if let Some(syn::GenericArgument::Type(inner_type)) = + args.args.first() + { + field.ty = inner_type.clone(); + } else { + // Angle bracketed args present but no generic type found + self.errors.push(syn::Error::new_spanned( + &field.ty, + "expected `Option<...>` with an inner type for the `unwrap` transform", + )); + } + } else { + // Not angle-bracketed arguments (unlikely), report diagnostic + self.errors.push(syn::Error::new_spanned( + &field.ty, + "expected `Option<...>` for the `unwrap` transform", + )); + } + } else { + // The last path segment is not `Option` + self.errors.push(syn::Error::new_spanned( + &field.ty, + "the `unwrap` transform was specified but the field is not an `Option`", + )); + } + } else { + // No path segments found (unexpected shape) + self.errors.push(syn::Error::new_spanned( + &field.ty, + "the `unwrap` transform was specified but the field is not an `Option`", + )); + } + } else { + // Field type is not a path (e.g. a tuple or reference), emit diagnostic + self.errors.push(syn::Error::new_spanned( + &field.ty, + "the `unwrap` transform was specified but the field is not an `Option`", + )); + } + } + TransformType::TryInto(target_type_box) => { + // `TransformType::TryInto` stores a boxed `syn::Type` to avoid large + // enum variants. Unbox and clone the inner type for use here. + let target_type: syn::Type = (**target_type_box).clone(); + transform = FieldTransform::TryInto(Box::new(target_type.clone())); + // Replace the field type with the target type + field.ty = target_type.clone(); + } + } + } + self.filter_attrs(&mut field.attrs, name); - if !matches!(arg.vis, syn::Visibility::Inherited) { - field.vis = arg.vis.clone(); + let config = FieldConfig { + docs: arg.docs.clone(), + vis: arg.vis.clone(), + transform, + }; + + if !matches!(config.vis, syn::Visibility::Inherited) { + field.vis = config.vis.clone(); } - if !arg.docs.is_empty() { + if !config.docs.is_empty() { field.attrs.retain(|attr| !attr.path().is_ident("doc")); - field.attrs.extend_from_slice(&arg.docs); + field.attrs.extend_from_slice(&config.docs); } - true + (true, config) } fn filter_attrs(&mut self, attrs: &mut Vec, name: &syn::Ident) { diff --git a/tests/it.rs b/tests/it.rs index 44ba834..8a6150e 100644 --- a/tests/it.rs +++ b/tests/it.rs @@ -8,7 +8,7 @@ fn test_convert_tuple() { let b = B(32); let a = b.into_a(5); - assert!(matches!(a, A(5, 32))) + assert!(matches!(a, A(5, 32))); } #[test] @@ -31,3 +31,130 @@ fn test_convert_normal() { } )); } + +#[test] +fn test_try_from_unwrap() { + #[substruct(RequiredData)] + #[derive(Clone, Debug, PartialEq)] + struct OptionalData { + #[substruct(RequiredData(unwrap))] + pub name: Option, + + #[substruct(RequiredData)] + pub id: u32, + + pub metadata: Option, + } + + // Test successful conversion + let optional = OptionalData { + name: Some("test".to_string()), + id: 42, + metadata: Some("extra".to_string()), + }; + + let required = RequiredData::try_from(optional).unwrap(); + assert_eq!(required.name, "test"); + assert_eq!(required.id, 42); + + // Test round-trip conversion + let back = required.into_optional_data(Some("new_extra".to_string())); + assert_eq!(back.name, Some("test".to_string())); + assert_eq!(back.id, 42); + assert_eq!(back.metadata, Some("new_extra".to_string())); + + // Test failed conversion + let optional_none = OptionalData { + name: None, + id: 42, + metadata: Some("extra".to_string()), + }; + + let result = RequiredData::try_from(optional_none); + assert!(result.is_err()); +} + +#[test] +fn test_try_into_transform() { + use substruct::substruct; + + #[substruct(ConvertedParams)] + #[derive(Clone, Debug)] + pub struct OriginalParams { + #[substruct(ConvertedParams(try_into = u64))] + pub id: u32, + + #[substruct(ConvertedParams)] + pub limit: usize, + + pub description: Option, + } + + // Test successful conversion + let original = OriginalParams { + id: 42u32, + limit: 10, + description: Some("test".to_string()), + }; + + let converted = ConvertedParams::try_from(original).unwrap(); + assert_eq!(converted.id, 42u64); + assert_eq!(converted.limit, 10); + + // Test round-trip conversion + let back_to_original = converted.into_original_params(Some("test".to_string())); + assert_eq!(back_to_original.id, 42u32); + assert_eq!(back_to_original.limit, 10); + assert_eq!(back_to_original.description, Some("test".to_string())); +} + +#[test] +fn test_mixed_transforms() { + use substruct::substruct; + + #[substruct(MixedParams)] + #[derive(Clone, Debug)] + pub struct OriginalParams { + #[substruct(MixedParams(unwrap))] + pub name: Option, + + #[substruct(MixedParams(try_into = u64))] + pub id: u32, + + #[substruct(MixedParams)] + pub active: bool, + + pub metadata: Option, + } + + // Test successful conversion with both transforms + let original = OriginalParams { + name: Some("test".to_string()), + id: 123u32, + active: true, + metadata: Some("meta".to_string()), + }; + + let mixed = MixedParams::try_from(original).unwrap(); + assert_eq!(mixed.name, "test"); + assert_eq!(mixed.id, 123u64); + assert!(mixed.active); + + // Test failed conversion due to None in unwrap + let original_with_none = OriginalParams { + name: None, + id: 123u32, + active: true, + metadata: Some("meta".to_string()), + }; + + let result = MixedParams::try_from(original_with_none); + assert!(result.is_err()); + + // Test round-trip conversion + let back_to_original = mixed.into_original_params(Some("meta".to_string())); + assert_eq!(back_to_original.name, Some("test".to_string())); + assert_eq!(back_to_original.id, 123u32); + assert!(back_to_original.active); + assert_eq!(back_to_original.metadata, Some("meta".to_string())); +} diff --git a/tests/ui/fail/inaccessible-struct.stderr b/tests/ui/fail/inaccessible-struct.stderr index 168f898..0531998 100644 --- a/tests/ui/fail/inaccessible-struct.stderr +++ b/tests/ui/fail/inaccessible-struct.stderr @@ -7,11 +7,11 @@ error[E0603]: struct `A` is private note: the struct `A` is defined here --> tests/ui/fail/inaccessible-struct.rs:4:17 | -4 | #[substruct(pub(self) A)] + 4 | #[substruct(pub(self) A)] | _________________^ -5 | | #[derive(Default)] -6 | | pub struct Test { -7 | | #[substruct(A)] -8 | | pub field: u32, -9 | | } + 5 | | #[derive(Default)] + 6 | | pub struct Test { + 7 | | #[substruct(A)] + 8 | | pub field: u32, + 9 | | } | |_____^ diff --git a/tests/ui/fail/invalid-transform.rs b/tests/ui/fail/invalid-transform.rs new file mode 100644 index 0000000..8e395a9 --- /dev/null +++ b/tests/ui/fail/invalid-transform.rs @@ -0,0 +1,9 @@ +use substruct::substruct; + +#[substruct(B)] +struct A { + #[substruct(B(invalid_transform))] + field: Option, +} + +fn main() {} diff --git a/tests/ui/fail/invalid-transform.stderr b/tests/ui/fail/invalid-transform.stderr new file mode 100644 index 0000000..e29306c --- /dev/null +++ b/tests/ui/fail/invalid-transform.stderr @@ -0,0 +1,5 @@ +error: unknown transformation type, expected 'unwrap' or 'try_into' + --> tests/ui/fail/invalid-transform.rs:5:19 + | +5 | #[substruct(B(invalid_transform))] + | ^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/fail/invalid-try-into-syntax.rs b/tests/ui/fail/invalid-try-into-syntax.rs new file mode 100644 index 0000000..1f35acc --- /dev/null +++ b/tests/ui/fail/invalid-try-into-syntax.rs @@ -0,0 +1,10 @@ +use substruct::substruct; + +#[substruct(ConvertedParams)] +#[derive(Clone, Debug)] +pub struct OriginalParams { + #[substruct(ConvertedParams(try_into))] + pub id: u8, +} + +fn main() {} diff --git a/tests/ui/fail/invalid-try-into-syntax.stderr b/tests/ui/fail/invalid-try-into-syntax.stderr new file mode 100644 index 0000000..45ed66b --- /dev/null +++ b/tests/ui/fail/invalid-try-into-syntax.stderr @@ -0,0 +1,5 @@ +error: expected `=` + --> tests/ui/fail/invalid-try-into-syntax.rs:6:41 + | +6 | #[substruct(ConvertedParams(try_into))] + | ^ diff --git a/tests/ui/pass/mixed-syntax.rs b/tests/ui/pass/mixed-syntax.rs new file mode 100644 index 0000000..426d39b --- /dev/null +++ b/tests/ui/pass/mixed-syntax.rs @@ -0,0 +1,57 @@ +use substruct::substruct; + +#[substruct(A(unwrap), B, C(unwrap))] +#[derive(Clone, Debug)] +pub struct MixedSyntax { + #[substruct(A(unwrap), B, C(unwrap))] + pub field1: Option, + + #[substruct(A, B, C)] + pub field2: i32, + + #[substruct(B)] + pub field3: bool, + + pub field4: Option, +} + +fn main() { + // Test struct A with unwrap transformation + let mixed = MixedSyntax { + field1: Some("test".to_string()), + field2: 42, + field3: true, + field4: Some(std::f64::consts::PI), + }; + + let a = A::try_from(mixed.clone()).unwrap(); + assert_eq!(a.field1, "test"); // String, not Option + assert_eq!(a.field2, 42); + + // Test struct B without unwrap + let b = B::from(mixed.clone()); + assert_eq!(b.field1, Some("test".to_string())); // Still Option + assert_eq!(b.field2, 42); + assert!(b.field3); + + // Test struct C with unwrap transformation + let c = C::try_from(mixed).unwrap(); + assert_eq!(c.field1, "test"); // String, not Option + assert_eq!(c.field2, 42); + + // Test failed conversion when Option is None + let mixed_none = MixedSyntax { + field1: None, + field2: 42, + field3: true, + field4: Some(std::f64::consts::PI), + }; + + // A and C should fail because they require unwrap + assert!(A::try_from(mixed_none.clone()).is_err()); + assert!(C::try_from(mixed_none.clone()).is_err()); + + // B should succeed because it doesn't unwrap + let b_none = B::from(mixed_none); + assert_eq!(b_none.field1, None); +} diff --git a/tests/ui/pass/mixed-transforms.rs b/tests/ui/pass/mixed-transforms.rs new file mode 100644 index 0000000..387175e --- /dev/null +++ b/tests/ui/pass/mixed-transforms.rs @@ -0,0 +1,25 @@ +use substruct::substruct; + +#[substruct(ConvertedParams)] +#[derive(Clone, Debug)] +pub struct OriginalParams { + #[substruct(ConvertedParams(unwrap))] + pub name: Option, + + #[substruct(ConvertedParams(try_into = u64))] + pub id: u32, + + #[substruct(ConvertedParams)] + pub active: bool, +} + +fn main() { + // Minimal compilation test - just verify mixed transforms generate valid code + let original = OriginalParams { + name: Some("test".to_string()), + id: 42u32, + active: true, + }; + + let _converted = ConvertedParams::try_from(original).unwrap(); +} diff --git a/tests/ui/pass/multiple-unwrap.rs b/tests/ui/pass/multiple-unwrap.rs new file mode 100644 index 0000000..976f68e --- /dev/null +++ b/tests/ui/pass/multiple-unwrap.rs @@ -0,0 +1,44 @@ +use substruct::substruct; + +#[substruct(AllRequired)] +#[derive(Clone, Debug)] +pub struct MultiOptional { + #[substruct(AllRequired(unwrap))] + pub first: Option, + + #[substruct(AllRequired(unwrap))] + pub second: Option, + + pub optional_field: Option, +} + +fn main() { + // Test successful conversion + let multi = MultiOptional { + first: Some("hello".to_string()), + second: Some(42), + optional_field: Some(true), + }; + + let required = AllRequired::try_from(multi).unwrap(); + assert_eq!(required.first, "hello"); + assert_eq!(required.second, 42); + + // Test failed conversion - first field None + let multi_fail1 = MultiOptional { + first: None, + second: Some(42), + optional_field: Some(true), + }; + + assert!(AllRequired::try_from(multi_fail1).is_err()); + + // Test failed conversion - second field None + let multi_fail2 = MultiOptional { + first: Some("hello".to_string()), + second: None, + optional_field: Some(true), + }; + + assert!(AllRequired::try_from(multi_fail2).is_err()); +} diff --git a/tests/ui/pass/try-into-transform.rs b/tests/ui/pass/try-into-transform.rs new file mode 100644 index 0000000..19cd505 --- /dev/null +++ b/tests/ui/pass/try-into-transform.rs @@ -0,0 +1,21 @@ +use substruct::substruct; + +#[substruct(ConvertedParams)] +#[derive(Clone, Debug)] +pub struct OriginalParams { + #[substruct(ConvertedParams(try_into = u32))] + pub id: u8, + + #[substruct(ConvertedParams)] + pub limit: usize, +} + +fn main() { + // Minimal compilation test - just verify the generated code compiles + let original = OriginalParams { + id: 42u8, + limit: 10, + }; + + let _converted = ConvertedParams::try_from(original).unwrap(); +} diff --git a/tests/ui/pass/unwrap-generics.rs b/tests/ui/pass/unwrap-generics.rs new file mode 100644 index 0000000..aae3cdd --- /dev/null +++ b/tests/ui/pass/unwrap-generics.rs @@ -0,0 +1,40 @@ +use substruct::substruct; + +#[substruct(RequiredGeneric)] +#[derive(Clone, Debug)] +pub struct OptionalGeneric { + #[substruct(RequiredGeneric(unwrap))] + pub value: Option, + + #[substruct(RequiredGeneric)] + pub id: usize, + + pub metadata: String, +} + +fn main() { + // Test with String type + let optional = OptionalGeneric { + value: Some("hello".to_string()), + id: 42, + metadata: "test".to_string(), + }; + + let required = RequiredGeneric::try_from(optional).unwrap(); + assert_eq!(required.value, "hello"); + assert_eq!(required.id, 42); + + // Test conversion back + let back = required.into_optional_generic("new_metadata".to_string()); + assert_eq!(back.value, Some("hello".to_string())); + assert_eq!(back.metadata, "new_metadata"); + + // Test with failed conversion + let optional_none: OptionalGeneric = OptionalGeneric { + value: None, + id: 42, + metadata: "test".to_string(), + }; + + assert!(RequiredGeneric::try_from(optional_none).is_err()); +} diff --git a/tests/ui/pass/unwrap-transform.rs b/tests/ui/pass/unwrap-transform.rs new file mode 100644 index 0000000..4b93316 --- /dev/null +++ b/tests/ui/pass/unwrap-transform.rs @@ -0,0 +1,42 @@ +use substruct::substruct; + +#[substruct(RequiredParams)] +#[derive(Clone, Debug)] +pub struct OptionalParams { + #[substruct(RequiredParams(unwrap))] + pub name: Option, + + #[substruct(RequiredParams)] + pub limit: usize, + + pub description: Option, +} + +fn main() { + // Test successful conversion + let optional = OptionalParams { + name: Some("test".to_string()), + limit: 10, + description: Some("desc".to_string()), + }; + + let required = RequiredParams::try_from(optional.clone()).unwrap(); + assert_eq!(required.name, "test"); + assert_eq!(required.limit, 10); + + // Test failed conversion + let optional_none = OptionalParams { + name: None, + limit: 10, + description: Some("desc".to_string()), + }; + + let result = RequiredParams::try_from(optional_none); + assert!(result.is_err()); + + // Test round-trip conversion + let back_to_optional = required.into_optional_params(Some("desc".to_string())); + assert_eq!(back_to_optional.name, Some("test".to_string())); + assert_eq!(back_to_optional.limit, 10); + assert_eq!(back_to_optional.description, Some("desc".to_string())); +} diff --git a/tests/ui/pass/unwrap-tuple.rs b/tests/ui/pass/unwrap-tuple.rs new file mode 100644 index 0000000..ca965af --- /dev/null +++ b/tests/ui/pass/unwrap-tuple.rs @@ -0,0 +1,21 @@ +use substruct::substruct; + +#[substruct(RequiredTuple)] +#[derive(Clone, Debug)] +pub struct OptionalTuple( + #[substruct(RequiredTuple)] pub String, + #[substruct(RequiredTuple(unwrap))] pub Option, + pub bool, +); + +fn main() { + let optional = OptionalTuple("test".to_string(), Some(42), true); + let required = RequiredTuple::try_from(optional).unwrap(); + + assert_eq!(required.0, "test"); + assert_eq!(required.1, 42); + + // Test failed conversion + let optional_none = OptionalTuple("test".to_string(), None, true); + assert!(RequiredTuple::try_from(optional_none).is_err()); +}