Skip to content
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

Add Enum Map Derive Macro #279

Merged
merged 24 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
16225b6
added enum map macro and lib.rs test
PokeJofeJr4th May 19, 2023
0fc2a6b
Merged in some of June's code
PokeJofeJr4th Jun 24, 2023
0ab13a4
Merge remote-tracking branch 'peternator/master'
PokeJofeJr4th Jun 24, 2023
8e488b2
added features #1, #2, #3
PokeJofeJr4th Jun 24, 2023
8e7aa05
added tests, fixed bad code from last commit
PokeJofeJr4th Jun 25, 2023
3ccbb3c
format
PokeJofeJr4th Jun 25, 2023
7bd40a3
added `all` and `all_ok` for options and results
PokeJofeJr4th Jun 25, 2023
6112e6d
Include EnumMap in README.md
PokeJofeJr4th Jun 25, 2023
b66a612
updated documentation
PokeJofeJr4th Jun 25, 2023
8f63bdf
Merge branch 'master' of https://github.com/PokeJofeJr4th/strum
PokeJofeJr4th Jun 25, 2023
510a160
implemented 2nd solution to #5 - panic when indexed
PokeJofeJr4th Jun 25, 2023
6a20524
Remove commented get and set
PokeJofeJr4th Jun 28, 2023
e4ec66c
used snakify from `enum_is`, used `format_ident` macro
PokeJofeJr4th Jun 28, 2023
c3eed1c
fixed format_ident
PokeJofeJr4th Jun 28, 2023
5f2f1c1
add test for `clone`, change `all_ok` to return `Result`
PokeJofeJr4th Jun 30, 2023
69da326
add error for empty enums (resolves #7), added test for keyword colli…
PokeJofeJr4th Jul 1, 2023
f283ab2
escape identifiers with `_` to prevent keyword name collisions
PokeJofeJr4th Jul 17, 2023
5d65bfc
rename EnumMap to EnumTable
PokeJofeJr4th Jul 17, 2023
7a16006
Merge branch 'Peternator7:master' into master
PokeJofeJr4th Jul 29, 2023
d68b5ba
Merge branch 'Peternator7:master' into master
PokeJofeJr4th Aug 16, 2023
c4d4ca5
absolute paths, get rid of `let ... else`
PokeJofeJr4th Aug 27, 2023
f6ed414
Merge https://github.com/Peternator7/strum
PokeJofeJr4th Aug 27, 2023
5df0666
Replace format macro invocations with arguments rather than inline so…
PokeJofeJr4th Aug 27, 2023
b965277
Merge branch 'Peternator7:master' into master
PokeJofeJr4th Dec 7, 2023
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Strum has implemented the following macros:
| [IntoStaticStr] | Implements `From<MyEnum> for &'static str` on an enum |
| [EnumVariantNames] | Adds an associated `VARIANTS` constant which is an array of discriminant names |
| [EnumIter] | Creates a new type that iterates of the variants of an enum. |
| [EnumMap] | Creates a new type that stores an item of a specified type for each variant of the enum. |
| [EnumProperty] | Add custom properties to enum variants. |
| [EnumMessage] | Add a verbose message to an enum variant. |
| [EnumDiscriminants] | Generate a new type with only the discriminant names. |
Expand Down
38 changes: 38 additions & 0 deletions strum_macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,44 @@ pub fn enum_is(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
toks.into()
}

/// Creates a new type that maps all the variants of an enum to another generic value.
///
/// This macro does not support any additional data on your variants.
/// The macro creates a new type called `YourEnumMap<T>`.
/// The map has a field of type `T` for each variant of `YourEnum`. The map automatically implements `Index<T>` and `IndexMut<T>`.
/// ```
/// use strum_macros::EnumMap;
///
/// #[derive(EnumMap)]
/// enum Color {
/// Red,
/// Yellow,
/// Green,
/// Blue,
/// }
///
/// assert_eq!(ColorMap::default(), ColorMap::new(0, 0, 0, 0));
/// assert_eq!(ColorMap::filled(2), ColorMap::new(2, 2, 2, 2));
/// assert_eq!(ColorMap::from_closure(|_| 3), ColorMap::new(3, 3, 3, 3));
/// assert_eq!(ColorMap::default().transform(|_, val| val + 2), ColorMap::new(2, 2, 2, 2));
///
/// let mut complex_map = ColorMap::from_closure(|color| match color {
/// Color::Red => 0,
/// _ => 3
/// });
/// complex_map[Color::Green] = complex_map[Color::Red];
/// assert_eq!(complex_map, ColorMap::new(0, 3, 0, 3));
///
/// ```
#[proc_macro_derive(EnumMap, attributes(strum))]
pub fn enum_map(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ast = syn::parse_macro_input!(input as DeriveInput);

let toks = macros::enum_map::enum_map_inner(&ast).unwrap_or_else(|err| err.to_compile_error());
debug_print_generated(&ast, &toks);
toks.into()
}

/// Add a function to enum that allows accessing variants by its discriminant
///
/// This macro adds a standalone function to obtain an enum variant by its discriminant. The macro adds
Expand Down
217 changes: 217 additions & 0 deletions strum_macros/src/macros/enum_map.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote};
use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident};

