diff --git a/CHANGELOG.md b/CHANGELOG.md index d67c8516..8c1b897d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## 0.21.3 - 2025-09-08 + +### Added +- New optional alternate `transform` syntax using a full fn, to allow support for custom lifetimes, generics and a where clause to custom builder method. + +Example: +```rust +#[derive(TypedBuilder)] +struct Foo { + #[builder( + setter( + fn transform<'a, M>(value: impl IntoValue<'a, String, M>) -> String + where + M: std::fmt::Display + { + value.into_value() + }, + ) + )] + s: String, +} +``` + ## 0.21.2 - 2025-08-21 ### Fixed - Recognize `TypeGroup` when checking for `Option`. diff --git a/Cargo.toml b/Cargo.toml index 9f1f87f7..6958805e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "./typed-builder-macro"] [workspace.package] description = "Compile-time type-checked builder derive" -version = "0.21.2" +version = "0.21.3" authors = ["IdanArye ", "Chris Morgan "] edition = "2021" license = "MIT OR Apache-2.0" @@ -27,4 +27,4 @@ keywords.workspace = true categories.workspace = true [dependencies] -typed-builder-macro = { path = "typed-builder-macro", version = "=0.21.2" } +typed-builder-macro = { path = "typed-builder-macro", version = "=0.21.3" } diff --git a/src/lib.rs b/src/lib.rs index 3aa415ca..ebb7cd0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -203,7 +203,21 @@ use core::ops::FnOnce; /// - `transform = |param1: Type1, param2: Type2 ...| expr`: this makes the setter accept /// `param1: Type1, param2: Type2 ...` instead of the field type itself. The parameters are /// transformed into the field type using the expression `expr`. The transformation is performed -/// when the setter is called. +/// when the setter is called. `transform` can also be provided in full `fn` syntax, +/// to allow custom lifetimes, a generic and a where clause. +/// Example: +/// ```rust +/// #[builder( +/// setter( +/// fn transform<'a, M>(value: impl IntoValue<'a, String, M>) -> String +/// where +/// M: 'a, +/// { +/// value.into_value() +/// }, +/// ) +/// )] +/// ``` /// /// - `prefix = "..."` prepends the setter method with the specified prefix. For example, setting /// `prefix = "with_"` results in setters like `with_x` or `with_y`. This option is combinable diff --git a/tests/tests.rs b/tests/tests.rs index eddd1fb4..8dbd6ee0 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -742,7 +742,7 @@ fn test_unsized_generic_params() { } #[test] -fn test_field_setter_transform() { +fn test_field_setter_transform_closure() { #[derive(PartialEq)] struct Point { x: i32, @@ -763,6 +763,72 @@ fn test_field_setter_transform() { ); } +#[test] +fn test_field_setter_transform_fn() { + struct MBaseCase; + + struct MClosure; + + // Lifetime is not needed, just added to test. + trait IntoValue<'a, T, M> { + fn into_value(self) -> T; + } + + impl IntoValue<'_, T, MBaseCase> for I + where + I: Into, + { + fn into_value(self) -> T { + self.into() + } + } + + impl IntoValue<'_, T, MClosure> for F + where + F: FnOnce() -> T, + { + fn into_value(self) -> T { + self() + } + } + + #[derive(TypedBuilder)] + struct Foo { + #[builder( + setter( + fn transform<'a, M>(value: impl IntoValue<'a, String, M>) + where + M: 'a, + { + value.into_value() + }, + ) + )] + s: String, + } + + // Check where clause and return type + #[derive(TypedBuilder)] + struct Bar { + #[builder( + setter( + fn transform(value: A) -> String + where + A: std::fmt::Display, + { + value.to_string() + }, + ) + )] + s: String, + } + + assert_eq!(Foo::builder().s("foo").build().s, "foo".to_owned()); + assert_eq!(Foo::builder().s(|| "foo".to_owned()).build().s, "foo".to_owned()); + + assert_eq!(Bar::builder().s(42).build().s, "42".to_owned()); +} + #[test] fn test_build_method() { #[derive(PartialEq, TypedBuilder)] diff --git a/typed-builder-macro/src/field_info.rs b/typed-builder-macro/src/field_info.rs index 751c1961..73470639 100644 --- a/typed-builder-macro/src/field_info.rs +++ b/typed-builder-macro/src/field_info.rs @@ -3,6 +3,7 @@ use std::ops::Deref; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote_spanned; use syn::{parse::Error, spanned::Spanned}; +use syn::{Expr, ExprBlock}; use crate::mutator::Mutator; use crate::util::{expr_to_lit_string, ident_to_type, path_to_single_string, strip_raw_ident_prefix, ApplyMeta, AttrArg}; @@ -248,6 +249,7 @@ impl ApplyMeta for FieldBuilderAttr<'_> { Ok(()) } AttrArg::Sub(_) => Err(expr.incorrect_type()), + AttrArg::Fn(_) => Err(expr.incorrect_type()), }, "default_code" => { use std::str::FromStr; @@ -295,6 +297,7 @@ impl ApplyMeta for FieldBuilderAttr<'_> { self.via_mutators = Some(via_mutators); } } + AttrArg::Fn(_) => return Err(expr.incorrect_type()), } Ok(()) } @@ -318,10 +321,13 @@ impl ApplyMeta for SetterSettings { Ok(()) } "transform" => { - self.transform = if let Some(key_value) = expr.key_value_or_not()? { - Some(parse_transform_closure(key_value.name.span(), key_value.parse_value()?)?) - } else { - None + self.transform = match expr { + AttrArg::Fn(func) => Some(parse_transform_fn(func.span(), func)?), + AttrArg::KeyValue(key_value) => { + Some(parse_transform_closure(key_value.name.span(), key_value.parse_value()?)?) + } + AttrArg::Not { .. } => None, + _ => return Err(expr.incorrect_type()), }; Ok(()) } @@ -441,14 +447,47 @@ impl ApplyMeta for Strip { pub struct Transform { pub params: Vec<(syn::Pat, syn::Type)>, pub body: syn::Expr, + pub generics: Option, + pub return_type: syn::ReturnType, span: Span, } +fn parse_transform_fn(span: Span, func: syn::ItemFn) -> Result { + if let Some(kw) = &func.sig.asyncness { + return Err(Error::new(kw.span, "Transform function cannot be async")); + } + + let params = func + .sig + .inputs + .into_iter() + .map(|input| match input { + syn::FnArg::Typed(pat_type) => Ok((*pat_type.pat, *pat_type.ty)), + syn::FnArg::Receiver(_) => Err(Error::new_spanned(input, "Transform function cannot have self parameter")), + }) + .collect::, _>>()?; + + let body = Expr::Block(ExprBlock { + attrs: Vec::new(), + label: None, + block: *func.block, + }); + + Ok(Transform { + params, + body, + span, + generics: Some(func.sig.generics), + return_type: func.sig.output, + }) +} + fn parse_transform_closure(span: Span, expr: syn::Expr) -> Result { let closure = match expr { syn::Expr::Closure(closure) => closure, _ => return Err(Error::new_spanned(expr, "Expected closure")), }; + if let Some(kw) = &closure.asyncness { return Err(Error::new(kw.span, "Transform closure cannot be async")); } @@ -469,6 +508,8 @@ fn parse_transform_closure(span: Span, expr: syn::Expr) -> Result StructInfo<'a> { } }); - let (param_list, arg_expr) = if field.builder_attr.setter.strip_bool.is_some() { - (quote!(), quote!(true)) + let (method_generics, param_list, arg_expr, method_where_clause) = if field.builder_attr.setter.strip_bool.is_some() { + (quote!(), quote!(), quote!(true), quote!()) } else if let Some(transform) = &field.builder_attr.setter.transform { let params = transform.params.iter().map(|(pat, ty)| quote!(#pat: #ty)); let body = &transform.body; - (quote!(#(#params),*), quote!({ #body })) + let method_generics = transform.generics.as_ref().map_or(quote!(), |g| g.to_token_stream()); + let method_where_clause = transform + .generics + .as_ref() + .and_then(|g| g.where_clause.as_ref()) + .map_or(quote!(), |w| w.to_token_stream()); + + let body = match &transform.return_type { + syn::ReturnType::Default => quote!({ #body }), + syn::ReturnType::Type(_, ty) => quote!({ + let value: #ty = { #body }; + value + }), + }; + + (method_generics, quote!(#(#params),*), body, method_where_clause) } else if option_was_stripped { - (quote!(#field_name: #arg_type), quote!(Some(#arg_expr))) + (quote!(), quote!(#field_name: #arg_type), quote!(Some(#arg_expr)), quote!()) } else { - (quote!(#field_name: #arg_type), arg_expr) + (quote!(), quote!(#field_name: #arg_type), arg_expr, quote!()) }; let repeated_fields_error_type_name = syn::Ident::new( @@ -344,7 +359,9 @@ impl<'a> StructInfo<'a> { #deprecated #doc #[allow(clippy::used_underscore_binding, clippy::no_effect_underscore_binding)] - pub fn #method_name (self, #param_list) -> #builder_name <#target_generics> { + pub fn #method_name #method_generics (self, #param_list) -> #builder_name <#target_generics> + #method_where_clause + { let #field_name = (#arg_expr,); let ( #(#destructuring,)* ) = self.fields; #builder_name { @@ -362,7 +379,9 @@ impl<'a> StructInfo<'a> { #deprecated #doc #[allow(clippy::used_underscore_binding, clippy::no_effect_underscore_binding)] - pub fn #method_name (self, #param_list) -> #builder_name <#target_generics> { + pub fn #method_name #method_generics (self, #param_list) -> #builder_name <#target_generics> + #method_where_clause + { let #field_name = (#arg_expr,); let ( #(#destructuring,)* ) = self.fields; #builder_name { @@ -382,7 +401,9 @@ impl<'a> StructInfo<'a> { #deprecated #doc #[allow(clippy::used_underscore_binding, clippy::no_effect_underscore_binding)] - pub fn #method_name (self, #param_list) -> #builder_name <#target_generics> { + pub fn #method_name #method_generics (self, #param_list) -> #builder_name <#target_generics> + #method_where_clause + { let #field_name = (#arg_expr,); let ( #(#destructuring,)* ) = self.fields; #builder_name { @@ -405,7 +426,9 @@ impl<'a> StructInfo<'a> { note = #repeated_fields_error_message )] #doc - pub fn #method_name (self, _: #repeated_fields_error_type_name) -> #builder_name <#target_generics> { + pub fn #method_name #method_generics (self, _: #repeated_fields_error_type_name) -> #builder_name <#target_generics> + #method_where_clause + { self } } diff --git a/typed-builder-macro/src/util.rs b/typed-builder-macro/src/util.rs index e57c0b07..829a1762 100644 --- a/typed-builder-macro/src/util.rs +++ b/typed-builder-macro/src/util.rs @@ -115,6 +115,7 @@ pub enum AttrArg { KeyValue(KeyValue), Sub(SubAttr), Not { not: Token![!], name: Ident }, + Fn(syn::ItemFn), } impl AttrArg { @@ -124,6 +125,7 @@ impl AttrArg { AttrArg::KeyValue(KeyValue { name, .. }) => name, AttrArg::Sub(SubAttr { name, .. }) => name, AttrArg::Not { name, .. } => name, + AttrArg::Fn(func) => &func.sig.ident, } } @@ -133,6 +135,7 @@ impl AttrArg { AttrArg::KeyValue(KeyValue { name, .. }) => format!("{:?} is not supported as key-value", name.to_string()), AttrArg::Sub(SubAttr { name, .. }) => format!("{:?} is not supported as nested attribute", name.to_string()), AttrArg::Not { name, .. } => format!("{:?} cannot be nullified", name.to_string()), + AttrArg::Fn(func) => format!("{:?} is not supported as a function", func.sig.ident.to_string()), }; syn::Error::new_spanned(self, message) } @@ -308,6 +311,11 @@ fn get_token_stream_up_to_cursor(input: syn::parse::ParseStream, cursor: syn::bu impl Parse for AttrArg { fn parse(input: syn::parse::ParseStream) -> syn::Result { + // Check for standalone function first + if input.peek(Token![fn]) { + return Ok(Self::Fn(input.parse()?)); + } + if input.peek(Token![!]) { Ok(Self::Not { not: input.parse()?, @@ -325,8 +333,6 @@ impl Parse for AttrArg { args: args.parse()?, })) } else if input.peek(Token![=]) { - // Try parsing as a type first, because it _should_ be simpler - Ok(Self::KeyValue(KeyValue { name, eq: input.parse()?, @@ -337,7 +343,7 @@ impl Parse for AttrArg { }, })) } else { - Err(input.error("expected !, = or (…)")) + Err(input.error("expected !, =, (…), or fn")) } } } @@ -353,6 +359,7 @@ impl ToTokens for AttrArg { not.to_tokens(tokens); name.to_tokens(tokens); } + AttrArg::Fn(func) => func.to_tokens(tokens), } } }