Skip to content

[FEATURE]: Generics, lifetimes, where clauses and internal field variant formatting #5

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

jngls
Copy link

@jngls jngls commented Jun 19, 2025

This is a considerable update of the proc macro which supports optional custom format strings per variant with full access to variant fields via the #[variant_display(format = "xyz")] attribute.

I recommend reading the code in isolation rather than as a diff.

Summary

  • Added EnumAttrs which handles parsing of the main enum_display attribute
  • Added VariantAttrs which handles parsing of the new variant_display field attribute
  • variant_display field attribute allows the user to customise each variant
    • The format = "xyz" argument defines a custom display format (see below for Format Behaviour) - if omitted, the original crate behaviour is used
  • Introduced a new Intermediate Representation (IR) to hold variant info and allow for more complex processing:
    • VariantInfo holds properties common to all variant types
    • NamedVariantIR, UnnamedVariantIR, and UnitVariantIR are the intermediate representations of variant types
    • VariantIR wraps all of those
  • Pipeline updated to account for new Intermediate Representation
    • Parsing of syn tokens is moved to the from_*() methods of EnumAttrs, VariantAttrs, and VariantIR
    • We detect if any variant specifies a "format" string - this is to ensure all match arms return the same type
    • Finally we generate match arm tokens with the VariantIR::gen() method
  • Tests have been updated

Format Behaviour

Format strings behave almost identically to using format!() in normal rust code so it should be very familiar to users. Besides a bit of translation of unnamed fields, the string specified by the user is the string that ends up as the parameter to format!() in each match arm.

The following considerations apply:

  • To print the case adjusted variant name, you use the {variant} placeholder
  • To print named variant fields, you use the field name placeholder, eg. if we have Variant { my_field: u32 }, you use the {my_field} placeholder
  • To print unnamed variant fields, you use a numbered placeholder, eg. if we have Variant(u32), you use the {0} placeholder (to access the first tuple element)
  • It is not currently possible to access fields within fields - each field must implment its own Display or Debug
  • Most of the common inline format specs are supported, eg. {my_field:?} displays my_field using its Debug implementation, {0:5} displays the first unnamed field with a width of 5 - however - format spec options which depend on extra arguments passed into format!() are not supported

Additional Notes

Full examples

#[derive(EnumDisplay)]
enum TestEnum {
    // Displayed as "Name"
    Name,

    // Displayed as "Unit: NameFullFormat"
    #[variant_display(format = "Unit: {variant}")]
    NameFullFormat,

    // Displayed as "Address"
    Address {
        street: String,
        city: String,
        state: String,
        zip: String,
    },

    // Displayed as "Named: AddressPartialFormat {123 Main St, 12345}"
    #[variant_display(format = "Named: {variant} {{{street}, {zip}}}")]
    AddressPartialFormat {
        street: String,
        city: String,
        state: String,
        zip: String,
    },

    // Displayed as "Named: AddressFullFormat {123 Main St, Any Town, CA, 12345}"
    #[variant_display(format = "Named: {variant} {{{street}, {city}, {state}, {zip}}}")]
    AddressFullFormat {
        street: String,
        city: String,
        state: String,
        zip: String,
    },

    // Displayed as "DateOfBirth"
    DateOfBirth(u32, u32, u32),

    // Displayed as "Unnamed: DateOfBirthPartialFormat(1999)"
    #[variant_display(format = "Unnamed: {variant}({2})")]
    DateOfBirthPartialFormat(u32, u32, u32),

    // Displayed as "Unnamed: DateOfBirthFullFormat(1, 2, 1999)"
    #[variant_display(format = "Unnamed: {variant}({0}, {1}, {2})")]
    DateOfBirthFullFormat(u32, u32, u32),
}

#[test]
fn test_unit_field_variant() {
    assert_eq!(TestEnum::Name.to_string(), "Name");
    assert_eq!(TestEnum::NameFullFormat.to_string(), "Unit: NameFullFormat");
}