use crate::helpers::{non_enum_error, HasStrumVariantProperties};

pub fn enum_map_inner(ast: &DeriveInput) -> syn::Result<TokenStream> {
let name = &ast.ident;
let gen = &ast.generics;
let vis = &ast.vis;
let mut doc_comment = format!("A map over the variants of [{}]", name);

if gen.lifetimes().count() > 0 {
return Err(syn::Error::new(
Span::call_site(),
"This macro doesn't support enums with lifetimes.",
));
}

let Data::Enum(data_enum) = &ast.data else {
return Err(non_enum_error())
};
let map_name = syn::parse_str::<Ident>(&format!("{}Map", name)).unwrap();
PokeJofeJr4th marked this conversation as resolved.
Show resolved Hide resolved

let variants = &data_enum.variants;

// the identifiers of each variant, in PascalCase
let mut pascal_idents = Vec::new();
// the identifiers of each struct field, in snake_case
let mut snake_idents = Vec::new();
// match arms in the form `MyEnumMap::Variant => &self.variant,`
let mut get_matches = Vec::new();
// match arms in the form `MyEnumMap::Variant => &mut self.variant,`
let mut get_matches_mut = Vec::new();
// match arms in the form `MyEnumMap::Variant => self.variant = new_value`
let mut set_matches = Vec::new();
// struct fields of the form `variant: func(MyEnum::Variant),*
let mut closure_fields = Vec::new();
// struct fields of the form `variant: func(MyEnum::Variant, self.variant),`
let mut transform_fields = Vec::new();

// identifiers for disabled variants
let mut disabled_variants = Vec::new();
// match arms for disabled variants
let mut disabled_matches = Vec::new();

for variant in variants {
// skip disabled variants
Copy link
Contributor

Choose a reason for hiding this comment

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

Having a variant that could be disabled and cause a panic if indexed with makes me nervous, but it seems to be in line with what this crate normally does. I feel like it would make more sense, though, to ignore the disabled attribute for this specific macro (among others, like AsRefStr) since generating code for the ignored attributes can't cause any harm.

If they already weren't going to index into this map with that variant (e.g. they were using EnumIter with a disabled variant to iterate through all the variants and modify this EnumMap with each variant), then there's no harm in not panicking if they try to index with what would be a 'disabled' variant. It could cause confusing behavior if they accidentally do index into this with the disabled variant (since it would basically do nothing instead of panicking), but I feel like that's much better behavior than panicking. Perhaps this comment should be made into an issue instead to discuss there.

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 think that unexpected behavior would be more detrimental to a project than a panic, since the panic is obvious what the problem is

if variant.get_variant_properties()?.disabled.is_some() {
let disabled_ident = &variant.ident;
let panic_message =
format!("Can't use `{disabled_ident}` with `{map_name}` - variant is disabled for Strum features");
disabled_variants.push(disabled_ident);
disabled_matches.push(quote!(#name::#disabled_ident => panic!(#panic_message),));
continue;
}

// Error on fields with data
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with this decision - calling this a map implies that every possible variant has its own value and if you have a variant that has data (e.g. Name(&'static str)) it could potentially be confusing if indexing with that variant but different data (e.g. map[Name("John")] vs map[Name("Jane")]) returns/modifies the same value.

However, this doesn't exactly seem to go in line with what this crate normally does - e.g. with EnumIter, it just generates default::Default() for each field if a variant has data (instead of refusing to expand the macro). If we wanted to keep behavior similar to the other derive macros here, we might want to still expand the macro but just ignore all data on each variant and only match on variant type. I can't think of a specific use-case where allowing data would be necessary, so I'm still in favor of keeping this as-is, but I'm not quite certain what other contributors would be in favor of.

let Fields::Unit = &variant.fields else {
return Err(syn::Error::new(
variant.fields.span(),
"This macro doesn't support enums with non-unit variants",
))
};

let pascal_case = &variant.ident;
// switch PascalCase to snake_case. This naively assumes they use PascalCase
let snake_case = format_ident!(
PokeJofeJr4th marked this conversation as resolved.
Show resolved Hide resolved
"{}",
pascal_case
.to_string()
.chars()
.enumerate()
.fold(String::new(), |mut s, (i, c)| {
if c.is_uppercase() && i > 0 {
s.push('-');
}
s.push(c.to_ascii_lowercase());
s
})
);

get_matches.push(quote! {#name::#pascal_case => &self.#snake_case,});
get_matches_mut.push(quote! {#name::#pascal_case => &mut self.#snake_case,});
set_matches.push(quote! {#name::#pascal_case => self.#snake_case = new_value,});
closure_fields.push(quote! {#snake_case: func(#name::#pascal_case),});
transform_fields.push(quote! {#snake_case: func(#name::#pascal_case, &self.#snake_case),});
pascal_idents.push(pascal_case);
snake_idents.push(snake_case);
}

// if the index operation can panic, add that to the documentation
if !disabled_variants.is_empty() {
doc_comment.push_str(&format!(
"\n# Panics\nIndexing `{map_name}` with any of the following variants will cause a panic:"
));
for variant in disabled_variants {
doc_comment.push_str(&format!("\n\n- `{name}::{variant}`"));
}
}

let doc_new = format!("Create a new {map_name} with a value for each variant of {name}");
let doc_closure =
format!("Create a new {map_name} by running a function on each variant of `{name}`");
let doc_transform = format!("Create a new `{map_name}` by running a function on each variant of `{name}` and the corresponding value in the current `{map_name}`");
let doc_filled = format!("Create a new `{map_name}` with the same value in each field.");
let doc_option_all = format!("Converts `{map_name}<Option<T>>` into `Option<{map_name}<T>>`. Returns `Some` if all fields are `Some`, otherwise returns `None`.");
let doc_result_all_ok = format!("Converts `{map_name}<Result<T, E>>` into `Option<{map_name}>`. Returns `Some` if all fields are `Ok`, otherwise returns `None`.");
PokeJofeJr4th marked this conversation as resolved.
Show resolved Hide resolved

Ok(quote! {
#[doc = #doc_comment]
#[allow(
missing_copy_implementations,
)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#vis struct #map_name<T> {
#(#snake_idents: T,)*
}

impl<T: Clone> #map_name<T> {
#[doc = #doc_filled]
#vis fn filled(value: T) -> #map_name<T> {
#map_name {
#(#snake_idents: value.clone(),)*
}
}
}

impl<T> #map_name<T> {
#[doc = #doc_new]
#vis fn new(
#(#snake_idents: T,)*
) -> #map_name<T> {
#map_name {
#(#snake_idents,)*
}
}

#[doc = #doc_closure]
#vis fn from_closure<F: Fn(#name)->T>(func: F) -> #map_name<T> {
#map_name {
#(#closure_fields)*
}
}

#[doc = #doc_transform]
#vis fn transform<U, F: Fn(#name, &T)->U>(&self, func: F) -> #map_name<U> {
#map_name {
#(#transform_fields)*
}
}

// // E.g. so that if you're using EnumIter as well, these functions work nicely
// fn get(&self, variant: #name) -> &T {
// match variant {
// #(#get_matches)*
// }
// }

// fn set(&mut self, variant: #name, new_value: T) {
// match variant {
// #(#set_matches)*
// }
// }
PokeJofeJr4th marked this conversation as resolved.
Show resolved Hide resolved
}

impl<T> core::ops::Index<#name> for #map_name<T> {
type Output = T;

fn index(&self, idx: #name) -> &T {
match idx {
#(#get_matches)*
#(#disabled_matches)*
}
}
}

impl<T> core::ops::IndexMut<#name> for #map_name<T> {
fn index_mut(&mut self, idx: #name) -> &mut T {
match idx {
#(#get_matches_mut)*
#(#disabled_matches)*
}
}
}

impl<T> #map_name<Option<T>> {
#[doc = #doc_option_all]
#vis fn all(self) -> Option<#map_name<T>> {
if let #map_name {
#(#snake_idents: Some(#snake_idents),)*
} = self {
Some(#map_name {
#(#snake_idents,)*
})
} else {
None
}
}
}

impl<T, E> #map_name<Result<T, E>> {
#[doc = #doc_result_all_ok]
#vis fn all_ok(self) -> Option<#map_name<T>> {
if let #map_name {
#(#snake_idents: Ok(#snake_idents),)*
} = self {
Some(#map_name {
#(#snake_idents,)*
})
} else {
None
}
}
}
})
}
1 change: 1 addition & 0 deletions strum_macros/src/macros/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod enum_count;
pub mod enum_discriminants;
pub mod enum_is;
pub mod enum_iter;
pub mod enum_map;
pub mod enum_messages;
pub mod enum_properties;
pub mod enum_variant_names;
Expand Down
87 changes: 87 additions & 0 deletions strum_tests/tests/enum_map.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use strum::EnumMap;

#[derive(EnumMap)]
enum Color {
Red,
Yellow,
Green,
#[strum(disabled)]
Teal,
Blue,
#[strum(disabled)]
Indigo,
}

#[test]
fn default() {
assert_eq!(ColorMap::default(), ColorMap::new(0, 0, 0, 0));
}

#[test]
#[should_panic]
fn disabled() {
let _ = ColorMap::<u8>::default()[Color::Indigo];
}

#[test]
fn filled() {
assert_eq!(ColorMap::filled(42), ColorMap::new(42, 42, 42, 42));
}

#[test]
fn from_closure() {
assert_eq!(
ColorMap::from_closure(|color| match color {
Color::Red => 1,
_ => 2,
}),
ColorMap::new(1, 2, 2, 2)
);
}

#[test]
fn index() {
let map = ColorMap::new(18, 25, 7, 2);
assert_eq!(map[Color::Red], 18);
assert_eq!(map[Color::Yellow], 25);
assert_eq!(map[Color::Green], 7);
assert_eq!(map[Color::Blue], 2);
}

#[test]
fn index_mut() {
let mut map = ColorMap::new(18, 25, 7, 2);
map[Color::Green] = 5;
map[Color::Red] *= 4;
assert_eq!(map[Color::Green], 5);
assert_eq!(map[Color::Red], 72);
}

#[test]
fn option_all() {
let mut map: ColorMap<Option<u8>> = ColorMap::filled(None);
map[Color::Red] = Some(64);
map[Color::Green] = Some(32);
map[Color::Blue] = Some(16);

assert_eq!(map.clone().all(), None);

map[Color::Yellow] = Some(8);
assert_eq!(map.all(), Some(ColorMap::new(64, 8, 32, 16)));
}

#[test]
fn result_all_ok() {
let mut map: ColorMap<Result<u8, u8>> = ColorMap::filled(Ok(4));
assert_eq!(map.clone().all_ok(), Some(ColorMap::filled(4)));

map[Color::Red] = Err(22);

assert_eq!(map.all_ok(), None);
}

#[test]
fn transform() {
let all_two = ColorMap::filled(2);
assert_eq!(all_two.transform(|_, n| *n * 2), ColorMap::filled(4));
}