-
Notifications
You must be signed in to change notification settings - Fork 52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Optionally transform Extend
fields to add items one by one
#46
base: master
Are you sure you want to change the base?
Conversation
The public API feels right to me now, as far as implemented. The first setter call for a given collection field isn't generic anymore, which simplifies and solves typing problems around custom collection initialisers and avoids any potential issues with type inference. Writing a custom initialiser isn't entirely intuitive in that it should also process the first item (which is necessary for strictly non-empty collections, as mentioned above). For this reason, I added the following error on there: I don't think the error location can be improved nicely with current proc macro tooling, but this could get sorted out with either upcoming changes to |
Can you please not run the code through a formatter? If the code needs to be formatted it deserves a PR of its own, where the settings of the formatter can be discussed and more importantly - where there is no need to go through all the code changes because it's auto-generated anyways and contains no real changes. When you do it as part of a regular PR, most of the changes are formatting changes which makes it very hard to review the real changes. Regarding the PR itself - I've only skimmed it (I can't properly read it with all the auto-generated formatting noise), but a few remarks:
|
Thanks for the feedback (and thank you for considering this feature submission at all)!
Sure, and sorry about that. Not using the auto-formatter at all makes it pretty difficult for me personally to work on code, but I'll add a commit that reverts those changes once I take care of the other problems with my changes. Feel free to unsubscribe from the pull-request until then if you'd like to avoid progress notifications - I'll ping you via @ mention after I clean it up.
It's a bit more complicated, since (as far as I can tell) collections aren't thoroughly defined by a single trait. It's definitely possible to reduce this to just I'm open to the rename regardless of the above, though.
That sounds a lot better than what I came up with, thanks! I'll change it 👍
It's an edge case, I think. For the default collections in Collections that by type require at least one entry are more ergonomic in some use-cases, in that you don't have to I'd like to have this feature in my framework because it's, in my experience, quite likely to get GUI designs where the empty case is under-specified. If that's actually not expected to be possible, I'd rather mark it in the type system than add it as comment and try to invent a style that looks sort of okay when the component is misused. That said, in my particular framework I'll likely be able to do this transparently even without specific support here. The (existing) It could also be used to start a collection without existing elements if the default has some (since I'd like to have the #[builder(default, setter(strip_option, extend(initializer = vec1![entry_first])))]
entry: Option<Vec1<Entry>>, in that case (which would most likely be written as
I'll remove it. I think it would be a good idea to demo the
I'll add it to my checklist in PR summary 👍 |
I don't mind setting up a formatter configuration and formatting all the code - as long as it's a separate PR. Or I can do it myself, if you don't mind handling the merge conflicts in this PR. Personally I prefer to set up a linter and do what it says because I like my line breaks at logical places and formatters tend to blindly re-format macros and function arguments according to width, but that can probably turned off in the configuration.
Maybe we are looking at it wrong. What if instead of building the end collection, the builder will always build a
I think I kind of missed that That's what I get for skimming a PR, I guess, because if the initializer creates it the collection from the first item then it does make sense. May I suggest to use a closure instead? So instead of #[builder(setter(extend(initializer = vec![v1_first])))]
v1: Vec<i32>, You'd have #[builder(setter(extend(initializer = |first| vec![first])))]
v1: Vec<i32>, This makes it more clear that an argument is involved.
|
(I'm going to collapse a few of the nested quotes for brevity's sake.)
I'd be glad if you did, thank you. I was actually considering submitting a Unfortunately, I don't think it's possible to make rustfmt preserve argument formatting. (You can set a maximum width, but that's it.)
It's definitely possible, yes. This would then use only I see three drawbacks with this:
In hindsight that was really confusing syntax, yeah. I'll make sure to move away from this in my other code too.Event handlers in my web framework currently get a Thank you! This is very helpful. |
I've added a |
…seed assignment in `SetterSettings`
…ing but is more ergonomic This commit also fixes the error on faulty custom collection initialisers.
…`default` nor a custom initialiser are present This commit should also improve collection type error localisation for `strip_collection`.
This won't worsen type inference or complicate custom initialisers, since the argument is still concretely typed on the first call.
…ead of `strip_collection = vec![…_first]`
0390267
to
8e4b301
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you and done 👍
I'll most likely take a break for a few more days, but for now I've added a few comments where I think the changes could use some explanation.
There might be a bit much copy-paste right now. I tend to clean that up fairly late into my features, but I'll have to see how it turns out in this case.
@@ -291,7 +371,7 @@ impl SetterSettings { | |||
Err(Error::new_spanned(expr, "Expected simple identifier".to_owned())) | |||
} | |||
} | |||
_ => Err(Error::new_spanned(expr, "Expected (<...>=<...>)")), | |||
_ => Err(Error::new_spanned(expr, "Expected (<...>=<...>) or (<...>(<...>))")), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't sure about the placeholder syntax here, but I think I got "assignment or call" right.
Since ident and !ident are accepted too, this might need an update, though. I didn't change this for now since those cases both were already present.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe using Lookahead1 would make sense here, since that can generate a similar error message automatically. The syntax isn't as clean as the match statement here, though.
examples/example.rs
Outdated
assert!( | ||
Foo::builder().x(1).y(2).z(3).v0(4).v1(5).h((6, 7)).build() | ||
== Foo { | ||
x: 1, | ||
y: Some(2), | ||
z: 3, | ||
v0: vec![4], | ||
v1: vec![5], | ||
h: extend![HashMap::new(); (6, 7)], | ||
} | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll split this and put it into a separate file. You're right that it's just too much at once.
src/struct_info.rs
Outdated
#[allow(dead_code, non_camel_case_types, missing_docs)] | ||
impl #impl_generics #builder_name < #( #target_generics ),* > #where_clause { | ||
#doc | ||
pub fn #field_name<Argument> (self, #field_name: Argument) -> #builder_name < #( #target_generics ),* > |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't changed the argument name here yet, but I've been thinking that item
for the single case and items
when extending with an iterator might be more clear. That would also avoid the field_hygienic
workaround.
The generic type parameter Argument
is a bad choice on my part - I'll change it to Item
once I get back to this.
examples/example.rs
Outdated
|
||
// No `default`: This field must be set at least once. | ||
// You can explicitly create the collection from the first item (but this is not required even without `default`). | ||
#[builder(setter(strip_collection(from_first = |first| vec![first])))] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think from_first
is more clear than initializer
here, and will nicely pair with from_iter
once added.
I'm concerned about what to default to if just one of the parameters is present, or with empty parentheses.
My current hunch is that initialisers missing from explicit parentheses shouldn't be generated at all, but also to accept from_first
and from_iter
without assignment to generate the default each. This would mirror how default
works as builder(…)
parameter.
I could also add support for !from_first
and !from_iter
to delete them again once set, but that feels a bit odd to me.
Is there a specific reason this is supported on the fields? The application I can think of is for overrides when combined with a macro, but I'm just guessing here 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is from_first
without arguments even meaningful? What would be the default?
As for from_iter
- I think maybe it should be the default - as in, without an argument you won't even need to specify it. Instead of keeping the actual target collection we can keep I: Iterator
, and each time we extend with the builder, we can make an std::iter::Chain
. We can still specify from_iter = |it| ...
if FromIterator
is not available for that collection or if for whatever reason we need custom behavior.
use proc_macro2::TokenStream; | ||
use quote::quote; | ||
use proc_macro2::{Span, TokenStream}; | ||
use quote::{quote, quote_spanned, ToTokens}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to use quote_spanned
quite often even when it's not strictly necessary, since it tends to make for better error locations.
My earlier hack with #[forbid(unused_variables)]
is gone, of course, but this should still flag some type/trait errors on the field itself or the matching attribute or attribute parameter.
Just a fyi, rustfmt will skip items marked |
# Conflicts: # src/struct_info.rs
…ator, move extending by one item to a separate (chooseable) name
…`s to initialise an `extend` field
I haven't been able to work on this much lately, but here's a little progress. Accepting Just I'll have to think a bit about how to integrate this with |
…ertain errors with explicit extend initialisers
tests/extend.rs
Outdated
#[test] | ||
fn extend_default() { | ||
#[derive(TypedBuilder)] | ||
struct A { | ||
#[builder(default = vec![0], setter(extend))] | ||
v: Vec<i8>, | ||
} | ||
|
||
assert_eq!(A::builder().v_item(2).build().v, vec![0, 2]); | ||
assert_eq!(A::builder().v(vec![3, 4]).build().v, vec![0, 3, 4]); | ||
} | ||
|
||
#[test] | ||
fn extend_default_explicit_auto() { | ||
#[derive(TypedBuilder)] | ||
struct A { | ||
#[builder(default = vec![0], setter(extend(from_first, from_iter)))] | ||
v: Vec<i8>, | ||
} | ||
|
||
assert_eq!(A::builder().v_item(2).build().v, vec![0, 2]); | ||
assert_eq!(A::builder().v(vec![3, 4]).build().v, vec![0, 3, 4]); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Writing this out in the tests made me realise this is probably unexpected behaviour (and would also combine awkwardly with strip_option
). I'll change it so that the auto-implementations always start with only the given items.
if let syn::Pat::Type(syn::PatType { colon_token, ty, .. }) = input { | ||
return Err(Error::new_spanned(quote!(#colon_token #ty), "Did not expect argument type, since it is set explicitly by the macro")) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll likely revisit this, making a closure argument type override the outer method argument type verbatim (including any impl
).
That isn't quite standard Rust (though Syn should still parse it), but is (likely) needed to constrain the argument type for e.g. non-empty target collections.
These were left over from when I experimented with what's now the "Did not expect argument type, since it is set explicitly by the macro" error.
This function really shouldn't be called without that macro option, so this might shortcut some troubleshooting in the future.
src/field_info.rs
Outdated
pub fn type_from_inside_option(&self) -> Result<&syn::Type, Error> { | ||
assert!(self.builder_attr.setter.strip_option); | ||
|
||
pub fn try_<'a>(field_info: &'a FieldInfo) -> Option<&'a syn::Type> { | ||
let path = if let syn::Type::Path(type_path) = field_info.ty { | ||
if type_path.qself.is_some() { | ||
return None; | ||
} else { | ||
&type_path.path | ||
} | ||
} else { | ||
return None; | ||
}; | ||
let segment = path.segments.last()?; | ||
if segment.ident != "Option" { | ||
return None; | ||
} | ||
let generic_params = if let syn::PathArguments::AngleBracketed(generic_params) = &segment.arguments { | ||
generic_params | ||
} else { | ||
return None; | ||
}; | ||
if let syn::GenericArgument::Type(ty) = generic_params.args.first()? { | ||
Some(ty) | ||
} else { | ||
&type_path.path | ||
None | ||
} | ||
} else { | ||
return None; | ||
}; | ||
let segment = path.segments.last()?; | ||
if segment.ident != "Option" { | ||
return None; | ||
} | ||
let generic_params = if let syn::PathArguments::AngleBracketed(generic_params) = &segment.arguments { | ||
generic_params | ||
} else { | ||
return None; | ||
}; | ||
if let syn::GenericArgument::Type(ty) = generic_params.args.first()? { | ||
Some(ty) | ||
} else { | ||
None | ||
} | ||
|
||
try_(self).ok_or_else(|| Error::new_spanned(&self.ty, "can't `strip_option` - field is not `Option<...>`")) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ended up using this method in a few more places, so I unified the error handling here.
This also means I changed a few other functions slightly to propagate these errors, but that should be fairly minimal.
let mut early_build_error_message = format!("Missing required field {}", field_name); | ||
if field.builder_attr.setter.extend.is_some() { | ||
write!(&mut early_build_error_message, " (single item setter: {})", field.item_name()).unwrap(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There probably should be a special message in case neither of these are available as initialisers.
I'll stop for today, but I've added this point to my to-do list.
(As an aside, I've just been able to confirm this feature will work for my use-case 🥳
I've queued that feature up just so it's easier to keep track of, but that won't change how I work on this one.)
A small status update since it's been a month: A few things with higher priority have been happening, so I haven't found time to continue here. I currently don't have an ETA on when I'll be able to resume work on this draft. |
# Conflicts: # src/struct_info.rs
I should probably put a(nother) small status update here so that it doesn't look like I ran off as soon as this started working for my specific use case… The reality of the situation is that I've been swamped with higher-priority work pretty much constantly since April, so I haven't found time to continue work on this feature here or the grammar feature in my framework that I'll need this for. |
No need to apologize. This is open source - we all work for free here, and no one should feel pressured to prioritize contribution over other things they have in their life. Also - it's not like I've been working on this crate that much this year... |
This is only a proof of concept right now, and has many rough edges. I decided to make a draft PR early to discuss this more easily.
Thanks for your consideration.
Motivation
I'm working on a web frontend framework where I use
typed-builder
to enable type-safe named and optional parameters, like this:Something I'd like to add to this (without special syntax) is a way to declare arguments as accepting zero-or-more or one-or-more parameters:
I could probably also do this on my end, but the feature is most likely a bit cleaner and easier to implement here in this crate.
The changes to examples/example.rs contain usage examples.
(Planned) Changes
For now, I added a setter setting
strip_extend
that optionally takes an expression to transform the first given argument into the collection type (which is necessary to make this work with, for example,Vec1
).I'll have to think about the naming here, maybe use two different names to make it clear that the parametrised variant needs to process the the argument too, instead of just providing an empty seed value.
The unparametrised version uses a specified
default =
orDefault::default()
to seed the field, but a field that's not set at all is still rejected unlessdefault
is specified explicitly.The generated setter method itself is generic over
<Argument>
, which means for exampleVec
will accept both its item type and references to it (if the items areCopy
). This doesn't seem to cause too many issues with type inference, but the type can also be specified explicitly when calling the method.This could cause issues with custom initialisers though, since
Argument
can only be used to call.extend(iter::once(...))
right now. I'm not yet sure how to fix this.Other issues with this draft that still need to be resolved:
Change
strip_collection
toextend
Change
extend = …
toextend(initializer = …)
extend(from_first = |…| …)
Extending by an iterator
This probably shouldn't be missing, but would need another method name.
("extend_{field_name}"
?)The plain field name now is used to accept iterators. Methods accepting items are suffixed
_item
by default.)Compatibility with
into
/auto_into
This might need a bit of work so that
Argument
can still be specified explicitly on the builder method call.Compatibility with
strip_option
I think this can be done in a hacky way without many changes, but it would definitely be cleaner if the field value was only wrapped in
Some
once.build()
is called. (I'm not sure whether this would affect performance,but more importantly it's a breaking change regarding whatThe builder field is only unwrapped if bothTypedBuilderFields
is set to.strip_option
andextend
are used.)Custom errors where
from_iter
and/orfrom_item
don't exist?Similarly to the missing fields message, these should communicate the name of the other method (if it exists) and that this one can be called later. If both are missing, it should suggest adding
from_first
and/orfrom_iter
to#[builder(setter(extend(...)))]
on this field.The missing field error message should be similarly changed if no initialisers are available at all.
Tests
Documentation
Example improvements (see below)
Changelog entry
Undo auto-formatting of unrelated code
Make sure the minimum Rust version didn't change