elastio / bon

Next-gen compile-time-checked builder generator, named function's arguments, and more!
https://elastio.github.io/bon/
Apache License 2.0
1.14k stars 17 forks source link

Macros for other collections #28

Closed korrat closed 2 months ago

korrat commented 2 months ago

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?

Veetaha commented 2 months ago

Hi, thank you! Sure, this feature is planned. If you'd like to work on that, I'll share my design thoughts around the planned macros.

Context

Today, there is a very popular crate called maplit. People use it to have macros that construct a (Btree|Hash)Map/(BTree|Hash)Set. However, those macros are not as convenient as they could be. For example, if you want to create a BTreeMap<String, String>, the code you'd need to write with maplit is this verbose:

use maplit::btreemap;

let map = btreemap! {
    "foo".to_owned() => "bar".to_owned(),
    "baz".to_owned() => "bruh".to_owned(),
};

or alternatively, using the convert_args helper, which reduces the boilerplate, but still, that double macro nesting looks meh... It also requires that the braces for the child btreemap! macro invocations are round. This, for example: convert_args!(btreemap!{}) doesn't work, it works only with convert_args!(btreemap!()) (this was one of the most annoying to debug problem I've ever had! 😸)

use maplit::{convert_args, btreemap};

let map = convert_args!(btreemap!(
    "foo" => "bar",
    "baz" => "bruh",
});

Another thing that I dislike about maplit is the syntax of => separating the keys and the values. The fat arrow was selected as the separator due to the reasons described in maplit's docs itself:

It was not possible to use : as separator due to syntactic restrictions in regular macro_rules! macros

Summary

I'd like to fix all of the problems of maplit described above in bon. I understand that bon's main feature is generating builders, but these macros will be a nice addition to the API for those who are already committed to using bon for generating builders. Also, populating collections is quite often paired with the process of building, so I think these macros fit into the bon crate.

What macros I'd like to propose:

map!

This macro is very polymorphic. It can support basically any kind of map-like collection and even 3-rd party collections that may ever exist. Its only requirement is that the target collection needs to implement FromIterator<(Key, Value)>.

It also converts both the keys and values using the Into trait just like bon::vec! and bon::arr! already do. Of course, it requires type annotations, but I think this is fine.

use std::collections::{BTreeMap, HashMap};
use indexmap::IndexMap;

let map: BTreeMap<String, String> = bon::map! {
    "foo": "bar",
    "baz": "bruh",
};
let map: HashMap<String, String> = bon::map! {
    "foo": "bar",
    "baz": "bruh",
};
let map: IndexMap<String, String> = bon::map! {
    "foo": "bar",
    "baz": "bruh",
}

The body of the macro should be basically:

::std::iter::FromIterator::from_iter([ 
    #((::std::convert::Into::into(#key), ::std::convert::Into::into(#value))),*
])

Also notice that the macro uses a : as a delimiter instead of =>. This separator is more common in many languages. For example, serde_json::json!() uses it just fine. I also like it because it makes the map literal compatible with JSON syntax. For example, if people copy-paste some JSON into code, the only thing they would need to do is to add map! prefix to the JSON object literal to make it a *Map<String, String>.

However, the most important thing is that this macro as well as the set! macro (that I'll describe below), must be proc macros. Not only for the reason of switching => to : (which wouldn't be possible with macro_rules!), but also because I'd like these macros to be a bit smart.

For example a map!{} macro can validate at compile time that all keys in the map are unique. This will be really useful to detect typos in map keys and thus catch more bugs at compile time. I think this will be one of the selling features of this macro.

However, this raises the question of how to perform such validation.. How deep should the analysis be? That's something I haven't decided fully on yet. I guess we should try our best at detecting duplicate keys, but be conservative to make sure there are absolutely no false positives. Meaning the macro should never reject valid sequence of key expressions that are unique.

The minimum plan for key uniqueness validation is to check the for equality between literal values. For example, all the following examples should throw a compile error that there were duplicate keys found:

let map: BTreeMap<String, String> = bon::map! {
     "foo": "bar",
     "foo": "baz",
};

let map: BTreeMap<u32, String> = bon::map! {
     2: "bar",
     2: "baz",
};

let map: BTreeMap<bool, String> = bon::map! {
     true: "bar",
     true: "baz",
};

The maximum that we can do is to throw compile errors for expressions that involve primitive types:

let map: BTreeMap<u32, String> = bon::map! {
     1 + 1: "bar",
     1 + 1: "baz",
};

But let's not overdo it. I definitely don't want to have a full-fledged Rust interpreter in a proc macro. We can just check that the expression consisted only of primitive literals and operators and that the expression tokens for each key are fully equal.

For example, it's fine if we don't generate a compile error in this case:

let map: BTreeMap<u32, String> = bon::map! {
     2 + 1: "bar",
     1 + 2: "baz",
};

This is because the token sequence 2 + 1 isn't equal to 1 + 2. This will keep the macro logic simple, but still useful. Remember that the most frequent use case for such macros is just a *Map<String, String> where keys are string literals (and not some complex expressions). So this is our target problem to solve.

set!

This macro must follow the same principles of the map! macro. It should validate that all items in the set are unique, and it should perform Into conversions in them automatically. The compiler should infer the resulting set type from type annotations.

Example:

use std::collections::{BTreeSet, HashSet};
use indexmap::IndexSet;

let set: BTreeSet<String> = bon::set!["a", "b", "c"];
let set: HashSet<String> = bon::set!["a", "b", "c"];
let set: IndexSet<String> = bon::set!["a", "b", "c"];

The following examples should generate a compile error because duplicate items were detected in the set:

let map: BTreeSet<String> = bon::set!["foo", "foo", "bar"];
let map: BTreeSet<u32> = bon::set![5, 2, 6, 2];
let map: BTreeSet<bool> = bon::set![true, false, true];