Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>", "Chris Morgan <[email protected]>"]
edition = "2021"
license = "MIT OR Apache-2.0"
Expand All @@ -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" }
16 changes: 15 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 67 additions & 1 deletion tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@
#[allow(clippy::items_after_statements)]
fn test_clone_builder_with_generics() {
#[derive(PartialEq, Default)]
struct Uncloneable;

Check warning on line 650 in tests/tests.rs

View workflow job for this annotation

GitHub Actions / Tests (ubuntu-latest, nightly)

struct `Uncloneable` is never constructed

#[derive(PartialEq, TypedBuilder)]
struct Foo<T> {
Expand Down Expand Up @@ -742,7 +742,7 @@
}

#[test]
fn test_field_setter_transform() {
fn test_field_setter_transform_closure() {
#[derive(PartialEq)]
struct Point {
x: i32,
Expand All @@ -763,6 +763,72 @@
);
}

#[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<T, I> IntoValue<'_, T, MBaseCase> for I
where
I: Into<T>,
{
fn into_value(self) -> T {
self.into()
}
}

impl<T, F> 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<A>(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)]
Expand Down
49 changes: 45 additions & 4 deletions typed-builder-macro/src/field_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -295,6 +297,7 @@ impl ApplyMeta for FieldBuilderAttr<'_> {
self.via_mutators = Some(via_mutators);
}
}
AttrArg::Fn(_) => return Err(expr.incorrect_type()),
}
Ok(())
}
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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<syn::Generics>,
pub return_type: syn::ReturnType,
span: Span,
}

fn parse_transform_fn(span: Span, func: syn::ItemFn) -> Result<Transform, Error> {
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::<Result<Vec<_>, _>>()?;

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<Transform, Error> {
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"));
}
Expand All @@ -469,6 +508,8 @@ fn parse_transform_closure(span: Span, expr: syn::Expr) -> Result<Transform, Err
params,
body: *closure.body,
span,
generics: None,
return_type: closure.output,
})
}

Expand Down
41 changes: 32 additions & 9 deletions typed-builder-macro/src/struct_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,16 +315,31 @@ impl<'a> 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(
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
}
Expand Down
13 changes: 10 additions & 3 deletions typed-builder-macro/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ pub enum AttrArg {
KeyValue(KeyValue),
Sub(SubAttr),
Not { not: Token![!], name: Ident },
Fn(syn::ItemFn),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to wonder - maybe instead of ItemFn this should be generalized as an Item? Or would that be too hard to work with?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tried but it does make it harder to work with, it just requires adding support in a few methods for a lot of variants not currently used. I'd prefer to leave as is unless you feel strongly about it?

}

impl AttrArg {
Expand All @@ -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,
}
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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<Self> {
// 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()?,
Expand All @@ -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()?,
Expand All @@ -337,7 +343,7 @@ impl Parse for AttrArg {
},
}))
} else {
Err(input.error("expected !<ident>, <ident>=<value> or <ident>(…)"))
Err(input.error("expected !<ident>, <ident>=<value>, <ident>(…), or fn"))
}
}
}
Expand All @@ -353,6 +359,7 @@ impl ToTokens for AttrArg {
not.to_tokens(tokens);
name.to_tokens(tokens);
}
AttrArg::Fn(func) => func.to_tokens(tokens),
}
}
}
Expand Down
Loading