Repository: elastio/bon (language: rust)

Issue:
Macros for other collections
Hi, `bon` looks really nice. Thank you for the project.

In the docs, you mention wanting to add `vec`-like macros for other collections. Would you be interested in a PR for that?


Discussion / hints:

Fix patch:
diff --git a/bon-macros/src/lib.rs b/bon-macros/src/lib.rs
index 171b5462..6f69a5ed 100644
--- a/bon-macros/src/lib.rs
+++ b/bon-macros/src/lib.rs
@@ -3,7 +3,9 @@
 mod bon;
 mod builder;
 mod error;
+mod map;
 mod normalization;
+mod set;
 mod util;
 
 use proc_macro::TokenStream;
@@ -100,3 +102,99 @@ pub fn bon(params: TokenStream, item: TokenStream) -> TokenStream {
         .unwrap_or_else(|err| error::error_into_token_stream(err, item.into()))
         .into()
 }
+
+/// Similar to [`maplit::hashmap!`]/[`maplit::btreemap!`] but converts each key and value with
+/// [`Into::into()`].
+///
+/// There are no separate variants for [`HashMap`] and [`BTreeMap`] since the macro works with any
+/// type that implements [`FromIterator<(K, V)>`].
+///
+/// A good example of the use case for this macro is when you want to create a
+/// `HashMap<String, String>` where part of the keys or values are hardcoded string literals of type `&str`
+/// and the other part is made of dynamic [`String`] values.
+///
+/// ```rust
+/// # use bon_macros as bon;
+/// # use std::collections::HashMap;
+/// let address_book: HashMap<String, String> = bon::map! {
+///     "jd@example.org": "John Doe",
+///     format!("{}@{}.{}", "jane.doe", "example", "com"): "Jane Doe",
+///     "roger@example.org": format!("Roger {}", "Simpson"),
+/// };
+/// ```
+///
+/// The macro also performs rudimentary uniqueness checking on keys: syntactically equal keys are
+/// rejected with a compile error.
+///
+/// ```rust compile_fail
+/// # use bon_macros as bon;
+/// # use std::collections::HashMap;
+/// let address_book: HashMap<String, String> = bon::map! {
+///     "jd@example.org": "John Doe",
+///     format!("{}@{}.{}", "jane.doe", "example", "com"): "Jane Doe",
+///     "roger@example.org": format!("Roger {}", "Simpson"),
+///     "jd@example.org": "Jane Doe", // compile error
+/// };
+/// ```
+///
+/// [`BTreeMap`]: std::collections::BTreeMap
+/// [`HashMap`]: std::collections::HashMap
+/// [`maplit::hashmap!`]: https://docs.rs/maplit/latest/maplit/macro.hashmap.html
+/// [`maplit::btreemap!`]: https://docs.rs/maplit/latest/maplit/macro.btreemap.html
+#[proc_macro]
+pub fn map(input: TokenStream) -> TokenStream {
+    let entries = syn::parse_macro_input!(input with util::parse_map_macro_input);
+
+    map::generate(entries)
+        .unwrap_or_else(|err| err.write_errors())
+        .into()
+}
+
+/// Similar to [`maplit::hashset!`]/[`maplit::btreeset!`] but converts each key and value with
+/// [`Into::into()`].
+///
+/// There are no separate variants for [`HashSet`] and [`BTreeSet`] since the macro works with any
+/// type that implements [`FromIterator<T>`].
+///
+/// A good example of the use case for this macro is when you want to create a `Hashset<String>` where part of
+/// the values are hardcoded string literals of type `&str` and the other part is made of dynamic [`String`]
+/// values.
+///
+/// ```rust
+/// # use bon_macros as bon;
+/// # use std::collections::HashSet;
+/// let fruit_basket: HashSet<String> = bon::set! [
+///         "apples",
+///         format!("b{0}n{0}n{0}s", 'a'),
+///         format!("or{:x}ng{:x}s", 10, 14),
+/// ];
+/// ```
+///
+/// The macro also performs rudimentary uniqueness checking on keys: syntactically equal elements are
+/// rejected with a compile error.
+///
+/// ```rust compile_fail
+/// # use bon_macros as bon;
+/// # use std::collections::HashSet;
+/// let fruit_basket: HashSet<String> = bon::set! [
+///         "apples",
+///         format!("b{0}n{0}n{0}s", 'a'),
+///         format!("or{:x}ng{:x}s", 10, 14),
+///         "apples", // compile error
+/// ];
+/// ```
+///
+/// [`BTreeSet`]: std::collections::BTreeSet
+/// [`HashSet`]: std::collections::HashSet
+/// [`maplit::hashset!`]: https://docs.rs/maplit/latest/maplit/macro.hashset.html
+/// [`maplit::btreeset!`]: https://docs.rs/maplit/latest/maplit/macro.btreeset.html
+#[proc_macro]
+pub fn set(input: TokenStream) -> TokenStream {
+    use syn::punctuated::Punctuated;
+
+    let entries = syn::parse_macro_input!(input with Punctuated::parse_terminated);
+
+    set::generate(entries)
+        .unwrap_or_else(|err| err.write_errors())
+        .into()
+}
diff --git a/bon-macros/src/map.rs b/bon-macros/src/map.rs
new file mode 100644
index 00000000..bb383c76
--- /dev/null
+++ b/bon-macros/src/map.rs
@@ -0,0 +1,22 @@
+use crate::util;
+use crate::util::prelude::*;
+use quote::quote;
+use syn::punctuated::Punctuated;
+use syn::Expr;
+use syn::Token;
+
+pub(crate) fn generate(entries: Punctuated<(Expr, Expr), Token![,]>) -> Result<TokenStream2> {
+    util::ensure_unique(entries.iter().map(|(k, _)| k))?;
+
+    let items = entries.into_iter().map(|(key, value)| {
+        let key = quote!(::core::convert::Into::into(#key));
+        let value = quote!(::core::convert::Into::into(#value));
+        quote!((#key, #value))
+    });
+
+    Ok(quote! {
+        ::core::iter::FromIterator::from_iter([
+            #(#items),*
+        ])
+    })
+}
diff --git a/bon-macros/src/set.rs b/bon-macros/src/set.rs
new file mode 100644
index 00000000..8408854a
--- /dev/null
+++ b/bon-macros/src/set.rs
@@ -0,0 +1,18 @@
+use crate::util;
+use crate::util::prelude::*;
+use quote::quote;
+use syn::punctuated::Punctuated;
+use syn::Expr;
+use syn::Token;
+
+pub(crate) fn generate(entries: Punctuated<Expr, Token![,]>) -> Result<TokenStream2> {
+    util::ensure_unique(entries.iter())?;
+
+    let entries = entries.into_iter();
+
+    Ok(quote! {
+        ::core::iter::FromIterator::from_iter([
+            #(::core::convert::Into::into(#entries)),*
+        ])
+    })
+}
diff --git a/bon-macros/src/util/mod.rs b/bon-macros/src/util/mod.rs
index de990f63..2c4d9fea 100644
--- a/bon-macros/src/util/mod.rs
+++ b/bon-macros/src/util/mod.rs
@@ -6,6 +6,10 @@ mod ty;
 
 use prelude::*;
 use proc_macro::TokenStream;
+use syn::parse::ParseStream;
+use syn::punctuated::Punctuated;
+use syn::Expr;
+use syn::Token;
 
 pub(crate) mod prelude {
     /// A handly alias for [`proc_macro2::TokenStream`].
@@ -45,6 +49,53 @@ where
     Ok((params, item))
 }
 
+pub(crate) fn parse_map_macro_input(
+    input: ParseStream<'_>,
+) -> Result<Punctuated<(Expr, Expr), Token![,]>, syn::Error> {
+    Punctuated::parse_terminated_with(input, parse_map_pair)
+}
+
+fn parse_map_pair(pair: ParseStream<'_>) -> Result<(Expr, Expr), syn::Error> {
+    let key = pair.parse().map_err(|_| pair.error(pair.to_string()))?;
+    let _: Token![:] = pair.parse()?;
+    let value = pair.parse()?;
+
+    Ok((key, value))
+}
+
+pub(crate) fn ensure_unique<'k, I>(items: I) -> Result<()>
+where
+    I: IntoIterator<Item = &'k Expr>,
+{
+    let mut errors = Error::accumulator();
+
+    let mut exprs = std::collections::HashSet::new();
+
+    items
+        .into_iter()
+        .filter(|item| is_pure(item))
+        .for_each(|key| {
+            if !exprs.insert(key.clone()) {
+                errors.push(err!(key, "duplicate map key"));
+            }
+        });
+
+    errors.finish()?;
+
+    Ok(())
+}
+
+fn is_pure(expr: &Expr) -> bool {
+    match expr {
+        Expr::Binary(binary) => is_pure(&binary.left) && is_pure(&binary.right),
+        Expr::Group(group) => is_pure(&group.expr),
+        Expr::Lit(_) => true,
+        Expr::Paren(paren) => is_pure(&paren.expr),
+        Expr::Unary(unary) => is_pure(&unary.expr),
+        _ => false,
+    }
+}
+
 /// Inspired by `anyhow::bail`, but returns a [`Result`] with [`darling::Error`].
 /// It accepts the value that implements [`syn::spanned::Spanned`] to attach the
 /// span to the error.
diff --git a/bon/src/lib.rs b/bon/src/lib.rs
index 752be22c..f27273b2 100644
--- a/bon/src/lib.rs
+++ b/bon/src/lib.rs
@@ -78,6 +78,7 @@ macro_rules! arr {
 
 #[cfg(test)]
 mod tests {
+
     #[test]
     fn arr_smoke() {
         let actual: [String; 3] = crate::arr!["foo", "bar", "baz"];
@@ -95,4 +96,52 @@ mod tests {
         let actual: Vec<String> = crate::vec![];
         assert!(actual.is_empty());
     }
+
+    #[test]
+    fn map_smoke() {
+        use std::collections::BTreeMap;
+        use std::collections::HashMap;
+
+        let hash_strings: HashMap<String, String> = crate::map! {
+            "Hello": "World",
+            "Goodbye": "Mars",
+        };
+
+        assert_eq!(hash_strings["Hello"], "World");
+        assert_eq!(hash_strings["Goodbye"], "Mars");
+
+        let tree_strings: BTreeMap<String, String> = crate::map! {
+            "Hello": "World",
+            "Goodbye": "Mars",
+        };
+
+        assert_eq!(tree_strings["Hello"], "World");
+        assert_eq!(tree_strings["Goodbye"], "Mars");
+    }
+
+    #[test]
+    fn set_smoke() {
+        use std::collections::BTreeSet;
+        use std::collections::HashSet;
+
+        let hash_strings: HashSet<String> = crate::set! {
+            "Hello", "World",
+            "Goodbye", "Mars",
+        };
+
+        assert!(hash_strings.contains("Hello"));
+        assert!(hash_strings.contains("World"));
+        assert!(hash_strings.contains("Goodbye"));
+        assert!(hash_strings.contains("Mars"));
+
+        let tree_strings: BTreeSet<String> = crate::set! {
+            "Hello", "World",
+            "Goodbye", "Mars",
+        };
+
+        assert!(tree_strings.contains("Hello"));
+        assert!(tree_strings.contains("World"));
+        assert!(tree_strings.contains("Goodbye"));
+        assert!(tree_strings.contains("Mars"));
+    }
 }
diff --git a/website/docs/guide/alternatives.md b/website/docs/guide/alternatives.md
index a05a7009..9d410da4 100644
--- a/website/docs/guide/alternatives.md
+++ b/website/docs/guide/alternatives.md
@@ -61,7 +61,7 @@ This feature isn't available today in `bon`, but it's planned for the future. Ho
 
 The problem of this feature is that a setter that pushes an element into a collection like that may confuse the reader in case if only one element is pushed. This may hide the fact that the member is actually is actually a collection called `friends` in plural. However, this feature is still useful to provide backwards-compatibility when changing the type of a member from `T` or `Option<T>` to `Collection<T>`.
 
-Alternatively, `bon` provides a separate solution. `bon` exposes a `bon::vec![]` macro (macros for other collections types are planned for future updates!) that includes automatic `Into` conversion for every item. So in `bon` syntax it would look like this:
+Alternatively, `bon` provides a separate solution. `bon` exposes a `bon::vec![]`, `bon::map!{}`, and `bon::set!` macros that include automatic `Into` conversion for every item. So in `bon` syntax it would look like this:
 
 ```rust
 use bon::builder;


Test patch: