Add `ui_macros` crate & `DerivePathStr` derive macro (#17811)

Nate Butler created

This PR adds the `ui_macros` crate to allow building supporting macros
for the `ui` crate.

Additionally, it implements the `DerivePathStr` derive macro and the
`path_str` attribute macro. These macros work together to generate a
`path` method for enum variants, which is useful for creating
standardized string representations of enum variants.

The `DerivePathStr` macro provides the following functionality:
- Generates a `path` method for each enum variant.
- Allows specifying a prefix (required) and suffix (optional) for all
paths.
- Supports `strum` attributes for case conversion (e.g., snake_case,
lowercase).

Usage example:

```rust
#[derive(DerivePathStr)]
#[path_str(prefix = "my_prefix", suffix = ".txt")]
#[strum(serialize_all = "snake_case")]
enum MyEnum {
    VariantOne,
    VariantTwo,
}

// Generated paths:
// MyEnum::VariantOne.path() -> "my_prefix/variant_one.txt"
// MyEnum::VariantTwo.path() -> "my_prefix/variant_two.txt"
```

In a later PR this will be used to automate the creation of icon & image
paths in the `ui` crate.

This gives the following benefits:

1. Ensures standard naming of assets as paths are not manually
specified.
2. Makes adding new enum variants less tedious and error-prone.
3. Quickly catches missing or incorrect paths during compilation.
3. Adds a building block towards being able to lint for unused assets in
the future.

Release Notes:

- N/A

Change summary

Cargo.lock                              |  11 ++
Cargo.toml                              |   3 
crates/editor/Cargo.toml                |   2 
crates/ui/Cargo.toml                    |   1 
crates/ui/src/path_str.rs               |  33 ++++++++
crates/ui/src/ui.rs                     |   1 
crates/ui_macros/Cargo.toml             |  19 ++++
crates/ui_macros/LICENSE-GPL            |   1 
crates/ui_macros/src/derive_path_str.rs | 105 +++++++++++++++++++++++++++
crates/ui_macros/src/ui_macros.rs       |  53 +++++++++++++
10 files changed, 228 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -12273,6 +12273,7 @@ dependencies = [
  "story",
  "strum 0.25.0",
  "theme",
+ "ui_macros",
  "windows 0.58.0",
 ]
 
@@ -12287,6 +12288,16 @@ dependencies = [
  "ui",
 ]
 
+[[package]]
+name = "ui_macros"
+version = "0.1.0"
+dependencies = [
+ "convert_case 0.6.0",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "unicase"
 version = "2.7.0"

Cargo.toml 🔗

@@ -118,6 +118,7 @@ members = [
     "crates/title_bar",
     "crates/ui",
     "crates/ui_input",
+    "crates/ui_macros",
     "crates/util",
     "crates/vcs_menu",
     "crates/vim",
@@ -292,6 +293,7 @@ time_format = { path = "crates/time_format" }
 title_bar = { path = "crates/title_bar" }
 ui = { path = "crates/ui" }
 ui_input = { path = "crates/ui_input" }
+ui_macros = { path = "crates/ui_macros" }
 util = { path = "crates/util" }
 vcs_menu = { path = "crates/vcs_menu" }
 vim = { path = "crates/vim" }
@@ -333,6 +335,7 @@ chrono = { version = "0.4", features = ["serde"] }
 clap = { version = "4.4", features = ["derive"] }
 clickhouse = "0.11.6"
 cocoa = "0.26"
+convert_case = "0.6.0"
 core-foundation = "0.9.3"
 core-foundation-sys = "0.8.6"
 ctor = "0.2.6"

crates/editor/Cargo.toml 🔗

@@ -35,7 +35,7 @@ chrono.workspace = true
 client.workspace = true
 clock.workspace = true
 collections.workspace = true
-convert_case = "0.6.0"
+convert_case.workspace = true
 db.workspace = true
 emojis.workspace = true
 file_icons.workspace = true

crates/ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ smallvec.workspace = true
 story = { workspace = true, optional = true }
 strum = { workspace = true, features = ["derive"] }
 theme.workspace = true
+ui_macros.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/ui/src/path_str.rs 🔗

@@ -0,0 +1,33 @@
+#[cfg(test)]
+mod tests {
+    use strum::EnumString;
+    use ui_macros::{path_str, DerivePathStr};
+
+    #[test]
+    fn test_derive_path_str_with_prefix() {
+        #[derive(Debug, EnumString, DerivePathStr)]
+        #[strum(serialize_all = "snake_case")]
+        #[path_str(prefix = "test_prefix")]
+        enum MyEnum {
+            FooBar,
+            Baz,
+        }
+
+        assert_eq!(MyEnum::FooBar.path(), "test_prefix/foo_bar");
+        assert_eq!(MyEnum::Baz.path(), "test_prefix/baz");
+    }
+
+    #[test]
+    fn test_derive_path_str_with_prefix_and_suffix() {
+        #[derive(Debug, EnumString, DerivePathStr)]
+        #[strum(serialize_all = "snake_case")]
+        #[path_str(prefix = "test_prefix", suffix = ".txt")]
+        enum MyEnum {
+            FooBar,
+            Baz,
+        }
+
+        assert_eq!(MyEnum::FooBar.path(), "test_prefix/foo_bar.txt");
+        assert_eq!(MyEnum::Baz.path(), "test_prefix/baz.txt");
+    }
+}

crates/ui/src/ui.rs 🔗

@@ -8,6 +8,7 @@ mod components;
 mod disableable;
 mod fixed;
 mod key_bindings;
+mod path_str;
 pub mod prelude;
 mod selectable;
 mod styled_ext;

crates/ui_macros/Cargo.toml 🔗

@@ -0,0 +1,19 @@
+[package]
+name = "ui_macros"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/ui_macros.rs"
+proc-macro = true
+
+[dependencies]
+proc-macro2 = "1.0.66"
+quote = "1.0.9"
+syn = { version = "1.0.72", features = ["full", "extra-traits"] }
+convert_case.workspace = true

crates/ui_macros/src/derive_path_str.rs 🔗

@@ -0,0 +1,105 @@
+use convert_case::{Case, Casing};
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{parse_macro_input, Attribute, Data, DeriveInput, Lit, Meta, NestedMeta};
+
+pub fn derive_path_str(input: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(input as DeriveInput);
+    let name = &input.ident;
+
+    let prefix = get_attr_value(&input.attrs, "prefix").expect("prefix attribute is required");
+    let suffix = get_attr_value(&input.attrs, "suffix").unwrap_or_else(|| "".to_string());
+
+    let serialize_all = get_strum_serialize_all(&input.attrs);
+    let path_str_impl = impl_path_str(name, &input.data, &prefix, &suffix, serialize_all);
+
+    let expanded = quote! {
+        impl #name {
+            pub fn path(&self) -> &'static str {
+                #path_str_impl
+            }
+        }
+    };
+
+    TokenStream::from(expanded)
+}
+
+fn impl_path_str(
+    name: &syn::Ident,
+    data: &Data,
+    prefix: &str,
+    suffix: &str,
+    serialize_all: Option<String>,
+) -> proc_macro2::TokenStream {
+    match *data {
+        Data::Enum(ref data) => {
+            let match_arms = data.variants.iter().map(|variant| {
+                let ident = &variant.ident;
+                let variant_name = if let Some(ref case) = serialize_all {
+                    match case.as_str() {
+                        "snake_case" => ident.to_string().to_case(Case::Snake),
+                        "lowercase" => ident.to_string().to_lowercase(),
+                        _ => ident.to_string(),
+                    }
+                } else {
+                    ident.to_string()
+                };
+                let path = format!("{}/{}{}", prefix, variant_name, suffix);
+                quote! {
+                    #name::#ident => #path,
+                }
+            });
+
+            quote! {
+                match self {
+                    #(#match_arms)*
+                }
+            }
+        }
+        _ => panic!("DerivePathStr only supports enums"),
+    }
+}
+
+fn get_strum_serialize_all(attrs: &[Attribute]) -> Option<String> {
+    attrs
+        .iter()
+        .filter(|attr| attr.path.is_ident("strum"))
+        .find_map(|attr| {
+            if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
+                meta_list.nested.iter().find_map(|nested_meta| {
+                    if let NestedMeta::Meta(Meta::NameValue(name_value)) = nested_meta {
+                        if name_value.path.is_ident("serialize_all") {
+                            if let Lit::Str(lit_str) = &name_value.lit {
+                                return Some(lit_str.value());
+                            }
+                        }
+                    }
+                    None
+                })
+            } else {
+                None
+            }
+        })
+}
+
+fn get_attr_value(attrs: &[Attribute], key: &str) -> Option<String> {
+    attrs
+        .iter()
+        .filter(|attr| attr.path.is_ident("path_str"))
+        .find_map(|attr| {
+            if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
+                meta_list.nested.iter().find_map(|nested_meta| {
+                    if let NestedMeta::Meta(Meta::NameValue(name_value)) = nested_meta {
+                        if name_value.path.is_ident(key) {
+                            if let Lit::Str(lit_str) = &name_value.lit {
+                                return Some(lit_str.value());
+                            }
+                        }
+                    }
+                    None
+                })
+            } else {
+                None
+            }
+        })
+}

crates/ui_macros/src/ui_macros.rs 🔗

@@ -0,0 +1,53 @@
+mod derive_path_str;
+
+use proc_macro::TokenStream;
+
+/// Derives the `path` method for an enum.
+///
+/// This macro generates a `path` method for each variant of the enum, which returns a string
+/// representation of the enum variant's path. The path is constructed using a prefix and
+/// optionally a suffix, which are specified using attributes.
+///
+/// # Attributes
+///
+/// - `#[path_str(prefix = "...")]`: Required. Specifies the prefix for all paths.
+/// - `#[path_str(suffix = "...")]`: Optional. Specifies a suffix for all paths.
+/// - `#[strum(serialize_all = "...")]`: Optional. Specifies the case conversion for variant names.
+///
+/// # Example
+///
+/// ```
+/// use strum::EnumString;
+/// use ui_macros::{path_str, DerivePathStr};
+///
+/// #[derive(EnumString, DerivePathStr)]
+/// #[path_str(prefix = "my_prefix", suffix = ".txt")]
+/// #[strum(serialize_all = "snake_case")]
+/// enum MyEnum {
+///     VariantOne,
+///     VariantTwo,
+/// }
+///
+/// // These assertions would work if we could instantiate the enum
+/// // assert_eq!(MyEnum::VariantOne.path(), "my_prefix/variant_one.txt");
+/// // assert_eq!(MyEnum::VariantTwo.path(), "my_prefix/variant_two.txt");
+/// ```
+///
+/// # Panics
+///
+/// This macro will panic if used on anything other than an enum.
+#[proc_macro_derive(DerivePathStr, attributes(path_str))]
+pub fn derive_path_str(input: TokenStream) -> TokenStream {
+    derive_path_str::derive_path_str(input)
+}
+
+/// A marker attribute for use with `DerivePathStr`.
+///
+/// This attribute is used to specify the prefix and suffix for the `path` method
+/// generated by `DerivePathStr`. It doesn't modify the input and is only used as a
+/// marker for the derive macro.
+#[proc_macro_attribute]
+pub fn path_str(_args: TokenStream, input: TokenStream) -> TokenStream {
+    // This attribute doesn't modify the input, it's just a marker
+    input
+}