#[test]
fn test_named_fields_variant() {
    assert_eq!(
        TestEnum::Address {
            street: "123 Main St".to_string(),
            city: "Any Town".to_string(),
            state: "CA".to_string(),
            zip: "12345".to_string()
        }
        .to_string(),
        "Address"
    );
    assert_eq!(
        TestEnum::AddressPartialFormat {
            street: "123 Main St".to_string(),
            city: "Any Town".to_string(),
            state: "CA".to_string(),
            zip: "12345".to_string()
        }
        .to_string(),
        "Named: AddressPartialFormat {123 Main St, 12345}"
    );
    assert_eq!(
        TestEnum::AddressFullFormat {
            street: "123 Main St".to_string(),
            city: "Any Town".to_string(),
            state: "CA".to_string(),
            zip: "12345".to_string()
        }
        .to_string(),
        "Named: AddressFullFormat {123 Main St, Any Town, CA, 12345}"
    );
}

#[test]
fn test_unnamed_fields_variant() {
    assert_eq!(TestEnum::DateOfBirth(1, 2, 1999).to_string(), "DateOfBirth");
    assert_eq!(TestEnum::DateOfBirthPartialFormat(1, 2, 1999).to_string(), "Unnamed: DateOfBirthPartialFormat(1999)");
    assert_eq!(TestEnum::DateOfBirthFullFormat(1, 2, 1999).to_string(), "Unnamed: DateOfBirthFullFormat(1, 2, 1999)");
}

@jngls
Copy link
Author

jngls commented Jun 19, 2025

Simplified the code a bit. It was previously checking that the user actually referenced each field before emitting variables for them, but this adds unnecessary complexity. We can just emit all field variables and let the compiler decide if they're unused.

@jngls
Copy link
Author

jngls commented Jun 19, 2025

Btw, my specific use case was this:

#[derive(Debug, Clone, PartialEq, Eq, EnumDisplay)]
#[enum_display(case = "Lower")]
pub enum ParserErrorKind<I> {
    NomError(ErrorKind),
    #[variant_display(format = "{variant}: {0}")]
    Keyword(Keyword),
    Ident(Ident<I>),
    ...
}

Where I wanted to provide a general impl of Display for most variants, but be specific for required Keywords.

@SeedyROM
Copy link
Owner

SeedyROM commented Jul 13, 2025

Thanks for submitting these PRs! Both the generics impl and this. I'll have time next weekend to actually look at this and critique. From the 1000 mile overview it looks like a solid addition to this library.

It seems like the build pipeline is currently failing and I'll investigate that more as well. Is it possibly due to different version of rustc or possibly the implementation itself??? I never intended this project to get this deep into generics and lifetimes so I might be a bit naive on if this code is correct or not.

Thanks again!

@SeedyROM SeedyROM changed the title Formatting [FEATURE]: Generics, lifetimes, where clauses and internal field variant formatting. Jul 13, 2025
@SeedyROM SeedyROM changed the title [FEATURE]: Generics, lifetimes, where clauses and internal field variant formatting. [FEATURE]: Generics, lifetimes, where clauses and internal field variant formatting Jul 13, 2025
@SeedyROM SeedyROM added the enhancement New feature or request label Jul 13, 2025
@jngls
Copy link
Author

jngls commented Jul 13, 2025

Cheers! Glad you like it on the surface.

The generics side of things was actually really simple (almost like the code equivalent of copy paste - you take the generics from syn and forward them on), so I'd be surprised if that was at fault.

I've had a quick look at the build issue and it's very weird. Does indeed look like maybe a version mismatch.

Anyway, yeah, like you, I'll be able to spend more time on this next weekend.

SeedyROM added 3 commits July 14, 2025 00:48
- Bump version number before publish
- `gen` is a reserved keyword: https://doc.rust-lang.org/edition-guide/rust-2024/gen-keyword.html
- Rename all functions named `gen` to `generate`
- Allow for local testing via workspaces, however when publishing the local dep needs to be replaced with the published version
   - This allowed for the actual proc_macro code changes to propegate into the local build.
@SeedyROM
Copy link
Owner

@jngls I created a branch on top of your changes you pushed here and have got the CI/CD pipeline building and fixed a few other things here: #6. You can see the fixes that were needed.

I'm unfortunately not a git master, so I think some of the history got borked with my changes being pushed upstream to my repo from your fork/branch. Feel free to add my commits to your PR and it works great if you want to properly preserve your impact on the codebase.

Thanks again for the great code and suggestions here. I hope my fixes are good enough for you!

@SeedyROM
Copy link
Owner

Almost forgot, we should update the docs if there's anything missing and the README.md so that all the latest features of the API are visible to everyone using the crate! Just saying this outloud so neither of us forget.

- Rename `variant_display` to `display`
- Support named argument `format=` and first argument for format.
@SeedyROM
Copy link
Owner

SeedyROM commented Aug 1, 2025

I made some changes in #6 based on #7, I've renamed variant_display to just display, I've also allowed for #[display("fmt")] and #[display(format = "fmt")]

@jngls
Copy link
Author

jngls commented Aug 1, 2025

Yo sorry, work was crazy lately. I finally have a day to myself, so I'll look at this now. Need to catch up on what you've done.

@jngls
Copy link
Author

jngls commented Aug 1, 2025

Yeah, your changes look great to me. I was never happy with the long variant_display attribute name and only named it that way to be similar in format to enum_display. display works for me.

With the build issue, good fix. I remember now I had to locally change the dependency to path to get the tests to build.

I don't see anything borked in the git history. Just a bunch of autoformat, which is fine. Looks good to me. I'll merge your changes into my branch, test locally, and then start updating docs.

@jngls
Copy link
Author

jngls commented Aug 1, 2025

Right. I've merged your changes into this PR and added a little doc example.

I want to consider this a little before you merge and publish a version:

I've left it to the user to decide if their target platform supports format!() - perhaps instead, we should extern alloc and hide it behind a feature flag?

I'm not 100% sure what the right course of action is so need to read up. Since the macro might inject a format!() invokation into user code, it could cause issues in no_std environments.

@jngls
Copy link
Author

jngls commented Aug 1, 2025

Ok. There is no format!() macro in core at all, even with alloc. core only seems to have the formatting traits.

So, that means the formatting code path is only supported on std environments. I think we need to:

  • Export a std feature which is enabled by default.
  • In the formatting or any_has_format, add std feature checks and switch between formatting version/raw str
  • Also at the end where it appends #post_fix since that's only there to convert the String to &str ... but maybe we can just always use .into() instead

@jngls
Copy link
Author

jngls commented Aug 1, 2025

I've been unable to use:

quote! {
    #ident(#(#fields),*) => {
        #[cfg(feature = "std")]
        {
            let variant = #ident_transformed;
            format!(#fmt)
        }
        #[cfg(not(feature = "std"))]
        {
            #ident_transformed
        }
    }
}

This is because it mandates that the std feature is available in the user's crate, which we don't want.

However, I have a plan. The std feature will be available in enum-display, so we export a macro to wrap format!() and String::from() and that can just pass the raw variant string out for no_std.

It feels slightly hacky and I don't like the idea of exporting something the user isn't meant to see. But I'm not sure what else to do right now. Lemme know if you have any ideas. I'll push the code shortly.

Maybe this can serve as a first iteration and if we find a better method we can switch to that.

@jngls
Copy link
Author

jngls commented Aug 1, 2025

Actually, alloc does have a format.

And my no_std stuff is getting drawn out and fiddly.

I need to head home. Will pick this up again when I can.

@SeedyROM
Copy link
Owner

SeedyROM commented Aug 2, 2025

I never had any intention of supporting no-std actually, so if you can find a nice solution to add this feature that would be awesome! I support any ideas you have as you probably have more experience with it than me.

@SeedyROM
Copy link
Owner

SeedyROM commented Aug 2, 2025

I was able to "vibe-code" (lol) a simple no_std solution for the proc macro to use ::core::write!() instead of strings when possible.

