diff --git a/bon-macros/Cargo.toml b/bon-macros/Cargo.toml index 7f64d60f..2602bb47 100644 --- a/bon-macros/Cargo.toml +++ b/bon-macros/Cargo.toml @@ -71,6 +71,8 @@ std = [] # See the docs on this feature in the `bon`'s crate `Cargo.toml` experimental-overwritable = [] +experimental-build-from = [] + # See the docs on this feature in the `bon`'s crate `Cargo.toml` experimental-generics-setters = [] diff --git a/bon-macros/src/builder/builder_gen/build_from.rs b/bon-macros/src/builder/builder_gen/build_from.rs new file mode 100644 index 00000000..27ded1dd --- /dev/null +++ b/bon-macros/src/builder/builder_gen/build_from.rs @@ -0,0 +1,148 @@ +use crate::builder::builder_gen::{BuilderGenCtx, member::Member}; +use crate::util::prelude::*; +use proc_macro2::{Ident, Span}; +use quote::quote; +use syn::{Type, ext::IdentExt, spanned::Spanned}; + +pub(super) fn emit(ctx: &BuilderGenCtx, target_ty: &Type) -> Result { + let mut tokens = TokenStream::new(); + let ctor_args: Vec<_> = ctx + .members + .iter() + .map(|m| { + let ident = m.orig_ident(); + quote! { #ident } + }) + .collect(); + let base_name = ctx.finish_fn.ident.clone(); + if ctx.build_from.is_some() { + tokens.extend(emit_build_from_method( + false, + &base_name, + target_ty, + &ctx.members, + &ctor_args, + ctx.build_from.as_ref(), + )?); + } + if ctx.build_from_clone.is_some() { + tokens.extend(emit_build_from_method( + true, + &base_name, + target_ty, + &ctx.members, + &ctor_args, + ctx.build_from_clone.as_ref(), + )?); + } + Ok(tokens) +} + +fn emit_build_from_method( + clone: bool, + base_name: &Ident, + target_ty: &Type, + members: &[Member], + ctor_args: &[TokenStream], + config: Option<&crate::parsing::ItemSigConfig>, +) -> Result { + let doc = if clone { + "Fills unset builder fields from a reference to the target type and builds it." + } else { + "Fills unset builder fields from an owned value of the target type and builds it." + }; + let method_name: Ident = config + .and_then(|cfg| cfg.name.as_ref().map(|spanned_key| spanned_key.unraw())) + .unwrap_or_else(|| { + if clone { + format_ident!("{}_from_clone", base_name) + } else { + format_ident!("{}_from", base_name) + } + }); + let arg_type = if clone { + quote!(&#target_ty) + } else { + quote!(#target_ty) + }; + let arg_pat = if clone { + quote!(mut from) + } else { + quote!(from) + }; + let ctor_path = extract_ctor_ident_path(target_ty, target_ty.span())?; + let field_vars = field_vars_from_members(members, clone); + Ok(quote! { + #[inline(always)] + #[doc = #doc] + pub fn #method_name(self, #arg_pat: #arg_type) -> #target_ty { + #( #field_vars )* + #ctor_path { + #( #ctor_args, )* + } + } + }) +} + +fn field_vars_from_members(members: &[Member], clone: bool) -> Vec { + members + .iter() + .map(|member| { + let ident = member.orig_ident(); + let ty = member.norm_ty(); + let default_expr = quote! { ::core::default::Default::default() }; + match member { + Member::Field(_) | Member::StartFn(_) => quote! { + let #ident: #ty = self.#ident; + }, + Member::Named(member) => { + let index = &member.index; + if clone { + quote! { + let #ident: #ty = match self.__unsafe_private_named.#index { + Some(value) => value, + None => from.#ident.clone(), + }; + } + } else { + quote! { + let #ident: #ty = match self.__unsafe_private_named.#index { + Some(value) => value, + None => from.#ident, + }; + } + } + } + Member::FinishFn(_) => { + if clone { + quote! { + let #ident: #ty = from.#ident.clone(); + } + } else { + quote! { + let #ident: #ty = from.#ident; + } + } + } + Member::Skip(_) => quote! { + let #ident: #ty = #default_expr; + }, + } + }) + .collect() +} + +pub(crate) fn extract_ctor_ident_path(ty: &Type, span: Span) -> Result { + let path = ty.as_path_no_qself().ok_or_else(|| { + err!( + &span, + "expected a concrete type path (like `MyStruct`) for constructor" + ) + })?; + let mut clean_path = path.clone(); + if let Some(last_segment) = clean_path.segments.last_mut() { + last_segment.arguments = syn::PathArguments::None; + last_segment.ident.set_span(span); + } + Ok(quote! { #clean_path }) +} diff --git a/bon-macros/src/builder/builder_gen/input_fn/mod.rs b/bon-macros/src/builder/builder_gen/input_fn/mod.rs index bac8e598..eef69fc3 100644 --- a/bon-macros/src/builder/builder_gen/input_fn/mod.rs +++ b/bon-macros/src/builder/builder_gen/input_fn/mod.rs @@ -422,6 +422,10 @@ impl<'a> FnInputCtx<'a> { state_mod: self.config.state_mod, start_fn: self.start_fn, finish_fn, + #[cfg(feature = "experimental-build-from")] + build_from: self.config.build_from, + #[cfg(feature = "experimental-build-from")] + build_from_clone: self.config.build_from_clone, }) } } diff --git a/bon-macros/src/builder/builder_gen/input_struct.rs b/bon-macros/src/builder/builder_gen/input_struct.rs index 69b2d1f8..61053e42 100644 --- a/bon-macros/src/builder/builder_gen/input_struct.rs +++ b/bon-macros/src/builder/builder_gen/input_struct.rs @@ -244,6 +244,10 @@ impl StructInputCtx { state_mod: self.config.state_mod, start_fn, finish_fn, + #[cfg(feature = "experimental-build-from")] + build_from: self.config.build_from, + #[cfg(feature = "experimental-build-from")] + build_from_clone: self.config.build_from_clone, }) } } diff --git a/bon-macros/src/builder/builder_gen/member/config/mod.rs b/bon-macros/src/builder/builder_gen/member/config/mod.rs index 2b68bb1a..1c487e8d 100644 --- a/bon-macros/src/builder/builder_gen/member/config/mod.rs +++ b/bon-macros/src/builder/builder_gen/member/config/mod.rs @@ -65,6 +65,9 @@ pub(crate) struct MemberConfig { /// this option to see if it's worth it. pub(crate) overwritable: darling::util::Flag, + /// Allows the use of `build_from` and `build_from_clone` methods. + pub(crate) build_from: darling::util::Flag, + /// Disables the special handling for a member of type `Option`. The /// member no longer has the default of `None`. It also becomes a required /// member unless a separate `#[builder(default = ...)]` attribute is @@ -102,6 +105,7 @@ enum ParamName { Into, Name, Overwritable, + BuildFrom, Required, Setters, Skip, @@ -119,6 +123,7 @@ impl fmt::Display for ParamName { Self::Into => "into", Self::Name => "name", Self::Overwritable => "overwritable", + Self::BuildFrom => "build_from", Self::Required => "required", Self::Setters => "setters", Self::Skip => "skip", @@ -184,6 +189,7 @@ impl MemberConfig { into, name, overwritable, + build_from, required, setters, skip, @@ -199,6 +205,7 @@ impl MemberConfig { (into.is_present(), ParamName::Into), (name.is_some(), ParamName::Name), (overwritable.is_present(), ParamName::Overwritable), + (build_from.is_present(), ParamName::BuildFrom), (required.is_present(), ParamName::Required), (setters.is_some(), ParamName::Setters), (skip.is_some(), ParamName::Skip), @@ -231,6 +238,14 @@ impl MemberConfig { ); } + if !cfg!(feature = "experimental-build-from") && self.build_from.is_present() { + bail!( + &self.build_from.span(), + "🔬 `build_from` attribute is experimental and requires \ + \"experimental-build-from\" cargo feature to be enabled.", + ); + } + if self.start_fn.is_present() { self.validate_mutually_allowed( ParamName::StartFn, diff --git a/bon-macros/src/builder/builder_gen/mod.rs b/bon-macros/src/builder/builder_gen/mod.rs index 016c41f7..08c08694 100644 --- a/bon-macros/src/builder/builder_gen/mod.rs +++ b/bon-macros/src/builder/builder_gen/mod.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "experimental-build-from")] +mod build_from; + mod builder_decl; mod builder_derives; mod finish_fn; @@ -144,6 +147,24 @@ impl BuilderGenCtx { let allows = allow_warnings_on_member_types(); + let build_froms = { + #[cfg(feature = "experimental-build-from")] + { + if self.build_from.is_some() || self.build_from_clone.is_some() { + match &self.finish_fn.output { + syn::ReturnType::Type(_, ty) => build_from::emit(self, ty)?, + syn::ReturnType::Default => quote! {}, + } + } else { + quote! {} + } + } + #[cfg(not(feature = "experimental-build-from"))] + { + quote! {} + } + }; + Ok(quote! { #allows // Ignore dead code warnings because some setter/getter methods may @@ -158,6 +179,7 @@ impl BuilderGenCtx { #where_clause { #finish_fn + #build_froms #(#accessor_methods)* #generic_setter_methods } diff --git a/bon-macros/src/builder/builder_gen/models.rs b/bon-macros/src/builder/builder_gen/models.rs index e7e21c75..1f692979 100644 --- a/bon-macros/src/builder/builder_gen/models.rs +++ b/bon-macros/src/builder/builder_gen/models.rs @@ -5,6 +5,12 @@ use crate::parsing::{BonCratePath, ItemSigConfig, SpannedKey}; use crate::util::prelude::*; use std::borrow::Cow; +#[cfg(feature = "experimental-build-from")] +use darling::util::Override; + +#[cfg(feature = "experimental-build-from")] +use crate::parsing::ItemSigConfigParsing; + pub(super) trait FinishFnBody { /// Generate the `finish` function body from the ready-made variables. /// The generated function body may assume that there are variables @@ -183,6 +189,10 @@ pub(crate) struct BuilderGenCtx { pub(super) state_mod: StateMod, pub(super) start_fn: StartFn, pub(super) finish_fn: FinishFn, + #[cfg(feature = "experimental-build-from")] + pub(super) build_from: Option, + #[cfg(feature = "experimental-build-from")] + pub(super) build_from_clone: Option, } pub(super) struct BuilderGenCtxParams<'a> { @@ -212,6 +222,10 @@ pub(super) struct BuilderGenCtxParams<'a> { pub(super) state_mod: ItemSigConfig, pub(super) start_fn: StartFnParams, pub(super) finish_fn: FinishFnParams, + #[cfg(feature = "experimental-build-from")] + pub(super) build_from: Option>, + #[cfg(feature = "experimental-build-from")] + pub(super) build_from_clone: Option>, } impl BuilderGenCtx { @@ -231,8 +245,28 @@ impl BuilderGenCtx { state_mod, start_fn, finish_fn, + #[cfg(feature = "experimental-build-from")] + build_from, + #[cfg(feature = "experimental-build-from")] + build_from_clone, } = params; + #[cfg(feature = "experimental-build-from")] + let build_from = build_from + .map(|wrapped_override| match wrapped_override { + Override::Inherit => Ok(ItemSigConfig::default()), + Override::Explicit(meta) => ItemSigConfigParsing::new(&meta, None).parse(), + }) + .transpose()?; + + #[cfg(feature = "experimental-build-from")] + let build_from_clone = build_from_clone + .map(|wrapped_override| match wrapped_override { + Override::Inherit => Ok(ItemSigConfig::default()), + Override::Explicit(meta) => ItemSigConfigParsing::new(&meta, None).parse(), + }) + .transpose()?; + let builder_type = BuilderType { ident: builder_type.ident, vis: builder_type.vis.unwrap_or(orig_item_vis), @@ -385,6 +419,10 @@ impl BuilderGenCtx { state_mod, start_fn, finish_fn, + #[cfg(feature = "experimental-build-from")] + build_from, + #[cfg(feature = "experimental-build-from")] + build_from_clone, }) } } diff --git a/bon-macros/src/builder/builder_gen/top_level_config/mod.rs b/bon-macros/src/builder/builder_gen/top_level_config/mod.rs index 5fc29675..296980eb 100644 --- a/bon-macros/src/builder/builder_gen/top_level_config/mod.rs +++ b/bon-macros/src/builder/builder_gen/top_level_config/mod.rs @@ -6,11 +6,14 @@ pub(crate) use on::OnConfig; use crate::parsing::{BonCratePath, ItemSigConfig, ItemSigConfigParsing, SpannedKey}; use crate::util::prelude::*; -use darling::ast::NestedMeta; use darling::FromMeta; +use darling::ast::NestedMeta; +use syn::ItemFn; use syn::parse::Parser; use syn::punctuated::Punctuated; -use syn::ItemFn; + +#[cfg(feature = "experimental-build-from")] +use darling::util::Override; fn parse_finish_fn(meta: &syn::Meta) -> Result { ItemSigConfigParsing::new(meta, Some("builder struct's impl block")).parse() @@ -65,6 +68,14 @@ pub(crate) struct TopLevelConfig { /// Specifies configuration for generic parameter conversion methods. #[darling(default, with = crate::parsing::parse_non_empty_paren_meta_list)] pub(crate) generics: Option>, + + #[cfg(feature = "experimental-build-from")] + #[darling(default)] + pub(crate) build_from: Option>, + + #[cfg(feature = "experimental-build-from")] + #[darling(default)] + pub(crate) build_from_clone: Option>, } impl TopLevelConfig { diff --git a/bon-macros/src/builder/builder_gen/top_level_config/on.rs b/bon-macros/src/builder/builder_gen/top_level_config/on.rs index 0289e2a0..00db4957 100644 --- a/bon-macros/src/builder/builder_gen/top_level_config/on.rs +++ b/bon-macros/src/builder/builder_gen/top_level_config/on.rs @@ -1,6 +1,6 @@ use crate::util::prelude::*; -use darling::util::Flag; use darling::FromMeta; +use darling::util::Flag; use syn::parse::Parse; use syn::spanned::Spanned; use syn::visit::Visit; @@ -10,6 +10,8 @@ pub(crate) struct OnConfig { pub(crate) type_pattern: syn::Type, pub(crate) into: Flag, pub(crate) overwritable: Flag, + #[allow(dead_code)] + pub(crate) build_from: Flag, pub(crate) required: Flag, pub(crate) setters: OnSettersConfig, } @@ -42,6 +44,7 @@ impl Parse for OnConfig { struct Parsed { into: Flag, overwritable: Flag, + build_from: Flag, required: Flag, #[darling(default, with = crate::parsing::parse_non_empty_paren_meta_list)] @@ -73,6 +76,14 @@ impl Parse for OnConfig { )); } + if !cfg!(feature = "experimental-build-from") && parsed.build_from.is_present() { + return Err(syn::Error::new( + parsed.build_from.span(), + "🔬 `build_from` attribute is experimental and requires \ + \"experimental-build-from\" cargo feature to be enabled.", + )); + } + struct FindAttr { attr: Option, } @@ -107,6 +118,7 @@ impl Parse for OnConfig { type_pattern, into: parsed.into, overwritable: parsed.overwritable, + build_from: parsed.build_from, required: parsed.required, setters: parsed.setters, }) diff --git a/bon-macros/src/parsing/item_sig.rs b/bon-macros/src/parsing/item_sig.rs index 6a0f47f1..ea9c9e0c 100644 --- a/bon-macros/src/parsing/item_sig.rs +++ b/bon-macros/src/parsing/item_sig.rs @@ -96,3 +96,9 @@ impl<'a> ItemSigConfigParsing<'a> { Ok(config) } } + +impl FromMeta for ItemSigConfig { + fn from_meta(meta: &syn::Meta) -> darling::Result { + ItemSigConfigParsing::new(meta, None).parse() + } +} diff --git a/bon/Cargo.toml b/bon/Cargo.toml index 288fc736..7fbdd9e4 100644 --- a/bon/Cargo.toml +++ b/bon/Cargo.toml @@ -92,6 +92,8 @@ experimental-overwritable = ["bon-macros/experimental-overwritable"] # generate methods for converting generic parameters on builders. experimental-generics-setters = ["bon-macros/experimental-generics-setters"] +experimental-build-from = ["bon-macros/experimental-build-from"] + # Legacy experimental attribute. It's left here for backwards compatibility, # and it will be removed in the next major release. # diff --git a/bon/src/__/ide.rs b/bon/src/__/ide.rs index 404b40a7..1abb5cad 100644 --- a/bon/src/__/ide.rs +++ b/bon/src/__/ide.rs @@ -105,6 +105,40 @@ pub mod builder_top_level { pub use core::future::IntoFuture; } + /// See the docs at + pub const build_from: Option = None; + + /// See the docs at + pub mod build_from { + use super::*; + + /// See the docs at + pub const name: Identifier = Identifier; + + /// See the docs at + pub const vis: VisibilityString = VisibilityString; + + /// See the docs at + pub const doc: DocComments = DocComments; + } + + /// See the docs at + pub const build_from_clone: Option = None; + + /// See the docs at + pub mod build_from_clone { + use super::*; + + /// See the docs at + pub const name: Identifier = Identifier; + + /// See the docs at + pub const vis: VisibilityString = VisibilityString; + + /// See the docs at + pub const doc: DocComments = DocComments; + } + /// The real name of this parameter is `crate` (without the underscore). /// It's hinted with an underscore due to the limitations of the current /// completions limitation. This will be fixed in the future. diff --git a/bon/tests/integration/builder/build_from.rs b/bon/tests/integration/builder/build_from.rs new file mode 100644 index 00000000..265b0cad --- /dev/null +++ b/bon/tests/integration/builder/build_from.rs @@ -0,0 +1,130 @@ +use crate::prelude::*; + +#[derive(Builder, Clone)] +#[builder(build_from, build_from_clone)] +struct Sut { + name: String, + age: u8, +} + +#[test] +fn test_build_from_works() { + let jon = Sut::builder().name("Jon".into()).age(25).build(); + let alice = Sut::builder().name("Alice".into()).build_from(jon); + assert_eq!(alice.age, 25); + assert_eq!(alice.name, "Alice"); +} + +#[test] +fn test_build_from_clone_works() { + let jon = Sut::builder().name("Jon".into()).age(25).build(); + let alice = Sut::builder().name("Alice".into()).build_from_clone(&jon); + assert_eq!(alice.age, 25); + assert_eq!(alice.name, "Alice"); +} + +#[builder(build_from, build_from_clone)] +fn create_user(name: String, age: u8) -> Sut { + Sut { name, age } +} + +#[test] +fn test_function_build_from_works() { + let jon = create_user().name("Jon".into()).age(25).call(); + let alice = create_user().name("Alice".into()).call_from(jon); + assert_eq!(alice.age, 25); + assert_eq!(alice.name, "Alice"); +} + +#[bon] +impl Sut { + #[builder(build_from, build_from_clone)] + fn from_parts(name: String, age: u8) -> Self { + Self { name, age } + } +} + +#[test] +fn test_method_build_from_clone_works() { + let jon = Sut::from_parts().name("Jon".into()).age(25).call(); + let alice = Sut::from_parts().name("Alice".into()).call_from_clone(&jon); + assert_eq!(alice.age, 25); + assert_eq!(alice.name, "Alice"); +} + +#[test] +fn test_build_from_path_and_generics() { + { + pub(crate) mod models { + #[derive(Debug, PartialEq)] + pub(crate) struct Sut { + pub(crate) value: T, + pub(crate) flag: bool, + } + } + #[derive(Builder)] + #[builder(build_from)] + struct Source { + value: String, + flag: bool, + } + let src = Source::builder() + .value("Veetah".to_string()) + .flag(true) + .build(); + + let actual = models::Sut:: { + value: src.value, + flag: src.flag, + }; + assert_eq!(actual.value, "Veetah"); + } +} + +#[test] +fn test_build_from_custom_vis_and_docs() { + { + pub(crate) mod external { + use crate::prelude::*; + + #[derive(Builder, Clone)] + #[builder(build_from)] + pub(crate) struct Source { + pub(crate) id: u32, + } + } + let base = external::Source::builder().id(99).build(); + let actual = external::Source::builder().build_from(base); + assert_eq!(actual.id, 99); + } +} + +#[test] +fn test_build_from_nested_path_and_generics_enforcement() { + { + pub(crate) mod complex_namespace { + pub(crate) mod deeply_nested { + pub(crate) struct TargetSut { + pub(crate) data: String, + pub(crate) extra: T, + } + } + } + #[builder(build_from)] + fn create_complex_target( + data: String, + extra: u32, + ) -> complex_namespace::deeply_nested::TargetSut { + complex_namespace::deeply_nested::TargetSut { data, extra } + } + let base = complex_namespace::deeply_nested::TargetSut { + data: "BaseData".to_string(), + extra: 100u32, + }; + let actual = create_complex_target() + .data("Veetah".to_string()) + .call_from(base); + assert_eq!(actual.data, "Veetah"); + assert_eq!(actual.extra, 100); + } +} diff --git a/bon/tests/integration/builder/mod.rs b/bon/tests/integration/builder/mod.rs index 7a749797..158211b8 100644 --- a/bon/tests/integration/builder/mod.rs +++ b/bon/tests/integration/builder/mod.rs @@ -16,6 +16,8 @@ mod attr_setters; mod attr_skip; mod attr_top_level_start_fn; mod attr_with; +#[cfg(feature = "experimental-build-from")] +mod build_from; mod cfgs; mod generics; #[cfg(feature = "experimental-generics-setters")]