diff --git a/enum-display-macro/src/lib.rs b/enum-display-macro/src/lib.rs
index 6483879..54628cb 100644
--- a/enum-display-macro/src/lib.rs
+++ b/enum-display-macro/src/lib.rs
@@ -150,9 +150,14 @@ impl NamedVariantIR {
         let fields = self.fields;
         match (any_has_format, attrs.format) {
             (true, Some(fmt)) => {
-                quote! { #ident { #(#fields),* } => { let variant = #ident_transformed; format!(#fmt) } }
+                quote! { #ident { #(#fields),* } => {
+                    let variant = #ident_transformed;
+                    ::core::write!(f, #fmt)
+                } }
+            }
+            (true, None) => {
+                quote! { #ident { .. } => ::core::fmt::Formatter::write_str(f, #ident_transformed), }
             }
-            (true, None) => quote! { #ident { .. } => String::from(#ident_transformed), },
             (false, None) => quote! { #ident { .. } => #ident_transformed, },
             _ => unreachable!(
                 "`any_has_format` should never be false when a variant has format string"
@@ -187,9 +192,14 @@ impl UnnamedVariantIR {
         let fields = self.fields;
         match (any_has_format, attrs.format) {
             (true, Some(fmt)) => {
-                quote! { #ident(#(#fields),*) => { let variant = #ident_transformed; format!(#fmt) } }
+                quote! { #ident(#(#fields),*) => {
+                    let variant = #ident_transformed;
+                    ::core::write!(f, #fmt)
+                } }
+            }
+            (true, None) => {
+                quote! { #ident(..) => ::core::fmt::Formatter::write_str(f, #ident_transformed), }
             }
-            (true, None) => quote! { #ident(..) => String::from(#ident_transformed), },
             (false, None) => quote! { #ident(..) => #ident_transformed, },
             _ => unreachable!(
                 "`any_has_format` should never be false when a variant has format string"
@@ -216,9 +226,14 @@ impl UnitVariantIR {
         } = self.info;
         match (any_has_format, attrs.format) {
             (true, Some(fmt)) => {
-                quote! { #ident => { let variant = #ident_transformed; format!(#fmt) } }
+                quote! { #ident => {
+                    let variant = #ident_transformed;
+                    ::core::write!(f, #fmt)
+                } }
+            }
+            (true, None) => {
+                quote! { #ident => ::core::fmt::Formatter::write_str(f, #ident_transformed), }
             }
-            (true, None) => quote! { #ident => String::from(#ident_transformed), },
             (false, None) => quote! { #ident => #ident_transformed, },
             _ => unreachable!(
                 "`any_has_format` should never be false when a variant has format string"
@@ -299,36 +314,44 @@ pub fn derive(input: TokenStream) -> TokenStream {
     .map(|variant| VariantIR::from_variant(variant, &enum_attrs))
     .collect();
 
-    // If any variants have a format string, the output of all match arms must be String instead of &str
-    // This is because we can't return a reference to the temporary output of format!()
+    // If any variants have a format string, we need to handle formatting differently
     let any_has_format = intermediate_variants.iter().any(|v| v.has_format());
-    let post_fix = if any_has_format {
-        quote! { .as_str() }
-    } else {
-        quote! {}
-    };
 
     // Build the match arms
     let variants = intermediate_variants
         .into_iter()
         .map(|v| v.generate(any_has_format));
 
-    // #[allow(unused_qualifications)] is needed
-    // due to https://github.com/SeedyROM/enum-display/issues/1
-    // Possibly related to https://github.com/rust-lang/rust/issues/96698
-    let output = quote! {
-        #[automatically_derived]
-        #[allow(unused_qualifications)]
-        impl #impl_generics ::core::fmt::Display for #ident #ty_generics #where_clause {
-            fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
-                ::core::fmt::Formatter::write_str(
-                    f,
+    let output = if any_has_format {
+        // When format strings are present, we write directly to the formatter
+        quote! {
+            #[automatically_derived]
+            #[allow(unused_qualifications)]
+            impl #impl_generics ::core::fmt::Display for #ident #ty_generics #where_clause {
+                fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
                     match self {
                         #(Self::#variants)*
-                    }#post_fix
-                )
+                    }
+                }
+            }
+        }
+    } else {
+        // When no format strings, we can return &str directly
+        quote! {
+            #[automatically_derived]
+            #[allow(unused_qualifications)]
+            impl #impl_generics ::core::fmt::Display for #ident #ty_generics #where_clause {
+                fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
+                    ::core::fmt::Formatter::write_str(
+                        f,
+                        match self {
+                            #(Self::#variants)*
+                        }
+                    )
+                }
             }
         }
     };
+
     output.into()
 }

There's a few other configuration changes to the main lib tests:

diff --git a/src/lib.rs b/src/lib.rs
index 62ea829..11379d3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,8 @@
 //!
-//! enum-display is a crate for implementing [`std::fmt::Display`] on enum variants with macros.
+//! enum-display is a crate for implementing [`core::fmt::Display`] on enum variants with macros.
+//!
+//! This crate supports both `std` and `no_std` environments. In `no_std` mode, it works
+//! without allocation by writing directly to the formatter.
 //!
 //! # Simple Example
 //!
@@ -31,8 +34,28 @@
 //!     HelloGreeting { name: String },
 //! }
 //!
+//! # #[cfg(feature = "std")]
 //! assert_eq!(Message::HelloGreeting { name: "Alice".to_string() }.to_string(), "hello-greeting");
+//! ```
+//!
+//! # No-std Usage
+//!
+//! This crate works in `no_std` environments:
+//!
+//! ```rust
+//! # #![cfg_attr(not(feature = "std"), no_std)]
+//! use enum_display::EnumDisplay;
 //!
+//! #[derive(EnumDisplay)]
+//! enum Status {
+//!     Ready,
+//!     
+//!     #[display("Error: {code}")]
+//!     Error { code: u32 },
+//! }
+//! ```
+
+#![cfg_attr(not(feature = "std"), no_std)]
 
 pub use enum_display_macro::*;
 
@@ -40,6 +63,15 @@ pub use enum_display_macro::*;
 mod tests {
     use super::*;
 
+    #[cfg(feature = "std")]
+    use std::string::{String, ToString};
+
+    #[cfg(not(feature = "std"))]
+    extern crate alloc;
+
+    #[cfg(not(feature = "std"))]
+    use alloc::string::{String, ToString};
+
     #[allow(dead_code)]
     #[derive(EnumDisplay)]
     enum TestEnum {
@@ -101,7 +133,7 @@ mod tests {
     #[derive(EnumDisplay)]
     enum TestEnumWithLifetimeAndGenerics<'a, T: Clone>
     where
-        T: std::fmt::Display,
+        T: core::fmt::Display,
     {
         Name,
         Address {

Same with added features in Cargo.toml:

diff --git a/Cargo.toml b/Cargo.toml
index 1bf8a55..34c8250 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,3 +14,18 @@ repository = "https://github.com/SeedyROM/enum-display"
 
 [dependencies]
 enum-display-macro = { version = "0.1.5", path = "./enum-display-macro" }
+
+[features]
+default = ["std"]
+std = []
+
+[dev-dependencies]
+static_assertions = "1.1"
+
+[[test]]
+name = "no_std_integration"
+required-features = []
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]

Integration test:

#![no_std]

extern crate alloc;
use alloc::string::ToString;
use enum_display::EnumDisplay;

#[derive(EnumDisplay)]
enum SimpleEnum {
    Red,
    Green,
    Blue,
}

#[derive(EnumDisplay)]
enum FormattedEnum {
    #[display("Custom: {variant}")]
    Format,

    #[display("Value is {value}")]
    Data { value: u32 },

    #[display("Tuple: ({0}, {1})")]
    Tuple(i32, i32),
}

#[derive(EnumDisplay)]
#[enum_display(case = "Snake")]
enum CaseTransformEnum {
    CamelCase,
    AnotherExample,
    XmlHttpRequest,
}

#[derive(EnumDisplay)]
enum ComplexEnum {
    Unit,

    Named {
        _field1: u32,
        _field2: alloc::string::String,
    },

    #[display("Complex: {variant} with {field1}")]
    NamedFormat {
        field1: u32,
        field2: alloc::string::String,
    },

    #[allow(dead_code)]
    Tuple(u32, alloc::string::String),

    #[display("Tuple: ({0})")]
    TupleFormat(u32),
}

#[test]
fn test_simple_enum() {
    assert_eq!(SimpleEnum::Red.to_string(), "Red");
    assert_eq!(SimpleEnum::Green.to_string(), "Green");
    assert_eq!(SimpleEnum::Blue.to_string(), "Blue");
}

#[test]
fn test_formatted_enum() {
    assert_eq!(FormattedEnum::Format.to_string(), "Custom: Format");
    assert_eq!(FormattedEnum::Data { value: 42 }.to_string(), "Value is 42");
    assert_eq!(FormattedEnum::Tuple(10, 20).to_string(), "Tuple: (10, 20)");
}

#[test]
fn test_case_transform() {
    assert_eq!(CaseTransformEnum::CamelCase.to_string(), "camel_case");
    assert_eq!(
        CaseTransformEnum::AnotherExample.to_string(),
        "another_example"
    );
    assert_eq!(
        CaseTransformEnum::XmlHttpRequest.to_string(),
        "xml_http_request"
    );
}

#[test]
fn test_complex_enum() {
    assert_eq!(ComplexEnum::Unit.to_string(), "Unit");

    assert_eq!(
        ComplexEnum::Named {
            _field1: 123,
            _field2: "test".into()
        }
        .to_string(),
        "Named"
    );

    assert_eq!(
        ComplexEnum::NamedFormat {
            field1: 456,
            field2: "ignored".into()
        }
        .to_string(),
        "Complex: NamedFormat with 456"
    );

    assert_eq!(ComplexEnum::Tuple(789, "value".into()).to_string(), "Tuple");

    assert_eq!(ComplexEnum::TupleFormat(999).to_string(), "Tuple: (999)");
}

#[test]
fn test_core_fmt_usage() {
    use core::fmt::Write;

    let mut buffer = alloc::string::String::new();

    // Test simple enum
    write!(&mut buffer, "{}", SimpleEnum::Red).unwrap();
    assert_eq!(buffer, "Red");

    buffer.clear();

    // Test formatted enum
    write!(&mut buffer, "{}", FormattedEnum::Format).unwrap();
    assert_eq!(buffer, "Custom: Format");

    buffer.clear();

    // Test case transform
    write!(&mut buffer, "{}", CaseTransformEnum::CamelCase).unwrap();
    assert_eq!(buffer, "camel_case");
}

// Test that Display trait is properly implemented
#[test]
fn test_display_trait() {
    fn accepts_display<T: core::fmt::Display>(item: T) -> alloc::string::String {
        alloc::format!("{item}")
    }

    assert_eq!(accepts_display(SimpleEnum::Red), "Red");
    assert_eq!(accepts_display(FormattedEnum::Format), "Custom: Format");
    assert_eq!(accepts_display(CaseTransformEnum::CamelCase), "camel_case");
}

// Test with generics
#[derive(EnumDisplay)]
enum GenericEnum<T: core::fmt::Display> {
    Value(T),

    #[display("Generic: {0}")]
    FormattedValue(T),
}

#[test]
fn test_generic_enum() {
    assert_eq!(GenericEnum::Value(42u32).to_string(), "Value");
    assert_eq!(
        GenericEnum::FormattedValue(42u32).to_string(),
        "Generic: 42"
    );
}

I think the most important changes are in the initial change to the proc macro. It also solves alot of the changes you made in your experimental branch I think??? I am dumb on this stuff but the tests are passing locally for me. We'll need more tests for embedded targets etc if we really want to verify. I think the main difference between out setups is the layout of the proc macro change. The config looks very close?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants